development icon

Ajax Testing in Drupal 8

Dan Muzyka, Software Architect
#Drupal 8 | Posted

Drupal has long supported automated testing, but until recently Drupal’s framework provided little support for testing Javascript code. To test ajax behaviors, a developer could write tests that simulated an ajax request and then validated the ajax response returned by the backend PHP code, but such a test would not run or validate Javascript code itself. Drupal 8.1 changed this situation when it shipped with a new class for writing Javascript tests, appropriately named  JavascriptTestBase. 

This post addresses ajax testing in Drupal 8 using the example of a module I’ve been helping to port to Drupal 8, Ajax Comments.

Benefits

Before we discuss how to write automated tests for ajax functionality in Drupal 8, let’s quickly review why you might do that.

Automated testing provides numerous benefits to your application. Not only does it help catch bugs introduced by future code updates (regressions), it also forces the developer to think about the code she is writing outside of the current, immediate context. Looking at the code in a more general context can be challenging when a developer is working on a specific project, in which the code is being used in one or two particular ways, and is interfacing with other application code in a very specific way.

In the case of Ajax Comments, I had been writing and testing the code for the Drupal 8 version on a site that had the Big Pipe module enabled. Due to the way that Big Pipe affects updates to the browser’s Document Object Model (DOM), some Javascript errors caused by the way that  Drupal.attachBehaviors() and Drupal.detachBehaviors() were affecting elements in the DOM did not become apparent while I was writing the initial version of the Drupal 8 Ajax Comments code. The Javascript tests, however, which did not assume that Big Pipe was enabled, were able to catch the issue while simulating the use of Ajax Comments in a default, minimally-configured environment.

Ajax testing in Drupal: Then and now

The release of Drupal 8.1 occurred while I was in the early stages of helping to port the Ajax Comments module to Drupal 8. I had already been writing automated tests for the D8 port using the old method, extending the WebTestBase class. The introduction of  JavascriptTestBase provided a way to test not just the data structures returned in ajax responses, but also the Javascript code actually making the ajax requests and receiving the ajax responses.

Given that I had already written some tests for ajax functionality by extending the  WebTestBase  class, I decided to continue writing tests using that method, and to also write tests that extended  JavascriptTestBase. As a result, I can show you side-by-side comparisons of both approaches. Following are code excerpts that test posting a comment using ajax. The first example uses  WebTestBase:

$this->drupalGet('node/' . $this->node->id());
$comment_text = $this->randomMachineName();
$edit = [
 'comment_body[0][value]'  => $comment_text,
];
$ajax_result = $this->drupalPostAjaxForm(NULL, $edit, ['op' => t('Save')]);
// Loop through the responses to find the replacement command.
foreach ($ajax_result as $index => $command) {
 if ($command['command'] === 'insert' && $command['method'] === 'replaceWith') {
   $this->setRawContent($command['data']);
   $this->pass('Ajax replacement content: ' . $command['data']);
 }
}
$this->assertText($comment_text, 'Comment posted.');
$this->pass('Comment: ' . $comment_text);

The next example also tests posting a comment through ajax, but this test extends  JavascriptTestBase:

$this->drupalGet($node->toUrl());
$page = $this->getSession()->getPage();

// Post comments through ajax.
for ($i = 0; $i < 2; $i++) {
  $comment_body_id = $page
    ->findField('comment_body[0][value]')
    ->getAttribute('id');
  $count = $i + 1;
  <span style="font-weight: 400;">// Insert a comment in the CKEditor instance attached to the comment body field.</span>
  $ckeditor_javascript = <<<JS
(function (){
  CKEDITOR.instances['$comment_body_id'].setData('New comment $count');
}());
JS;
  $this->getSession()->executeScript($ckeditor_javascript);
  $page->pressButton('Save');
  $this->assertSession()->assertWaitOnAjaxRequest(20000);
}

// Export the updated content of the page.
if ($this->htmlOutputEnabled) {
  $out = $page->getContent();
  $this->htmlOutput($out);
}
$this->assertSession()->pageTextContains('Your comment has been posted.');
$this->assertSession()->pageTextContains('New comment 1');
$this->assertSession()->pageTextContains('New comment 2');

