Senior Developer
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.
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:
Taken from the project page, by implementing page objects:
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('<front>'); $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 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('<front>'); $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.