Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TDD - Test Driven Drupal

Oliver Davies
October 17, 2023

TDD - Test Driven Drupal

Oliver Davies

October 17, 2023
Tweet

More Decks by Oliver Davies

Other Decks in Technology

Transcript

  1. Why write tests? • Peace of mind • Prevent regressions

    • Catch bugs earlier • Write less code • Documentation • Drupal core requirement • More important with regular D8/D9 releases and supporting multiple versions @opdavies
  2. 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 from core, moved back to contrib @opdavies
  3. Writing PHPUnit Tests for Drupal • 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 @opdavies
  4. What to test? • Creating nodes with data from an

    API • Calculating attendance figures for an event • Determining if an event is purchasable • Promotions and coupons for new users • Cloning events • Queuing private message requests • Re-opening closed support tickets when comments are added @opdavies
  5. What does a test look like? 1 // web/modules/custom/example/tests/src/Functional. 2

    3 namespace Drupal\Tests\example\Functional; 4 5 use Drupal\Tests\BrowserTestBase; 6 7 class ExampleTest extends BrowserTestBase { 8 9 public function testSomething() { 10 $this->assertTrue(FALSE); 11 } 12 13 } @opdavies
  6. Writing test methods 1 public function testSomething() 2 3 public

    function test_something() 4 5 /** @test */ 6 public function it_does_something() @opdavies
  7. Functional Tests • Tests end-to-end functionality • UI testing •

    Interacts with database • Full Drupal installation • Slower to run • With/without JavaScript @opdavies
  8. Kernel tests • Integration tests • Can install modules, interact

    with services, container, database • Minimal Drupal bootstrap • Faster than functional tests • More setup required @opdavies
  9. Unit Tests • Tests PHP logic • No database interaction

    • Fast to run • Need to mock dependencies • Can become tightly coupled • Can be hard to refactor @opdavies
  10. Core script $ php web/core/scripts/run-tests.sh $ php web/core/scripts/run-tests.sh \ --all

    $ php web/core/scripts/run-tests.sh \ --module example $ php web/core/scripts/run-tests.sh \ --class ExampleTest @opdavies
  11. Core script $ php web/core/scripts/run-tests.sh \ --module example \ --sqlite

    /dev/shm/test.sqlite \ --url http://web @opdavies
  12. Drupal test run --------------- Tests to be run: - Drupal\Tests\example\Functional\ExamplePageTest

    Test run started: Saturday, October 14, 2023 - 10:28 Test summary ------------ Drupal\Tests\example\Functional\ExamplePageTest 1 passes Test run duration: 7 sec @opdavies
  13. PHPUnit $ export SIMPLETEST_BASE_URL=http://web $ web/vendor/bin/phpunit \ -c web/core \

    modules/contrib/examples/modules/phpunit_example @opdavies
  14. 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" @opdavies
  15. Specification • Job adverts created in Broadbean UI, create nodes

    in Drupal. • Application URL links users to separate application system. • Constructed from domain, includes role ID as a GET parameter and optionally UTM parameters. • Jobs need to be linked to offices. • Job length specified in number of days. • Path is specified as a field in the API. @opdavies
  16. 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 @opdavies
  17. Incoming data $data = [ 'command' => 'add', 'username' =>

    'bobsmith', 'password' => 'p455w0rd', 'active_for' => '365', 'details' => 'This is the detailed description.', 'job_title' => 'Healthcare Assistant (HCA)', 'locations' => 'Bath, Devizes', 'role_id' => 'A/52/86', 'summary' => 'This is the short description.', 'url_alias' => 'healthcare-assistant-aldershot-june17', // ... ]; @opdavies
  18. Implementation • If no error, create the job node, return

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

    occurred and responsibilities • Reduced debugging time @opdavies
  21. Test Driven Development • Write a failing test • Write

    code until the test passes • Refactor • Repeat @opdavies
  22. Porting Modules to Drupal 8 • Make a new branch

    • Add/update the tests • Write code to make the tests pass • Refactor • Repeat @opdavies
  23. 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 @opdavies
  24. How I Write Tests - "Outside In" • Functional -

    57 tests, 180 assertions • Kernel - 38 tests, 495 assertions • Unit - 5 tests, 18 assertions Run in 2-3 minutes in a CI pipeline with GitHub Actions. @opdavies
  25. Acceptance criteria • As a site visitor • I want

    to see a list of published articles at /blog • Ordered by post date, most recent first @opdavies
  26. Tasks • Ensure the blog page exists • Ensure only

    published articles are shown • Ensure the articles are shown in the correct order @opdavies
  27. 1 // tests/src/Functional/BlogPageTest.php 2 3 namespace Drupal\Tests\drupalcon\Functional; 4 5 use

    Drupal\Tests\BrowserTestBase; 6 7 final class BlogPageTest extends BrowserTestBase { 8 9 public $defaultTheme = 'stark'; 10 11 public static $modules = []; 12 13 } @opdavies
  28. 1 // tests/src/Functional/BlogPageTest.php 2 3 /** @test */ 4 public

    function it_loads_the_blog_page(): void { 5 $this->drupalGet('/blog'); 6 7 $this->assertSession()->statusCodeEquals(200); 8 } 9 @opdavies
  29. E 1 / 1 (100%) Time: 00:01.379, Memory: 6.00 MB

    There was 1 error: 1) Drupal\Tests\drupalcon\Functional\BlogPageTest::it_loads_the_blog_page Behat\Mink\Exception\ExpectationException: Current response status code is 404, but 200 expected. /app/vendor/behat/mink/src/WebAssert.php:794 /app/vendor/behat/mink/src/WebAssert.php:130 /app/web/modules/custom/drupalcon/tests/src/BlogPageTest.php:16 /app/vendor/phpunit/phpunit/src/Framework/TestResult.php:728 ERRORS! Tests: 1, Assertions: 2, Errors: 1. @opdavies
  30. 1 # drupalcon.routing.yml 2 3 blog.page: 4 path: /blog 5

    defaults: 6 _controller: Drupal\drupalcon\Controller\BlogPageController 7 _title: Blog 8 requirements: 9 _permission: access content 10 @opdavies
  31. E 1 / 1 (100%) Time: 00:01.379, Memory: 6.00 MB

    There was 1 error: 1) Drupal\Tests\drupalcon\Functional\BlogPageTest::it_loads_the_blog_page Behat\Mink\Exception\ExpectationException: Current response status code is 404, but 200 expected. /app/vendor/behat/mink/src/WebAssert.php:794 /app/vendor/behat/mink/src/WebAssert.php:130 /app/web/modules/custom/drupalcon/tests/src/BlogPageTest.php:16 /app/vendor/phpunit/phpunit/src/Framework/TestResult.php:728 ERRORS! Tests: 1, Assertions: 2, Errors: 1. @opdavies
  32. 1 public static $modules = ['drupalcon']; E 1 / 1

    (100%) Time: 00:01.532, Memory: 6.00 MB There was 1 error: 1) Drupal\Tests\drupalcon\Functional\BlogPageTest::it_loads_the_blog_page Behat\Mink\Exception\ExpectationException: Current response status code is 403, but 200 expected. @opdavies
  33. 1 public static $modules = ['node', 'drupalcon']; E 1 /

    1 (100%) Time: 00:01.906, Memory: 6.00 MB There was 1 error: 1) Drupal\Tests\drupalcon\Functional\BlogPageTest::it_loads_the_blog_page Behat\Mink\Exception\ExpectationException: Current response status code is 500, but 200 expected. @opdavies
  34. 1 // src/Controller/BlogPageController.php 2 3 namespace Drupal\drupalcon\Controller; 4 5 class

    BlogPageController { 6 7 public function __invoke(): array { 8 return []; 9 } 10 11 } 12 @opdavies
  35. . 1 / 1 (100%) Time: 00:01.916, Memory: 6.00 MB

    OK (1 test, 3 assertions) Task completed in 0m2.147s @opdavies
  36. 1 /** @test */ 2 public function it_loads_the_blog_page(): void {

    3 $this->drupalGet('/blog'); 4 5 $session = $this->assertSession(); 6 $session->statusCodeEquals(200); 7 8 $session->responseContains('<h1>Blog</h1>'); 9 $session->pageTextContains('Welcome to my blog!'); 10 } 11 @opdavies
  37. 1 namespace Drupal\drupalcon\Controller; 2 3 use Drupal\Core\StringTranslation\StringTranslationTrait; 4 5 class

    BlogPageController { 6 7 use StringTranslationTrait; 8 9 public function __invoke(): array { 10 return [ 11 '#markup' => $this->t('Welcome to my blog!'), 12 ]; 13 } 14 15 } 16 @opdavies
  38. . 1 / 1 (100%) Time: 00:01.911, Memory: 6.00 MB

    OK (1 test, 3 assertions) @opdavies
  39. 1 // tests/src/ArticleRepositoryTest.php 2 3 namespace Drupal\Tests\drupalcon\Kernel; 4 5 use

    Drupal\KernelTests\Core\Entity\EntityKernelTestBase; 6 7 class ArticleRepositoryTest extends EntityKernelTestBase { 8 9 /** @test */ 10 public function it_returns_blog_posts(): void { 11 $repository = $this->container->get(ArticleRepository::class); 12 13 $articles = $repository->getAll(); 14 15 $this->assertCount(1, $articles); 16 } 17 @opdavies
  40. E 1 / 1 (100%) Time: 00:00.405, Memory: 6.00 MB

    There was 1 error: 1) Drupal\Tests\drupalcon\Kernel\ArticleRepositoryTest::it_returns_blog_posts Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: You have requested a non-existent service "Drupal\Tests\drupalcon\Kernel\ArticleRepository". @opdavies
  41. 1 // src/Repository/ArticleNodeRepository.php 2 3 namespace Drupal\drupalcon\Repository; 4 5 final

    class ArticleRepository { 6 } 7 1 # drupalcon.services.yml 2 3 services: 4 Drupal\drupalcon\Repository\ArticleRepository: ~ 5 @opdavies
  42. E 1 / 1 (100%) Time: 00:00.403, Memory: 6.00 MB

    There was 1 error: 1) Drupal\Tests\drupalcon\Kernel\ArticleRepositoryTest::it_returns_blog_posts Error: Call to undefined method Drupal\drupalcon\Repository\ArticleRepository::getAll() /app/web/modules/custom/drupalcon/tests/src/ArticleRepositoryTest.php:18 /app/vendor/phpunit/phpunit/src/Framework/TestResult.php:728 ERRORS! Tests: 1, Assertions: 4, Errors: 1. @opdavies
  43. 1 namespace Drupal\drupalcon\Repository; 2 3 final class ArticleRepository { 4

    5 public function getAll(): array { 6 return []; 7 } 8 9 } 10 @opdavies
  44. F 1 / 1 (100%) Time: 00:00.266, Memory: 6.00 MB

    There was 1 failure: 1) Drupal\Tests\drupalcon\Kernel\ArticleRepositoryTest::it_returns_blog_posts Failed asserting that actual size 0 matches expected size 1. @opdavies
  45. 1 namespace Drupal\drupalcon\Repository; 2 3 use Drupal\Core\Entity\EntityStorageInterface; 4 use Drupal\Core\Entity\EntityTypeManagerInterface;

    5 6 final class ArticleRepository { 7 8 private EntityStorageInterface $nodeStorage; 9 10 public function __construct( 11 private EntityTypeManagerInterface $entityTypeManager, 12 ) { 13 $this->nodeStorage = $this->entityTypeManager->getStorage('node'); 14 } 15 16 17 @opdavies
  46. 18 public function getAll(): array { 19 return $this->nodeStorage->loadMultiple(); 20

    } 21 22 } 23 1 # drupalcon.services.yml 2 3 services: 4 Drupal\drupalcon\Repository\ArticleRepository: 5 autowire: true 6 @opdavies
  47. E 1 / 1 (100%) Time: 00:00.405, Memory: 6.00 MB

    There was 1 error: 1) Drupal\Tests\drupalcon\Kernel\ArticleRepositoryTest::it_returns_blog_posts Drupal\Component\Plugin\Exception\PluginNotFoundException: The "node" entity type does not exist. 1 public static $modules = [ 2 'drupalcon', 3 'node', 4 ]; 5 @opdavies
  48. F 1 / 1 (100%) Time: 00:00.421, Memory: 6.00 MB

    There was 1 failure: 1) Drupal\Tests\drupalcon\Kernel\ArticleRepositoryTest::it_returns_blog_posts Failed asserting that actual size 0 matches expected size 1. @opdavies
  49. 1 use NodeCreationTrait; 2 3 /** @test */ 4 public

    function it_returns_blog_posts() { 5 $this->createNode(['type' => 'article']); 6 7 /** @var ArticleRepository */ 8 $repository = $this->container->get(ArticleRepository::class); 9 10 $articles = $repository->getAll(); 11 12 $this->assertCount(1, $articles); 13 } 14 @opdavies
  50. . 1 / 1 (100%) Time: 00:00.439, Memory: 6.00 MB

    OK (1 test, 11 assertions) @opdavies
  51. 1 $this->createNode([ 2 'title' => 'Test post', 3 'type' =>

    'article', 4 ]); 5 6 $repository = $this->container->get(ArticleRepository::class); 7 8 $articles = $repository->getAll(); 9 10 $this->assertCount(1, $articles); 11 $this->assertIsObject($articles[1]); 12 13 $this->assertInstanceOf(NodeInterface::class, $articles[1]); 14 $this->assertSame('article', $articles[1]->bundle()); 15 $this->assertSame('Test post', $articles[1]->label()); 16 @opdavies
  52. 1 $this->createNode(['type' => 'article', 'status' => Node::PUBLISHED]); 2 $this->createNode(['type' =>

    'article', 'status' => Node::NOT_PUBLISHED]); 3 $this->createNode(['type' => 'article', 'status' => Node::PUBLISHED]); 4 $this->createNode(['type' => 'article', 'status' => Node::NOT_PUBLISHED]); 5 $this->createNode(['type' => 'article', 'status' => Node::PUBLISHED]); 6 7 $repository = $this->container->get(ArticleRepository::class); 8 9 $articles = $repository->getAll(); 10 11 $this->assertCount(3, $articles); 12 @opdavies
  53. .F 2 / 2 (100%) Time: 00:00.903, Memory: 6.00 MB

    There was 1 failure: 1) Drupal\Tests\drupalcon\Kernel\ArticleRepositoryTest:: only_published_articles_are_returned Failed asserting that actual size 5 matches expected size 3. @opdavies
  54. 1 public function getAll(): array { 2 return $this->nodeStorage->loadByProperties([ 3

    'status' => NodeInterface::PUBLISHED, 4 ]); 5 } 6 .. 2 / 2 (100%) Time: 00:00.891, Memory: 6.00 MB OK (2 tests, 22 assertions) @opdavies
  55. 1 $this->createNode(['type' => 'article', 2 'created' => (new DrupalDateTime('-2 days'))->getTimestamp()]);

    3 $this->createNode(['type' => 'article', 4 'created' => (new DrupalDateTime('-1 week'))->getTimestamp()]); 5 $this->createNode(['type' => 'article', 6 'created' => (new DrupalDateTime('-1 hour'))->getTimestamp()]); 7 $this->createNode(['type' => 'article', 8 'created' => (new DrupalDateTime('-1 year'))->getTimestamp()]); 9 $this->createNode(['type' => 'article', 10 'created' => (new DrupalDateTime('-1 month'))->getTimestamp()]); 11 12 $repository = $this->container->get(ArticleRepository::class); 13 $nodes = $repository->getAll(); 14 15 $this->assertSame([3, 1, 2, 5, 4], array_keys($nodes)); 16 @opdavies
  56. F 1 / 1 (100%) Time: 00:00.449, Memory: 8.00 MB

    There was 1 failure: 1) Drupal\Tests\drupalcon\Kernel\ArticleRepositoryTest::nodes_are_ordered_by_date_and_ returned_newest_first Failed asserting that two arrays are identical. --- Expected +++ Actual @@ @@ Array &0 ( - 0 => 3 - 1 => 1 - 2 => 2 - 3 => 5 @opdavies
  57. - 4 => 4 + 0 => 1 + 1

    => 2 + 2 => 3 + 3 => 4 + 4 => 5 ) /app/vendor/phpunit/phpunit/src/Framework/Constraint/Constraint.php:121 /app/vendor/phpunit/phpunit/src/Framework/Constraint/IsIdentical.php:79 /app/web/modules/custom/drupalcon/tests/src/ArticleRepositoryTest.php:60 /app/vendor/phpunit/phpunit/src/Framework/TestResult.php:728 FAILURES! Tests: 1, Assertions: 11, Failures: 1. @opdavies
  58. 1 $articles = $this->nodeStorage->loadByProperties([ 2 'status' => NodeInterface::PUBLISHED, 3 ]);

    4 5 uasort($articles, fn (NodeInterface $a, NodeInterface $b) => 6 $b->getCreatedTime() <=> $a->getCreatedTime()); 7 8 return $articles; 9 . 1 / 1 (100%) Time: 00:00.462, Memory: 6.00 MB OK (1 test, 11 assertions) @opdavies