The code for each version of the test starts off similar, with a command to load a specific node page. The code to actually submit a comment on that page through ajax, however, is radically different between the two examples. In the WebTestBase version of the test, the code uses  $this->drupalPostAjaxForm() to generate an HTTP request that simulates an ajax request. It then directly loads the content of the HTTP response, manually sets it as the content of the current page using  $this->setRawContent(), and then tests that the page content matches the expected value using  $this->assertText(). In the  JavascriptTestBase version, however, the test actually simulates a user entering text into a comment body field (and in this case, a body field with the CKEditor WYSIWYG editor loaded), clicking on the Save button, and then testing that the expected values are in the updated page content.

The key takeaway here is that tests that extend  JavascriptTestBase actually load the page and run the Javascript on it, thereby testing the Javascript code itself. When testing ajax functionality, the test provides a more realistic representation of a user interacting with the site, rather than simulating what the ajax endpoint on the backend would return if the Javascript code behaved in the manner expected and made the expected HTTP requests.

Setting up your environment

To actually run tests that extend  JavascriptTestBase, you’ll need to make a few changes to your environment, including setting up PhantomJS, a headless web browser that loads and interacts with your site in much the same way as a browser operated by an end user. The use of PhantomJS is what allows the Javascript on your site to actually run while your tests execute.

The steps to setup your environment are installing PhantomJS; starting it up; setting up a directory where the test results will be written; creating or editing the core/phpunit.xml file; and running the command to start the tests. Following is a detailed breakdown of each step.

1. Download and install PhantomJS per the instructions on the PhantomJS website. For a Mac development machine, if you use homebrew, you can run brew install phantomjs. Otherwise, download the zip file, unzip it to a directory, move the resulting directory to  /usr/local/phantomjs, confirm that the phantomjs binary is executable, and symlink  /usr/local/bin/phantomjs to /usr/local/phantomjs/bin/phantomjs:

  • $ mv ~/Downloads/phantomjs-2.1.1-macosx/ /usr/local/phantomjs
  • $ chmod a+x /usr/local/bin/phantomjs
  • $ ln -s /usr/local/phantomjs/bin/phantomjs /usr/local/bin/phantomjs

2. Assuming that you saved the phantomjs executable file to somewhere in your system path, such as  /usr/local/bin/phantomjs (as we did in the previous step), start up phantomjs using the following command. Execute it from the root of your project, so that the  vendor/jcalderonzumba/gastonjs/src/Client/main.js path is correct relative to your current position in the directory structure: 

$ phantomjs --ssl-protocol=any --ignore-ssl-errors=true vendor/jcalderonzumba/gastonjs/src/Client/main.js 8510 1024 768

Note: I found it helpful to create a bash alias for this command so that I didn’t have to type it everytime. To do so, add the following to your  ~/.bashrc file:

alias phantomtestserver="phantomjs --ssl-protocol=any --ignore-ssl-errors=true vendor/jcalderonzumba/gastonjs/src/Client/main.js 8510 1024 768"

Additional Note: The command line will appear to stall after PhantomJS starts up, until you start running your test. This is expected behavior. You’ll leave PhantomJS running here, while you run the test in a separate terminal window or tab. When you are finished using PhantomJS, you can stop it running in this terminal window by entering Control+C.

3. Next, set up a directory where the output files generated by the test can be saved. In my case, I used  sites/default/files/simpletest/. Set the owner and group to the webserver user (for example, _www  on Macs, apache  or www-data  on many versions of Linux).

  • $ mkdir sites/default/files/simpletest
  • $ sudo chown -R _www:_www sites/default/files/simpletest/

4. If you haven’t done so already, you’ll need to copy the  core/phpunit.xml.dist file to  core/phpunit.xml, and then edit the following lines to use your local site’s hostname and database credentials, and the writeable file directory you created in the previous step:

  <!-- Example SIMPLETEST_BASE_URL value: http://localhost -->
  <env name="SIMPLETEST_BASE_URL" value="http://drupal-8.dev"/>
  <!-- Example SIMPLETEST_DB value: mysql://username:password@localhost/databasename#table_prefix -->
  <env name="SIMPLETEST_DB" value="mysql://drupal-8:drupal-8@localhost/drupal-8"/>
  <!-- Example BROWSERTEST_OUTPUT_DIRECTORY value: /path/to/webroot/sites/simpletest/browser_output -->
  <env name="BROWSERTEST_OUTPUT_DIRECTORY" value="sites/default/files/simpletest"/>

Running a Javascript functional test

Once you setup your environment, you can run Javascript functional tests in your Drupal environment. Before running a Javascript functional test, be sure to follow the setup steps described in the previous section, and be sure that the PhantomJS server is running in another terminal window or tab.

To initiate a test, enter a command in your terminal similar to the following example. This example uses the Javascript test for Ajax Comments:

$ sudo -u _www ./vendor/bin/phpunit -c core/phpunit.xml --printer="\Drupal\Tests\Listeners\HtmlOutputPrinter" modules/contrib/ajax_comments/tests/src/FunctionalJavascript/AjaxCommentsFunctionalTest.php

Here is a breakdown of the above command:

sudo -u _www – Run this command as  _www to ensure that you have permission to write the output files to your BROWSERTEST_OUTPUT_DIRECTORY.

./vendor/bin/phpunit – This is the path to the PHPUnit executable.

-c core/phpunit.xml  – Use the  -c or  --configuration flag to specify a configuration file to use when running the test.

--printer="\Drupal\Tests\Listeners\HtmlOutputPrinter" – The  --printer flag is used here to trigger the  HtmlOutputPrinter, which saves static snapshots of the page being tested at various points during the test. This feature is immensely useful for debugging your test!

modules/contrib/ajax_comments/tests/src/FunctionalJavascript/AjaxCommentsFunctionalTest.php – This is the path to the file that actually has the test.

After you run the test, you should see output similar to the following:

PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

.

Time: 30.31 seconds, Memory: 6.00MB

OK (1 test, 18 assertions)

HTML output was generated
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…
http://drupal-8.dev/sites/simpletest/browser_output/Drupal_Tests_ajax_c…

The great news in the above example is that all of the tests passed! You can look at the snapshots of the page at each stage of the test by copying the URLs in the test output and pasting them into a browser window. Again, if something in the test broken looking at the snapshots of the page can be an invaluable debugging technique.

Examples in core

A great way to get help writing your own Javascript tests is to look at examples from Drupal core. As of the time of this writing, there are several such examples:

core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php
core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php
core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php
core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInJavascriptTestBase.php
core/modules/simpletest/tests/src/FunctionalJavascript/BrowserWithJavascriptTest.php
core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php
core/modules/views/tests/src/FunctionalJavascript/ClickSortingAJAXTest.php
core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php
core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php
core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FieldTest.php
core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxThemeTest.php

While writing the tests for Ajax Comments, I found the four Javascript test classes in the Views module, as well as  BrowserWithJavascriptTest in the Simpletest module, to have particularly helpful examples. The next section, Useful methods, calls out several of these helpful examples.

Useful methods

The following is a reference of methods I found to be particularly helpful, which are available in test classes that extend  JavascriptTestBase.

  • $this->createContentType(['type' => 'bundle']);

This method actually comes from  Drupal\simpletest\ContentTypeCreationTrait. You’ll need to provide a  use statement near the top of your file ( useDrupal\simpletest\ContentTypeCreationTrait;), and then provide another use statement just below your class declaration:

class AjaxCommentsFunctionalTest extends JavascriptTestBase {

  use ContentTypeCreationTrait;

Creates a content type for use in the test, where ‘bundle’ is the machine name of the content type. You can also specify a ‘name’ key in the array of parameters to specify a human-readable name. This method automatically adds a body field to the content type.

  • $this->clickLink('Click me!');

Simulates clicking the first link on the page whose text matches the parameter passed in.

  • $page = $this->getSession()->getPage();

Get an object representing the current page, and save it to a  $page variable.

  • $save_button = $page->find('css', 'form.form-edit-class input[value=Save]');

When passing in  'css' as the first parameter to  $page->find(), you can use a CSS selector as the second parameter to identify the DOM element you want to reference in your variable. Using a CSS selector instead of XPath to specify a DOM element is likely easier for many developers.

