Automated Web Testing with PHP and Selenium
Things break. Just the other day through a series of seemingly unrelated events, a new Microsoft x509 certificate made its way into a security handshake process which went unnoticed until current single sign-on sessions began to expire. Had we also had automated security testing, we would have caught this one-off. I’ll explain how I set up Selenium automated browser testing in a PHP environment, and how it can be used for more than just UI testing across browsers.
What is Selenium WebDriver?
Selenium automates browsers. That’s the first line of the Selenium HQ web site. To sell the idea of putting my energy into setting up automated web testing for my company, I explained that, boiled down, Selenium is a web driver available in PHP1 that lets you programmatically control the major desktop and mobile browsers, including clicking on things and inspecting elements – perfect for automated web site testing.
I’ll show you how to get all the pieces working together for an example.
Learning Selenium
If you are interested, I recommend a Lynda.com course2 on Selenium to quickly get familiar or refreshed in it.
Install WebDriver bindings for PHP
Selenium WebDriver bindings for PHP are available in the Facebook Github repo. They can be installed via composer.
yum install php7.2-zip
(CentOS) – ext-zip is required for the composer installation below. This may install several more PHP 7 packages also.composer require facebook/webdriver
– Install the PHP bindings for Selenium
Install Selenium Standalone Server and Javaw
Facebook provides instructions here [https://github.com/facebook/php-webdriver], but you only need to download the selenium-server-standalone-#.jar
standalone Java JAR file. But first, you should install the latest version of Java.
For now, you can just double-click on the jar file to start the standalone Selenium server on port 4444. You can then access it in a browser on the same machine at http://localhost:4444/wd/hub/
. Later, I’ll be using a batch file to pass in command-line arguments.
You can also verify that javaw.exe
is listening for connections on default port 4444 in PowerShell with netstat -a -o -b -n
3:
The Selenium PHP bindings installed above use the same server address by default.
To terminate the process in Windows 10, open the task manager, go to the details tab, right-click on any column header and click “Select columns”. Check “Command line”. You can then scroll down to the Java process that loads the JAR file you double-clicked and end its process. When I make a batch file, this will be unnecessary.
Test the Setup
Facebook has an example.php on Github that you can use to test your setup. Most of the examples of Selenium usage are in Java, so you will have to convert examples to PHP. For example,
1 2 3 4 5 | // Java // driver.manage().window().setPosition(new Point(0,0)); // PHP $driver->manage()->window()->setPosition(new WebDriverPoint(0,0)); |
However, most IDEs will have code completion, so it will be a breeze to convert example code to PHP. Here is a sample script that navigates to a page and takes a screenshot on this site.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | <?php use Facebook\WebDriver\Remote\DesiredCapabilities; use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\WebDriverBy; use Facebook\WebDriver\WebDriverDimension; use Facebook\WebDriver\WebDriverExpectedCondition; use Facebook\WebDriver\WebDriverPoint; require_once __DIR__ . '/../vendor/autoload.php'; // Use the remote addr to locate where javaw is running $host = 'http://'.$_SERVER['REMOTE_ADDR'].':4444/wd/hub'; // this is the default port echo "WebDriver: $host<br>"; // See all the capabilities here: https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities $driver = RemoteWebDriver::create($host, DesiredCapabilities::chrome()); // Set size $driver->manage()->window()->setPosition(new WebDriverPoint(0,0)); $driver->manage()->window()->setSize(new WebDriverDimension(1280,800)); // Navigate to ED $driver->get('https://ericdraken.com'); // Wait at most 10s until the page is loaded $driver->wait(10)->until( WebDriverExpectedCondition::titleContains('Eric Draken') ); // Click the link 'About Eric' $link = $driver->findElement( WebDriverBy::linkText('About Eric') ); $link->click(); // Wait at most 10s until the page is loaded $driver->wait(10)->until( WebDriverExpectedCondition::titleContains('Eric Draken') ); // Print the title of the current page echo "The title is " . $driver->getTitle() . "<br>"; // Take a screenshot $driver->takeScreenshot(__DIR__ . "/screenshots/ericdraken.png"); // Close the Chrome browser $driver->quit(); // Display the screenshot ?><img src="/screenshots/ericdraken.png"> |
Results:
Install PHPUnit and Monolog
Now let’s combine PHPUnit with WebDriver and the Facebook PHP bindings to do some really cool testing. First, as per the installation docs, here is how to install PHPUnit with composer. You could instead just download the latest PHAR file, but I like installing PHPUnit via composer so that when PHPUnit is updated I can see the diff
of changes.
composer require phpunit/phpunit ^6.3
4
Let’s also add Monolog via composer for versatile logging. I like sending alerts and notices to Slack for my team to see as well.
composer require monolog/monolog
Install a Logger Test Listener
One final package that will tie test results to a PSR-3 logger is a logger test listener. This listens to PHPUnit test results and delivers test outcomes to the Monolog Slack logger, or to any one of many endpoints if you like. However, it was last updated over 2 years ago and does not work with the latest PHPUnit without a slight modification. Other test listeners have the same issue. I’ll outline an easy fix shortly.
composer require bartlett/phpunit-loggertestlistener
PHPUnit_Framework_TestCase
) to PSR-4 namespaces (e.g. PHPUnit\Framework\TestCase
) and extended their TestListener interface. To make the bartlett package work here, you will need to make use of class_alias
to map the old names to the new namespaces, plus add a addWarning(...)
method to a listener through inheritance.Here is how I mapped the underscore names to namespaces. You could also make your own SPL autoloader. This below is sufficient for today’s purpose.
1 2 3 4 5 6 7 8 9 10 11 12 13 | // Map the PHPUnit < 6 underscore classes to PHPUnit >= 6 namespace classes if ( !class_exists('\PHPUnit_Framework_Test') ) { class_alias('\PHPUnit\Framework\TestListener', '\PHPUnit_Framework_TestListener'); class_alias('\PHPUnit\Framework\Test', '\PHPUnit_Framework_Test'); class_alias('\PHPUnit\Framework\TestCase', '\PHPUnit_Framework_TestCase'); class_alias('\PHPUnit\Framework\TestResult', '\PHPUnit_Framework_TestResult'); class_alias('\PHPUnit\Framework\TestSuite', '\PHPUnit_Framework_TestSuite'); class_alias('\PHPUnit\Framework\AssertionFailedError', '\PHPUnit_Framework_AssertionFailedError'); class_alias('\PHPUnit\Util\Filter', '\PHPUnit_Util_Filter'); class_alias('\PHPUnit\Util\Test', '\PHPUnit_Util_Test'); class_alias('\PHPUnit\Runner\BaseTestRunner', '\PHPUnit_Runner_BaseTestRunner'); } |
Setup Composer Autoload for the Project
You can either run unit tests directly in the IDE (where you can see a red or green status bar), on the command line, or on-demand via a PHP. I’m alone on my team in using PhpStrom5, and the one most familiar with PHPUnit, so I’ll make a test runner API that anyone can use if they clone my project.
To that end, here is my composer.json
so far where I have the above dependencies, plus my custom test runner PSR-4 namespace entry. By adding this to composer.json
and running composer update
and including vendor/autoload.php
I can autoload PHP classes in the [project root]/src/PHPUnit/
folder. See below. This technique can be expanded to organize other classes into different namespaces.
Next I’ll create a test runner supervisor that runs tests and sends the results to Slack.
Create the Specialized Test Suites
Above are all the tools needed to perform automated PHPUnit tests with Selenium WebDriver. Here is a sample test driver to get started.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | <?php use Monolog\Logger; use PHPUnit\PHPUnitTestSupervisor; use PHPUnit\SlackHandlerExtended; require_once __DIR__ . '/../vendor/autoload.php'; // Set the timezone of the delivered messages Logger::setTimezone(new \DateTimeZone('America/Vancouver')); // Slack credentials and channel $channel = "unit-tests"; $token = "xoxb-************-*********************"; // Attach a special Slack handler $slackHandler = new SlackHandlerExtended($channel, $token); // Add extra information to the Slack log // e.g. {"url":"/unittests.php","ip":"192.168.40.1","http_method":"GET","server":"ci.example.com","referrer":null} $slackHandler->pushProcessor(new \Monolog\Processor\WebProcessor()); // Set the minimum report level to warnings and above $slackHandler->setLevel(Logger::WARNING); // Instantiate the logger $logger = new Logger($channel); $logger->pushHandler($slackHandler); // Run all the tests in the '[project root]/tests' folder $root = realpath($_SERVER['DOCUMENT_ROOT'] . '/../'); $results = PHPUnitTestSupervisor::runTests($root . '/tests', $logger, [], $display); // Display the test results echo '<pre>'; echo "PHPUnit tests:" . PHP_EOL; echo $display; echo '</pre>'; |
I’ve crafted a few helper classes that create test suites, run custom PHPUnit commands and monitor test results. Now I will take the earlier Selenium driver script and convert it into a couple tests:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | <?php use Facebook\WebDriver\Remote\DesiredCapabilities; use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\WebDriverBy; use Facebook\WebDriver\WebDriverDimension; use Facebook\WebDriver\WebDriverExpectedCondition; use Facebook\WebDriver\WebDriverPoint; class SimpleSeleniumTest extends \PHPUnit\Framework\TestCase { /** @var RemoteWebDriver */ private static $driver; /** * Open the Chrome browser just once to be reused on each test. */ public static function setupBeforeClass() { $host = 'http://'.$_SERVER['REMOTE_ADDR'].':4444/wd/hub'; // this is the default port $driver = RemoteWebDriver::create($host, DesiredCapabilities::chrome()); // Set size $driver->manage()->window()->setPosition(new WebDriverPoint(30,30)); $driver->manage()->window()->setSize(new WebDriverDimension(1280,800)); self::$driver = $driver; } /** * Before each test the same browser instance will be reused, * so clear cookies, local storage, etc., or open a new incognito tab */ public function setUp() {} /** * After all tests have finished, quit the browser, * even if an exception was thrown and/or tests fail */ public static function tearDownAfterClass() { // Close the browser self::$driver->quit(); } /** * Verify the page title */ public function testPageTitle() { // Navigate to ED self::$driver->get('https://ericdraken.com'); // Verify the title contains my name $this->assertContains("Eric Draken", self::$driver->getTitle()); } /** * Test clicking "About Eric" and taking a screenshot * @depends testPageTitle */ public function testScreenshot() { $driver = self::$driver; // Navigate to ED $driver->get('https://ericdraken.com'); // Click the link 'About Eric' $link = $driver->findElement( WebDriverBy::linkText('About Eric') ); $link->click(); // Wait until the page is loaded $driver->wait(15)->until( WebDriverExpectedCondition::titleContains('Eric Draken') ); // Take a screenshot $path = sys_get_temp_dir() . "/testScreenshot-ericdraken.png"; $driver->takeScreenshot($path); // Verify the screenshot was taken $this->assertFileExists($path); // Verify the file length is greater than 0 $this->assertTrue( filesize($path) > 0 ); // Cleanup unlink($path); } } |
The real excitement comes when you create test suites for specific purposes. For example:
- Finding broken links (combine with Guzzle)
- Finding JavaScript errors and warnings
- Spellchecking (e.g. color vs colour)
- Testing page load time (e.g. success is under 2s)
All of the above kinds of test suites can run on any domain, and the sitemap.xml
can feed into them. These are the tests I’m most interested in because I can write them once and they keep working even when the site changes.
Browser Support for WebDriver
Chrome and FireFox WebDriver support comes out of the box. To test with Microsoft Edge, you must download the MicrosoftWebDriver.exe and indicate its path because starting the local EdgeDriver is not supported yet in the PHP bindings.
In fact, you will probably have to download several WebDriver binaries for the browsers you wish to test. I’ve created a folder to hold these binaries. They will need to be updated when browser major versions change. Also, since I am using PHP, I cannot pass in the paths to the WebDriver binaries at runtime. Instead, I’ll use a batch file to pass in the binary paths to the Java executable. See below.
1 2 3 4 5 6 7 8 9 10 | @echo off title Selenium Java Server echo Starting Selenium Java server :: Pass in WebDriver paths java^ -jar^ -Dwebdriver.edge.driver=".\..\webdrivers\MicrosoftWebDriver.exe"^ -Dwebdriver.chrome.driver=".\..\webdrivers\chromedriver.exe"^ "selenium-server-standalone-3.5.3.jar" |
You can now run this batch file and it will stay open in a window, and you will not have to manually terminate a running process again. Plus, all the WebDriver activity will be logged to this window as well.
Going Beyond Automated Testing
Other test suites can be created to test specific pages, forms, popups, navigation, exit-intent, and so forth. Other test suites can test how our ads appear in Google, Bing and in Facebook, or even what position some of our ads appear at. When combined with a VPN, a form of “testing” could be intelligence on your competition. Maybe you don’t want to sign into and navigate through Google Analytics every day to check on conversion rates? Daily screenshots for marketing!
Automation of this kind can do the work of several people. If the tests build upon modules which build upon objects that follow the single responsibility principle, that is, each object is responsible a single function, then it is possible to build highly reusable code that a team can manage.
Notes:
- Selenium is a company, Selenium WebDriver is a W3C draft of a standard browser API, which at this time is in the RC stage, and there is a Selenium PHP API wrapper available by Facebook. But, these details were glossed over for the ‘elevator pitch’. ↩
- Lynda.com is free in some areas if you have a library card ↩
- -b shows the binaries associated with the open port. ↩
- PHPUnit 6.3 onward requires PHP 7, which is highly encouraged. ↩
- And wow do I love PhpStorm. I cannot praise JetBrains enough over this IDE. ↩