TDD - Test Driven Drupal

TDD - Test Driven Drupal

71c9ebde850996d2533c5df4df2c93c6?s=128

Oliver Davies

May 12, 2020
Tweet

Transcript

  1. TDD: Test Driven Drupal

  2. • Why write tests, and what to test • Types

    of tests • How to run tests • Example • Building a new module with test driven development
  3. • Full Stack Web Developer & System Administrator • Senior

    Software Engineer at Inviqa • PHP South Wales organiser • @opdavies • www.oliverdavies.uk
  4. Write custom modules and themes for clients

  5. Contributor to Drupal core

  6. Maintain and contribute to contrib projects

  7. None
  8. Override Node Options • Become maintainer in 2012 • #232

    most used module on Drupal.org (May 2020) • Had some existing tests • Crucial to preventing regressions
  9. None
  10. None
  11. None
  12. Why write tests?

  13. 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
  14. 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 https://opdavi.es/drupal-core-testing-gate
  15. 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
  16. 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
  17. Arrange Act Assert

  18. Given When Then

  19. Given the About page exists When I go to the

    page Then I should see "About Me"
  20. // 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... } }
  21. // 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... } }
  22. // 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... } }
  23. // 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... } }
  24. public function testSomething() {} public function test_something() {} /** @test

    */ public function it_does_something() {}
  25. 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
  26. None
  27. Types of Tests

  28. Types of Tests • Functional / FunctionalJavascript (web, browser, feature)

    • Kernel (integration) • Unit
  29. Functional Tests • Tests end-to-end functionality • UI testing •

    Interacts with database • Full Drupal installation • Slower to run • With/without JavaScript
  30. 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; // ... }
  31. 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; // ... }
  32. 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.'); // ... }
  33. 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.'); // ... }
  34. 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.'); // ... }
  35. Kernel Tests • Integration tests • Can install modules, interact

    with services, container, database • Minimal Drupal bootstrap • Faster than functional tests • More setup required
  36. 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(); } // ... }
  37. 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(); } // ... }
  38. class BlockRebuildTest extends KernelTestBase { // ... public function testRebuildNoBlocks()

    { block_rebuild(); $messages = \Drupal::messenger()->all(); \Drupal::messenger()->deleteAll(); $this->assertEquals([], $messages); } }
  39. Unit Tests • Tests PHP logic • No database interaction

    • Fast to run • Need to mock dependencies • Can become tightly coupled • Can be hard to refactor
  40. 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; } // ... } }
  41. 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; } // ... } }
  42. 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); } }
  43. 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); } }
  44. Example

  45. None
  46. 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
  47. None
  48. 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
  49. $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', ];
  50. $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', ];
  51. Implementation • If no error, create the job node, return

    OK response to Broadbean • If an Exception is thrown, return an error code and message
  52. 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
  53. 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)
  54. 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
  55. Results • 0 bugs! • Easier to identify where issues

    occurred and responsibilities • Reduced debugging time • Added more tests for any bugs to prevent
  56. Running Tests

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

    $ php core/scripts/run-tests.sh --class ExampleTest
  58. PHPUnit $ vendor/bin/phpunit \ -c core \ modules/contrib/examples/phpunit_example

  59. 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"
  60. Test Driven Development

  61. Test Driven Development • Write a test • Test fails

    • Write code • Test passes • Refactor • Repeat
  62. https://github.com/foundersandcoders/testing-tdd-intro

  63. Red, Green, Refactor

  64. Porting Modules to Drupal 8 • Make a new branch

    • Add/update the tests • Write code to make the tests pass • Refactor • Repeat
  65. 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
  66. Demo: Building a Blog module

  67. Acceptance criteria • As a site visitor • I want

    to see a list of published articles at /blog • Ordered by post date
  68. Tasks • Ensure the blog page exists • Ensure only

    published articles are shown • Ensure the articles are shown in the correct order
  69. None
  70. None
  71. TestDrivenDrupal.com

  72. None
  73. Questions? @opdavies oliverdavies.uk