Functional testing with PHP page objects and Mink
Page objects are a pattern that can be used to write clearer and more resilient test suites. This blog post will explore implementing page objects in PHP with the Mink library.
by
Sam Becker
/ 29 July 2019
There are various PHP libraries for creating and maintaining page objects. In order to create a library that was useful for the current state of PHP functional testing in Drupal, I created a library with the design goals of:
- Working seamlessly with Drupal core test classes, traits and weitzman/drupal-test-traits.
- Working with all of Drupal's dev dependency version constraints and not introducing additional dependencies.
- Exclusively utilising the Mink API, to provide a fast on-ramp for moving existing tests to page objects and for developers to write new page objects using their existing knowledge of Mink.
- Drawing inspiration from nightwatch.js to provide transferability between PHP and JS functional tests.
Taken from the project page, by implementing page objects:
- You create tests that are easier to read and maintain.
- You reduce coupling between test cases and markup.
- You encourage thorough testing by making the whole process easier.
While these examples will be using sam152/mink-page-objects the principles apply to using any library or indeed plain old objects. First I'll examine a real project test case using Mink directly, written to test a search feature on a Drupal site:
/**
* Test how search results appear on the site.
*/
public function testSearchItemDisplay() {
$sample_result = $this->randomMachineName(32);
$this->createNode([
'title' => $sample_result,
'type' => 'news_item',
'body' => ['value' => 'Test news item body'],
'moderation_state' => 'published',
]);
$this->searchApiIndexItems();
$this->drupalGet('');
$this->submitForm([
'query' => $sample_result,
], 'Search');
$this->assertSession()->pageTextContains('1 results for');
$this->assertSession()->elementContains('css', 'h1', $sample_result);
$this->assertSession()->elementContains('css', '.sidebar-menu__item--active', 'Show all');
$this->assertSession()->elementContains('css', '.listing', $sample_result);
// A news item should not appear when filtering by basic pages.
$this->clickLink('Basic page');
$this->assertSession()->pageTextContains('0 results for');
$this->assertSession()->elementContains('css', '.sidebar-menu__item--active', 'Basic page');
$this->clickLink('News item');
$this->assertSession()->elementContains('css', '.sidebar-menu__item--active', 'News item');
$this->assertSession()->elementContains('css', '.listing', $sample_result);
}
And now the equivalent test refactored to use a page object:
/**
* Test how search results appear on the site.
*/
public function testSearchItemDisplayPageObject() {
$sample_result = $this->randomMachineName(32);
$this->createNode([
'title' => $sample_result,
'type' => 'news_item',
'body' => ['value' => 'Test news item body'],
'moderation_state' => 'published',
]);
$this->searchApiIndexItems();
$search_page = SearchPage::create($this);
$search_page->executeSearch($sample_result)
->elementContains('@title', $sample_result)
->assertResultCount(1)
->assertResultsContain($sample_result)
->assertActiveFilter('Show all');
$this->clickLink('Basic page');
$search_page->assertActiveFilter('Basic page')
->assertResultCount(0);
$this->clickLink('News item');
$search_page->assertActiveFilter('News item')
->assertResultCount(1)
->assertResultsContain($sample_result);
}
In the second test, there are a few advantages:
- The code is more DRY, since selectors on the page aren't repeated. In fact, if the page object was used for all future search tests, they'd never be repeated in a test again!
- The test uses a more natural language that is easier to parse by readers of the code and communicates the intentions of the author in a clearer fashion.
- The search page object is type-hinted, making writing new tests fast and reducing the amount of page related knowledge developers must collect and remember.
The cost paid for these benefits is an additional layer of indirection between your test case and the test browser, so to realise the full benefit of such an approach, I'd expect a page object to be written to service at least two different test cases however I haven't experimented implementing this pattern across a large scale test suite.
An annotated version of the page object (for the purposes of demonstration) looks like:
/**
* A page object for the search page.
*/
class SearchPage extends DrupalPageObjectBase {
/**
* {@inheritdoc}
*/
protected function getElements() {
// Selectors found on the page, these can be referenced from any of the Mink
// API calls within this page object.
return [
'title' => 'h1',
'results' => '.listing',
'activeFilter' => '.sidebar-menu__item--active',
];
}
/**
* Assert the number of results on the search page.
*
* @param int $count
* The number of items.
*
* @return $this
*/
public function assertResultCount($count) {
$this->assertSession()->pageTextContains("$count results for");
return $this;
}
/**
* Assert a string appears on the page.
*
* @param string $string
* The string that should appear on the page.
*
* @return $this
*/
public function assertResultsContain($string) {
$this->elementContains('@results', $string);
return $this;
}
/**
* Assert a string does not appear on the page.
*
* @param string $string
* The string that should not appear on the page.
*
* @return $this
*/
public function assertResultsNotContain($string) {
$this->elementNotContains('@results', $string);
return $this;
}
/**
* Assert the active filter.
*
* @param string $filter
* The active filter.
*
* @return $this
*/
public function assertActiveFilter($filter) {
$this->elementContains('@activeFilter', $filter);
return $this;
}
/**
* Execute a search query.
*
* @param string $query
* A search query.
*
* @return $this
*/
public function executeSearch($query) {
$this->drupalGet('');
$this->submitForm([
'query' => $query,
], 'Search');
return $this;
}
}
While the library itself is decoupled from Drupal, the DrupalPageObjectBase base class integrates a few additional Drupal features such as UiHelperTrait for methods like ::drupalGet and ::submitForm as well as creating a ::create factory to automatically wire dependencies from Drupal tests into the page object itself.
I would be interested in hearing thoughts on if introducing page objects may benefit Drupal core's own functional test suite and details on how that might be accomplished given the tools available.
Tagged