presentations/src/test-driven-drupal/slides.md
2020-05-12 21:49:33 +01:00

20 KiB

theme: Simple, 8 autoscale: true build-lists: true header: alignment(left), line-height(1.1), text-scale(1.3) text: alignment(left) text-emphasis: #53B0EB

[.header: alignment(center)]

TDD: Test
Driven Drupal


  • Why write tests, and what to test
  • Types of tests
  • How to run tests
  • Example
  • Building a new module with test driven development

[.background-color: #FFFFFF] [.build-lists: false] [.header: #111111] [.text: #111111, alignment(left)]

right 800%

  • Full Stack Web Developer & System Administrator
  • Senior Software Engineer at Inviqa
  • PHP South Wales organiser
  • @opdavies
  • www.oliverdavies.uk

^ - Work at Inviqa.

  • Organiser of the PHP South Wales user group. Formerly PHP South West, Drupal Bristol, SWDUG
  • Maintain Drupal modules, PHP CLI tools and libraries
  • Blog on my website

[.header: alignment(center)]

Write custom modules
and themes
for clients


[.header: alignment(center)]

Contributor to Drupal core

^ - Drupal 7 and 8 core contributor

  • Most recent patch submitted on a live stream

[.header: alignment(center)]

Maintain and contribute
to contrib projects

^ Approaching this from a few different angles


[.background-color: #FFFFFF]

inline 150%


Override Node Options

  • Become maintainer in 2012
  • #232 most used module on Drupal.org (May 2020)
  • Had some existing tests
  • Crucial to preventing regressions

^ Preventing regressions when adding new features or fixing bugs, but also user submitted patches First module I ported to Drupal 8, aided by tests




^ - D5 and D6 gone down, D7 up and a few different D8 versions including D9 compatibility.

  • Around 30,000 sites

[.header: alignment(center)]

Why write tests?


Why write tests?

  • Catch bugs earlier
  • Peace of mind
  • Prevent regressions
  • Write less code
  • Documentation
  • Drupal core requirement
  • More important with regular D8/D9 releases and supporting multiple versions

^ Dave Liddament talk - better and cheaper to catch bugs earlier (e.g. whilst developing rather than after it's been released) Refer to tests when writing implementation code ONO merge conflict


Core Testing Gate

Description When
Check test coverage and ensure all tests pass When changing/refactoring existing code
Add new tests When adding new features
Upload a test case that fails When fixing bugs in PHP code
Add JavaScript test When making JS changes
Manually test in browsers and provide screenshots or screencasts When making markup or CSS changes
Provide an example module When a new feature is not implemented by Drupal core

[.footer: https://opdavi.es/drupal-core-testing-gate]


Testing in Drupal

  • Drupal 7 - SimpleTest (testing) module provided as part of core
  • Drupal 8 - PHPUnit added as a core dependency, later became the default via the PHPUnit initiative
  • Drupal 9 - SimpleTest removed, moved to contrib

Writing Tests (Drupal 8)

  • PHP class with .php extension
  • tests/src directory within each module
  • Within the Drupal\Tests\module_name namespace
  • Class name must match the filename
  • Namespace must match the directory structure
  • One test class per feature

^ Different to D7


[.header: alignment(center)]

Arrange

Act

Assert


[.header: alignment(center)]

Given

When

Then


[.header: alignment(center)]

Given the About page exists

When I go to the page

Then I should see "About Me"


// modules/example/tests/src/Functional/ExampleTest.php

namespace Drupal\Tests\example\Functional;

use Drupal\Tests\BrowserTestBase;

class ExampleTest extends BrowserTestBase {

  public function testSomething() {
    // Given...
    // When...
    // Then...
  }

}

// modules/example/tests/src/Functional/ExampleTest.php

namespace Drupal\Tests\example\Functional;

use Drupal\Tests\BrowserTestBase;

class ExampleTest extends BrowserTestBase {

  public function testSomething() {
    // Given...
    // When...
    // Then...
  }

}

// modules/example/tests/src/Functional/ExampleTest.php

namespace Drupal\Tests\example\Functional;

use Drupal\Tests\BrowserTestBase;

class ExampleTest extends BrowserTestBase {

  public function testSomething() {
    // Given...
    // When...
    // Then...
  }

}

// modules/example/tests/src/Functional/ExampleTest.php

namespace Drupal\Tests\example\Functional;

use Drupal\Tests\BrowserTestBase;

class ExampleTest extends BrowserTestBase {

  public function testSomething() {
    // Given...
    // When...
    // Then...
  }

}

public function testSomething() {}

public function test_something() {}

/** @test */
public function it_does_something() {}

What to test?

  • Creating nodes with data from an API
  • Calculating attendance figures for an event
  • Determining if an event is purchasble
  • Promotions and coupons for new users
  • Cloning events
  • Queuing private message requests
  • Emails for new memberships
  • Closed support tickets are re-opened when comments are added
  • Custom form validation rules

^ Examples of some things that I tested on a previous project.


[.background-color: #FFFFFF]

inline 125%


[.header: alignment(center) ]

Types of Tests


Types of Tests

  • Functional / FunctionalJavascript (web, browser, feature)
  • Kernel (integration)
  • Unit

Functional Tests

  • Tests end-to-end functionality
  • UI testing
  • Interacts with database
  • Full Drupal installation
  • Slower to run
  • With/without JavaScript

^ testing profile Functional/FunctionalJavascript Nightwatch


class BlockTest extends BlockTestBase {

  public function testBlockVisibility() {
    $block_name = 'system_powered_by_block';
    $title = $this->randomMachineName(8);

    $default_theme = $this->config('system.theme')->get('default');

    $edit = [
      'id' => strtolower($this->randomMachineName(8)),
      'region' => 'sidebar_first',
      'settings[label]' => $title,
      'settings[label_display]' => TRUE,
    ];

    $edit['visibility[request_path][pages]'] = '/user*';
    $edit['visibility[request_path][negate]'] = TRUE;
    $edit['visibility[user_role][roles][' . RoleInterface::AUTHENTICATED_ID . ']'] = TRUE;

    // ...
  }

class BlockTest extends BlockTestBase {

  public function testBlockVisibility() {
    $block_name = 'system_powered_by_block';
    $title = $this->randomMachineName(8);

    $default_theme = $this->config('system.theme')->get('default');

    $edit = [
      'id' => strtolower($this->randomMachineName(8)),
      'region' => 'sidebar_first',
      'settings[label]' => $title,
      'settings[label_display]' => TRUE,
    ];

    $edit['visibility[request_path][pages]'] = '/user*';
    $edit['visibility[request_path][negate]'] = TRUE;
    $edit['visibility[user_role][roles][' . RoleInterface::AUTHENTICATED_ID . ']'] = TRUE;

    // ...
  }

^ Arrange step



class BlockTest extends BlockTestBase {

  public function testBlockVisibility() {
    // ...

    $this->drupalGet('admin/structure/block/add/' . $block_name . '/' . $default_theme);
    $this->assertFieldChecked('edit-visibility-request-path-negate-0');

    $this->drupalPostForm(NULL, $edit, t('Save block'));
    $this->assertText('The block configuration has been saved.', 'Block was saved');

    $this->clickLink('Configure');
    $this->assertFieldChecked('edit-visibility-request-path-negate-1');

    $this->drupalGet('');
    $this->assertText($title, 'Block was displayed on the front page.');

    $this->drupalGet('user');
    $this->assertNoText($title, 'Block was not displayed according to block visibility rules.');

    // ...
  }

class BlockTest extends BlockTestBase {

  public function testBlockVisibility() {
    // ...

    $this->drupalGet('admin/structure/block/add/' . $block_name . '/' . $default_theme);
    $this->assertFieldChecked('edit-visibility-request-path-negate-0');

    $this->drupalPostForm(NULL, $edit, t('Save block'));
    $this->assertText('The block configuration has been saved.', 'Block was saved');

    $this->clickLink('Configure');
    $this->assertFieldChecked('edit-visibility-request-path-negate-1');

    $this->drupalGet('');
    $this->assertText($title, 'Block was displayed on the front page.');

    $this->drupalGet('user');
    $this->assertNoText($title, 'Block was not displayed according to block visibility rules.');

    // ...
  }

class BlockTest extends BlockTestBase {

  public function testBlockVisibility() {
    // ...

    $this->drupalGet('admin/structure/block/add/' . $block_name . '/' . $default_theme);
    $this->assertFieldChecked('edit-visibility-request-path-negate-0');

    $this->drupalPostForm(NULL, $edit, t('Save block'));
    $this->assertText('The block configuration has been saved.', 'Block was saved');

    $this->clickLink('Configure');
    $this->assertFieldChecked('edit-visibility-request-path-negate-1');

    $this->drupalGet('');
    $this->assertText($title, 'Block was displayed on the front page.');

    $this->drupalGet('user');
    $this->assertNoText($title, 'Block was not displayed according to block visibility rules.');

    // ...
  }

Kernel Tests

  • Integration tests
  • Can install modules, interact with services, container, database
  • Minimal Drupal bootstrap
  • Faster than functional tests
  • More setup required


class BlockRebuildTest extends KernelTestBase {

  use BlockCreationTrait;

  public static $modules = ['block', 'system'];

  protected function setUp() {
    parent::setUp();

    $this->container->get('theme_installer')
      ->install(['stable', 'classy']);

    $this->container->get('config.factory')
      ->getEditable('system.theme')
      ->set('default', 'classy')
      ->save();
  }

  // ...

}


class BlockRebuildTest extends KernelTestBase {

  use BlockCreationTrait;

  public static $modules = ['block', 'system'];

  protected function setUp() {
    parent::setUp();

    $this->container->get('theme_installer')
      ->install(['stable', 'classy']);

    $this->container->get('config.factory')
      ->getEditable('system.theme')
      ->set('default', 'classy')
      ->save();
  }

  // ...

}

^ Still have access to the service container, via $this->container


class BlockRebuildTest extends KernelTestBase {

  // ...

  public function testRebuildNoBlocks() {
    block_rebuild();

    $messages = \Drupal::messenger()->all();
    \Drupal::messenger()->deleteAll();

    $this->assertEquals([], $messages);
  }

}

^ Can still access services like \Drupal::messenger()


Unit Tests

  • Tests PHP logic
  • No database interaction
  • Fast to run
  • Need to mock dependencies
  • Can become tightly coupled
  • Can be hard to refactor

class BlockRepositoryTest extends UnitTestCase {

  public function testGetVisibleBlocksPerRegion(array $blocks_config, array $expected_blocks) {
    $blocks = [];

    foreach ($blocks_config as $block_id => $block_config) {
      $block = $this->getMock('Drupal\block\BlockInterface');

      $block->expects($this->once())
        ->method('access')
        ->will($this->returnValue($block_config[0]));

      $block->expects($block_config[0] ? $this->atLeastOnce() : $this->never())
        ->method('getRegion')
        ->willReturn($block_config[1]);

      $block->expects($this->any())
        ->method('label')
        ->willReturn($block_id);

      // ...

      $blocks[$block_id] = $block;
    }

    // ...
  }
}


class BlockRepositoryTest extends UnitTestCase {

  public function testGetVisibleBlocksPerRegion(array $blocks_config, array $expected_blocks) {
    $blocks = [];

    foreach ($blocks_config as $block_id => $block_config) {
      $block = $this->getMock('Drupal\block\BlockInterface');

      $block->expects($this->once())
        ->method('access')
        ->will($this->returnValue($block_config[0]));

      $block->expects($block_config[0] ? $this->atLeastOnce() : $this->never())
        ->method('getRegion')
        ->willReturn($block_config[1]);

      $block->expects($this->any())
        ->method('label')
        ->willReturn($block_id);

      // ...

      $blocks[$block_id] = $block;
    }

    // ...
  }
}

class BlockRepositoryTest extends UnitTestCase {

  public function testGetVisibleBlocksPerRegion(array $blocks_config, array $expected_blocks) {
    // ..

    $this->blockStorage->expects($this->once())
      ->method('loadByProperties')
      ->with(['theme' => $this->theme])
      ->willReturn($blocks);

    $result = [];
    $cacheable_metadata = [];

    foreach ($this->blockRepository->getVisibleBlocksPerRegion($cacheable_metadata) as $region => $resulting_blocks) {
      $result[$region] = [];

      foreach ($resulting_blocks as $plugin_id => $block) {
        $result[$region][] = $plugin_id;
      }
    }

    $this->assertEquals($expected_blocks, $result);
  }

}

class BlockRepositoryTest extends UnitTestCase {

  public function testGetVisibleBlocksPerRegion(array $blocks_config, array $expected_blocks) {
    // ..

    $this->blockStorage->expects($this->once())
      ->method('loadByProperties')
      ->with(['theme' => $this->theme])
      ->willReturn($blocks);

    $result = [];
    $cacheable_metadata = [];

    foreach ($this->blockRepository->getVisibleBlocksPerRegion($cacheable_metadata) as $region => $resulting_blocks) {
      $result[$region] = [];

      foreach ($resulting_blocks as $plugin_id => $block) {
        $result[$region][] = $plugin_id;
      }
    }

    $this->assertEquals($expected_blocks, $result);
  }

}

[.header: alignment(center)]

Example



Specification

  • Job adverts created in Broadbean UI, needs to create nodes in Drupal
  • Application URL links users to separate application system
  • Jobs need to be linked to offices
  • Job length specified in number of days
  • Path is specified as a field in the API
  • Application URL constructed from domain, includes role ID as a GET parameter and optionally UTM parameters

[.background-color: #FFFFFF]

inline 125%


Implementation

  • Added route to accept data from API as XML
  • Added system user with API role to authenticate
  • active_for converted from number of days to UNIX timestamp
  • branch_name and locations converted from plain text to entity reference (job node to office node)
  • url_alias property mapped to path

$data = [
  'command' => 'add',
  'username' => 'bobsmith',
  'password' => 'p455w0rd',
  'active_for' => '365',
  'application_email' => 'bob.12345.123@smith.aplitrak.com',
  'branch_address' => '123 Fake St, Bristol, BS1 2AB',
  'branch_name' => 'Test',
  'contract' => 'Temporary',
  'details' => 'This is the detailed description.',
  'job_id' => 'abc123_1234567',
  'job_title' => 'Healthcare Assistant (HCA)',
  'job_type' => 'Care at Home',
  'keywords' => 'flexible, Bristol, part-time',
  'locations' => 'Bath, Devizes',
  'role_id' => 'A/52/86',
  'salary' => '32,000.00 per annum',
  'salary_prefix' => 'Basic Salary',
  'status' => 'Part time',
  'summary' => 'This is the short description.',
  'url_alias' => 'healthcare-assistant-aldershot-june17',
];

$data = [
  'command' => 'add',
  'username' => 'bobsmith',
  'password' => 'p455w0rd',
  'active_for' => '365',
  'application_email' => 'bob.12345.123@smith.aplitrak.com',
  'branch_address' => '123 Fake St, Bristol, BS1 2AB',
  'branch_name' => 'Test',
  'contract' => 'Temporary',
  'details' => 'This is the detailed description.',
  'job_id' => 'abc123_1234567',
  'job_title' => 'Healthcare Assistant (HCA)',
  'job_type' => 'Care at Home',
  'keywords' => 'flexible, Bristol, part-time',
  'locations' => 'Bath, Devizes',
  'role_id' => 'A/52/86',
  'salary' => '32,000.00 per annum',
  'salary_prefix' => 'Basic Salary',
  'status' => 'Part time',
  'summary' => 'This is the short description.',
  'url_alias' => 'healthcare-assistant-aldershot-june17',
];

Implementation

  • If no error, create the job node, return OK response to Broadbean
  • If an Exception is thrown, return an error code and message

^ Required field missing Incorrect branch name


Testing Goals

  • Ensure job nodes are successfully created
  • Ensure that fields are mapped correctly
  • Ensure that calculations are correct
  • Ensure that entity references are linked correctly

Types of tests

  • Functional: job nodes are created with the correct URL and the correct response code is returned
  • FunctionalJavaScript: application URL is updated with JavaScript based on UTM parameters (hosting)

Types of tests

  • Kernel: job nodes can be added and deleted, expired job nodes are deleted, application URL is generated correctly
  • Unit: ensure number of days are converted to timestamps correctly

Results

  • 0 bugs!
  • Easier to identify where issues occurred and responsibilities
  • Reduced debugging time
  • Added more tests for any bugs to prevent

[.header: alignment(center)]

Running Tests


Core script

$ php core/scripts/run-tests.sh

$ php core/scripts/run-tests.sh --module example

$ php core/scripts/run-tests.sh --class ExampleTest

PHPUnit

$ vendor/bin/phpunit \
  -c core \
  modules/contrib/examples/phpunit_example

Prerequisite (creating a phpunit.xml file)

  • Configures PHPUnit
  • Needed to run some types of tests
  • Ignored by Git by default
  • Copy core/phpunit.xml.dist to core/phpunit.xml
  • Add and change as needed
    • SIMPLETEST_BASE_URL, SIMPLETEST_DB, BROWSERTEST_OUTPUT_DIRECTORY
    • stopOnFailure="true"

[.header: alignment(center)]

Test Driven
Development


Test Driven Development

  • Write a test
  • Test fails
  • Write code
  • Test passes
  • Refactor
  • Repeat

[.background-color: #FFFFFF] [.footer: https://github.com/foundersandcoders/testing-tdd-intro] [.footer-style: #2F2F2F]

100%


[.header: alignment(center)]

Red, Green, Refactor


Porting Modules to Drupal 8

  • Make a new branch
  • Add/update the tests
  • Write code to make the tests pass
  • Refactor
  • Repeat

How I Write Tests - "Outside In"

  • Start with functional tests
  • Drop down to integration or unit tests where needed
  • Programming by wishful thinking
  • Write comments first, then fill in the code
  • Sometimes write assertions first

[.header: alignment(center)]

Demo: Building a Blog module


Acceptance criteria

  • As a site visitor
  • I want to see a list of published articles at /blog
  • Ordered by post date

Tasks

  • Ensure the blog page exists
  • Ensure only published articles are shown
  • Ensure the articles are shown in the correct order

[.background-color: #FFFFFF]

140%


[.background-color: #FFFFFF]

150%


[.header: alignment(center)]

TestDrivenDrupal.com


100%


[.header: alignment(center)]

Questions?

@opdavies

oliverdavies.uk