Introduction To Page Objects
First published at Tuesday 6 September 2016
Warning: This blog post is more then 8 years old – read and use with care.
Introduction To Page Objects
A while ago we wrote about writing acceptance tests (end-to-end tests) with Mink and PHPUnit. While this is a great set of tools for various applications such tests tend be susceptible to changes in the frontend. And the way they break is often hard to debug, too. Today I will introduce you to Page Objects which can solve some of these problems.
The basic idea behind a Page Object is that you get an object oriented representation of your website. The Page Objects maps the HTML (or JSON) to an object oriented structure you can interact with and assert on. This is more initial work then than writing tests with PHPUnit and Mink directly, but it can be worth the effort. I will introduce you to Page Objects by writing some simple tests for Tideways – our application performance monitoring platform.
Groundwork
We will again use the awesome Mink to simulate browsers and make it easy to interact with a website. Thus we are actually re-using the FeatureTest
base class from the Using Mink in PHPUnit blog post. We have set up a repository where can take a full look at the working example and maybe even try it out yourself.
You'll need some tools to set this up – in the mentioned repository it is sufficient to execute composer install
. Setting it up in a new projects you'd execute something like:
composer require --dev phpunit/phpunit behat/mink behat/mink-goutte-driver
The FeatureTest base class handles the basic mink interaction and has already been discussed in the last blog post so that we can skip it here.
A First Test
As mentioned we want to test Tideways and Tideways requires you to login. Thus we start with a simple login test:
class LoginTest extends FeatureTest
{
public function testLogInWithWrongPassword()
{
$page = (new Page\Login($this->session))->visit(Page\Login::PATH);
$page->setUser(getenv('USER'));
$page->setPassword('wrongPassword');
$newPage = $page->login();
$this->assertInstanceOf(Page\Login::class, $newPage);
}
// …
}
This test already uses a page object by instantiating the class Page\Login
. And by using this one it makes the test very easy to read. You instantiate the page, visit()
it and then interact with it in an obvious way. We set username and password, and then call login()
. Since we set a wrong password we expect to stay on the login page.
This already is the nice thing with page objects. The test are readable and this is something we want to optimize for, right?
One the other hand the logic must be implemented in the Page Object. By implementing it in a Page Object it is re-usable in other tests as you will see later. So let's take a look at this simple Page Object:
use Qafoo\Page;
class Login extends Page
{
const PATH = '/login';
public function setUser($user)
{
$this->find('input[name="_username"]')->setValue($user);
}
public function setPassword($password)
{
$this->find('input[name="_password"]')->setValue($password);
}
public function login()
{
$this->find('input[name="_submit"]')->press();
return $this->createFromDocument();
}
}
Since we use Mink and implement some logic in the Qafoo\Page
base class this still does not look that complex. What you should note is the fact that the method setUser()
(and alike) hide the interaction with the DOM tree. If the name of those form fields change you'll have to change it in one single location. The methods find()
and visitPath()
can be found in the Page base class and just abstract Mink a little bit and provide better error messages if something fails.
The login()
method will execute a HTTP request to some page. If the login failed we will be redirected back to the login page (like in the test above), otherwise we expect to be redirected to the dashboard:
public function testSuccessfulLogIn()
{
$page = (new Page\Login($this->session))->visit(Page\Login::PATH);
$page->setUser(getenv('USER'));
$page->setPassword(getenv('PASSWORD'));
$newPage = $page->login();
$this->assertInstanceOf(Page\Dashboard::class, $newPage);
}
We expect the user name and password to be set as environment variables since there are no public logins for Tideways. If you want to run the tests yourself, just create an account and provide them like mentioned in the README.
There is one "magic" method left in the page object shown before – the method createFromDocument()
. The method maps the path of the last request back to Page Object. Something like the router in about every framework would do, but we map to a Page Object instead of a controller. This method will get more complex for complex routes but it helps us to make assertions on the resulting page.
Refactoring The Frontend
We recently migrated the dashboard from being plain HTML rendered using Twig templates on the server into a React.js component. What happens to our page objects in this case? Let's take a look at our dashboard tests first:
class DashboardTest extends FeatureTest
{
use Helper\User;
// …
public function testHasDemoOrganization()
{
$this->logIn();
$page = (new Page\Dashboard($this->session))->visit(Page\Dashboard::PATH);
$organizations = $page->getOrganizations();
$this->assertArrayHasKey('demo', $organizations);
return $organizations['demo'];
}
// …
}
This test again makes assertions on a page object – now Page\Dashboard
which can be instantiated after logging in successfully. The test itself does not reveal in any way if we are asserting on HTML or some JSON data. It is simple and asserts that we find the demo
organization in the current users account (which you might need to enable).
So let's take a look at the dashboard Page Object, where the magic happens:
class Dashboard extends Page
{
const PATH = '/dashboard';
public function getOrganizations()
{
$dataElement = $this->find('[data-dashboard]');
$dataUrl = $dataElement->getAttribute('data-dashboard');
$data = json_decode($this->visitPath($dataUrl)->getContent());
\PHPUnit_FrameWork_Assert::assertNotNull($data, "Failed to parse JSON response");
$organizations = array();
foreach ($data->organizations as $organization) {
$organizations[$organization->name] = new Dashboard\Organization($organization, $data->applications);
}
return $organizations;
}
}
We are currently migrating (step by step) from jQuery modules to React.js components and are still using data attributes to trigger loading React.js components in the UI. Instead of asserting on the HTML, what we would have done when still rendering the dashboard on the server side, we check for such a data attribute and load the data from the server. For each organization found on the page we then return another object which represents a partial (organization) on the page.
Using this object oriented abstraction of the page allows us to transparently switch between plain HTML rendering and React components while the test will look just like before. The only thing changed is the page object, but this one can still power many tests which can make the effort worth it. On top of those Organization
partials we can then execute additional assertions:
/**
* @depends testHasDemoOrganization
*/
public function testMonthlyRequestLimitReached(Page\Dashboard\Organization $organization)
{
$this->assertFalse($organization->getMonthlyRequestLimitReached());
}
/**
* @depends testHasDemoOrganization
*/
public function testHasDemoApplications(Page\Dashboard\Organization $organization)
{
$this->assertCount(3, $organization->getApplications());
}
// …
Problems With Page Objects
As you probably can guess providing a full abstraction for your frontend will take some time to write. Those page objects can also get slightly more complex, so that you might even feel like testing them at some point.
Since end-to-end tests also will always be slow'ish (compared to unit tests) we advise to only write those tests for critical parts of your application. The tests will execute full HTTP requests which take time – and nobody runs a test suite which takes multiple hours to execute.
Also remember that, like with any integration test, you probably need some means to setup and reset the environment the tests run on. In this simple example we run the test on the live system and assume that the user has the demo organization enabled. In a real-world scenario you'd boot up your application (provision a vagrant box or docker container), reset the database, mock some services, prime the database and run the tests against such a reproducible environment. This takes more effort to implement, again.
While the tests are immune to UI changes this way (as long as the same data is still available) they are not immune to workflow changes. If your team adds another step to a checkout process, for example, the corresponding Page Object tests will still fail and you'll have to adapt them.
Conclusion
Page Objects can be a good approach to write mid-term stable end-to-end tests even for complex applications. By investing more time in your tests you can get very readable tests which are easy to adapt to most UI changes.
Subscribe to updates
There are multiple ways to stay updated with new posts on my blog: