Lab #3: Writing system tests with BrowserTestBase and Nightwatch.js - WidgetsBurritos/drupal-test-writing GitHub Wiki

Part 1: The Security Bug

Problem Statement

Oh no! You've just discovered a pretty major security flaw in your module. Unauthenticated users can access /my-message even though this functionality was supposed to be accessible for authenticated users.

The Proposed Fix

To fix it, you are proposing changing the route in my_testing_module.routing.yml from:

my_testing_module.my_message:
  path: 'my-message'
  defaults:
    _controller: '\Drupal\my_testing_module\Controller\MyMessageController::displayMessage'
    _title_callback: '\Drupal\my_testing_module\Controller\MyMessageController::title'
  requirements:
    _permission: 'access content'

To this:

my_testing_module.my_message:
  path: 'my-message'
  defaults:
    _controller: '\Drupal\my_testing_module\Controller\MyMessageController::displayMessage'
    _title_callback: '\Drupal\my_testing_module\Controller\MyMessageController::title'
  requirements:
    _role: 'authenticated'

The Decision: Write a Functional Test

You decide just fixing the problem isn't enough. You want to add functional test coverage to ensure this code doesn't break again. Yet another great move!

Upon digging you discover there's already a functional test present in tests/src/Functional/MyFunctionalTest.php.

  1. You decide to modify the existing test case to ensure a 200 status code.
  2. You determine you only need one more test case ensuring authenticated users receive a 403 status code.

Running the Tests

Before we begin, let's ensure the existing test is working as expected. We can do so by running this command:

php web/core/scripts/run-tests.sh --color --verbose --sqlite /tmp/a.sqlite --non-html --class 'Drupal\Tests\my_testing_module\Functional\MyFunctionalTest'

The response should contain the following text:

---- Drupal\Tests\my_testing_module\Functional\MyFunctionalTest ----


Status    Group      Filename          Line Function                            
--------------------------------------------------------------------------------
Pass      Other      MyFunctionalTest.   44 Drupal\Tests\my_testing_module\Func

If it doesn't please ensure you followed all of the steps in the Getting Started documentation

Writing the Test

  1. Add $assert->statusCodeEquals(200); to the testMessageControllerIsLoadingForAuthenticatedUsers() method.

  2. Rerun the test to ensure everything still works as expected.

  3. Create a new test case method for our 403 test:

    /**
     * Confirm unauthenticated users can't access controller route.
     */
    public function testMessageControllerDoesntLoadForUnauthenticatedUsers() {
    }
    
  4. Retrieve our WebAssert session object inside the new test:

    $assert = $this->assertSession();
    
  5. Navigate to the controller router:

    $this->drupalGet('my-message');
    
  6. Check for expected failure message and status code:

    $assert->pageTextContains('You are not authorized to access this page.');
    $assert->statusCodeEquals(403);
    
  7. Rerun the tests. If you did everything right the response should contain:

    ---- Drupal\Tests\my_testing_module\Functional\MyFunctionalTest ----
    
    
    Status    Group      Filename          Line Function                            
    --------------------------------------------------------------------------------
    Pass      Other      MyFunctionalTest.   44 Drupal\Tests\my_testing_module\Func
        
    Pass      Other      MyFunctionalTest.   55 Drupal\Tests\my_testing_module\Func
    
  8. So both tests pass, but what you've just realized is by creating two separate tests, it's bootstraps Drupal twice. As functional tests are much slower than other types of test, you're willing to consolidate other multiple scenarios into a single test. To do so, copy this into the first test, follow these steps:

    1. Rename testMessageControllerIsLoadingForAuthenticatedUsers() to testMessageControllerProperlyHandlesAccessControls() as that's a little bit more accurate what we're testing here.
    2. Move this code into above the $this->drupalLogin() line in the original test:
      $this->drupalGet('my-message');
      $assert->pageTextContains('You are not authorized to access this page.');
      $assert->statusCodeEquals(403);
      
    3. Delete the testMessageControllerDoesntLoadForUnauthenticatedUsers() test method.
    4. For improved readability, add a comment to each section within the test to provide a plaintext explanation as to what is happening:
      // Test unauthenticated users can't access the controller.
      
      // Test authenticated users can access the controller.
      
  9. Rerun the tests one more time. If you did everything correctly, your tests should still be passing.

Part 2: The JavaScript Test

Problem Statement

As you continue to look at your module, you notice that the admin form has some custom javascript functionality execute when the checkbox is toggled that changes the color of the text label. Obviously functionality this important really needs to be tested. So you opt to build a test in Nightwatch.js. Whoa! There aren't a whole lot of Drupal devs doing that yet. You're ahead of the curve on this one.

Running the Tests

Before we begin let's ensure we can run the existing Nightwatch test.

ddev ssh
cd /var/www/html/web/core
yarn test:nightwatch ../modules/custom/my_testing_module/tests/src/Nightwatch

Writing the Test

  1. Create a new setup fixture called web/modules/custom/my_testing_module/tests/src/Nightwatch/fixtures/TestSiteInstallTestScript.php. This file will be referenced by Nightwatch to bootstrap Drupal with the correct modules installed. (Note: Once this core issue is resolved, you won't need to do steps 1 and 2).

  2. Add the following code to install the my_testing_module

    <?php
    
    namespace Drupal\TestSite;
    
    /**
     * Setup file used by Nightwatch tests.
     */
    class TestSiteInstallTestScript implements TestSetupInterface {
    
      /**
       * {@inheritdoc}
       */
      public function setup() {
        $modules = ['my_testing_module'];
        \Drupal::service('module_installer')->install($modules);
      }
    
    }
    
  3. Modify the MyNightwatchTest.js file to use our Drupal bootstrap fixture by changing:

    browser
      .drupalInstall()
    

    to:

    browser
      .drupalInstall({
        setupFile: __dirname + '/fixtures/TestSiteInstallTestScript.php',
      })
    
  4. Delete the 'Visit the home page and ensure "Skip to main content" is present' test case as that was merely to confirm nightwatch is working correctly.

  5. Create a new test case by adding this:

    'Visit the message module settings and toggle log checkbox': (browser) => {
      // New code will be added here.
    
      // Clean up our session.
      browser
        .end();
    }
    
  6. Within the test case, login as an admin user and navigate to the settings page:

    // Navigate to module admin setting.
    browser
      .drupalLoginAsAdmin()
      .drupalRelativeURL('/admin/config/system/my_testing_module/settings');
    

    Rerun Nightwatch. If you did everything correctly tests should still be passing.

  7. Confirm that label[for=edit-log-users] has my-unchecked-class set and not my-checked-class:

    // Confirm label contains correct CSS class.
    browser
      .assert.cssClassPresent('label[for=edit-log-users]', 'my-unchecked-class')
      .assert.not.cssClassPresent('label[for=edit-log-users]', 'my-checked-class')
    
  8. Retrieve the state of the checkbox by executing a command directly in the browser and then asserting the value is false:

     .execute(function() {
       return Drupal.myMessageLogging.myCheckbox.checked;
     }, [], function (result) {
       browser.assert.strictEqual(result.value, false);
     })
    

    Rerun Nightwatch. If you did everything correctly tests should still be passing.

  9. Toggle the checkbox state:

    browser
      .click('input[name=log_users]')
    
  10. Now reusing what we've learned above, confirm the checkbox state and label classes are correct. Then flip the checkbox state one more time, and confirm everything reverted back to normal.

Need Assistance?

If you're stuck, have a look at Pull Request #4