  • $save_button->press();

Clicks the button referenced by the  $save_button variable.

  • $field = $page->findField('comment_body[0][value]');

Finds a form field DOM element using the value of the id, name, or label attribute, and saves a reference to the element in a variable.

  • $field_id = $field->getAttribute('id');

Saves the value of the specified HTML attribute into a variable.

  • $this->getSession()->executeScript($string_of_javascript);

Allows you to manually invoke Javascript in your test. The variable $string_of_javascript should consist of executable Javascript represented as a string. A convenient way to assign a value to this variable is to use PHP heredoc syntax:

$string_of_javascript = <<<JS

  (function (){

    console.log('Testing Javascript...');

  }());

JS;

  • $this->assertSession()->assertWaitOnAjaxRequest(20000);

This method tells the test to pause until an ajax response comes back, or until a timeout is reached, whichever comes first. You specify the timeout in milliseconds in the optional first parameter. The default value, If you don’t specify a value for the timeout, is 10000 milliseconds (10 seconds). Note that in some cases, if the code to generate the ajax response is slow, or the test is running on slow hardware, you may want to set a higher timeout than the default, to prevent your test from failing (the example above sets the timeout to 20 seconds, double the default).

  • $this->assertJsCondition($javascript_assertion, $timeout, $message);

This assertion tests whether a string of Javascript code evaluates to TRUE before the  $timeout is reached. The  $timeout parameter is optional, and defaults to 1000 milliseconds (1 second). Similarly to the example above for $this->getSession()->executeScript(), you may want to set the value of $javascript_assertion using the PHP heredoc syntax.

  • $this->assertSession()->pageTextContains('Some text');

This assertion tests whether the text passed in appears on the page currently. Any changes to the DOM caused by Javascript that has finished execution will affect the result of calling this method.

  • $this->assertSession()->pageTextNotContains('This text should NOT appear on the page.');

This assertion is the inverse of  $this->assertSession()->pageTextContains(). It tests whether the text passed in does NOT appear on the page currently. Any changes to the DOM caused by Javascript that has executed on the page will affect the result of calling this method.

  • $this->assertSession()->elementExists('css', 'form.form-edit-class');

This assertion tests whether a particular DOM element exists on the page at the point in the test when this method is called. By specifying 'css'  as the first parameter, you can use a CSS selector in the second parameter to search for the DOM element, rather than an XPath. This approach may be easier for many developers. Any changes to the DOM caused by Javascript that has executed on the page will affect the result of calling this method.

if ($this->htmlOutputEnabled) {
  $out = $page->getContent();
  $html_output = $out . '<hr />' . $this->getHtmlOutputHeaders();
  $this->htmlOutput($html_output);
}

This block of code is adapted from  \Drupal\Tests\BrowserTestBase::drupalGet(). 

First, it checks whether the test was invoked with the option to save snapshots of the page  ($this->htmlOutputEnabled), which you can enable by passing the flag  --printer="\Drupal\Tests\Listeners\HtmlOutputPrinter" to the  phpunit command on the command line. See the preceding section, Running a Javascript functional test, for more details.

Next, if  $this->htmlOutputEnabled is true, this block of code takes the current snapshot of the page content and loads it as a string, which it assigns to the $out  variable. It then appends the raw HTTP headers to the bottom of the page, and finally it saves the modified page content as a static file, which you can open in a browser after the test finishes. As mentioned previously, this technique can be immensely helpful when debugging problems with your test.

Conclusion

By now you have learned the benefits of writing Javascript tests for Drupal; how tests of ajax behavior that extend the new  JavascriptTestBase class differ from ones that extend the old  WebTestBase class, and the advantages of using the new class; how to setup your environment to run Javascript functional tests; how to actually run a test; where to find examples in Drupal core to help you write your own tests; and which test methods may prove particularly useful as you write your own tests. You should have enough information to get started writing your own Javascript functional tests to ensure that the ajax behaviors in your module function correctly under a variety of circumstances, and continue to function properly as you continue to introduce code changes in the future. If you have any other useful advice, tricks, or information about gotchas to share with the community, feel free to share in the comments below!

Dan Muzyka

Software Architect