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. TDD: Test Driven
    Drupal
    Oliver Davies (@opdavies)
    https://opdavi.es/tdd-test-driven-drupal

    View full-size slide

  2. Software Engineer, Full Stack
    Development Consultant,
    Open-Source Maintainer
    @opdavies

    View full-size slide

  3. 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

    View full-size slide

  4. 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

    View full-size slide

  5. 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

    View full-size slide

  6. Arrange, Act, Assert
    @opdavies

    View full-size slide

  7. Given, When, Then
    @opdavies

    View full-size slide

  8. 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

    View full-size slide

  9. 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

    View full-size slide

  10. Writing test methods
    1 public function testSomething()
    2
    3 public function test_something()
    4
    5 /** @test */
    6 public function it_does_something()
    @opdavies

    View full-size slide

  11. Types of Tests
    • Functional/FunctionalJavascript (web, browser, feature)
    • Kernel (integration)
    • Unit
    @opdavies

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  14. 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

    View full-size slide

  15. Running Tests
    @opdavies

    View full-size slide

  16. 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

    View full-size slide

  17. Core script
    $ php web/core/scripts/run-tests.sh \
    --module example \
    --sqlite /dev/shm/test.sqlite \
    --url http://web
    @opdavies

    View full-size slide

  18. 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

    View full-size slide

  19. PHPUnit
    $ export SIMPLETEST_BASE_URL=http://web
    $ web/vendor/bin/phpunit \
    -c web/core \
    modules/contrib/examples/modules/phpunit_example
    @opdavies

    View full-size slide

  20. PHPUnit 9.6.13 by Sebastian Bergmann and contributors.
    Testing /app/web/modules/contrib/examples/modules/phpunit_example
    ................................. 33 / 33 (100%)
    Time: 00:08.660, Memory: 10.00 MB
    OK (33 tests, 43 assertions)
    @opdavies

    View full-size slide

  21. 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

    View full-size slide

  22. Example
    @opdavies

    View full-size slide

  23. 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

    View full-size slide

  24. 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

    View full-size slide

  25. 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

    View full-size slide

  26. 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

    View full-size slide

  27. 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

    View full-size slide

  28. Results
    • 0 bugs!
    • Easier to identify where issues occurred and responsibilities
    • Reduced debugging time
    @opdavies

    View full-size slide

  29. Test Driven Development
    • Write a failing test
    • Write code until the test passes
    • Refactor
    • Repeat
    @opdavies

    View full-size slide

  30. Red, Green, Refactor
    @opdavies

    View full-size slide

  31. Porting Modules to Drupal 8
    • Make a new branch
    • Add/update the tests
    • Write code to make the tests pass
    • Refactor
    • Repeat
    @opdavies

    View full-size slide

  32. 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

    View full-size slide

  33. 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

    View full-size slide

  34. Demo: Building a blog module
    @opdavies

    View full-size slide

  35. 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

    View full-size slide

  36. Tasks
    • Ensure the blog page exists
    • Ensure only published articles are shown
    • Ensure the articles are shown in the correct order
    @opdavies

    View full-size slide

  37. 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

    View full-size slide

  38. 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

    View full-size slide

  39. 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

    View full-size slide

  40. 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

    View full-size slide

  41. 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

    View full-size slide

  42. 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

    View full-size slide

  43. 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

    View full-size slide

  44. 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

    View full-size slide

  45. . 1 / 1 (100%)
    Time: 00:01.916, Memory: 6.00 MB
    OK (1 test, 3 assertions)
    Task completed in 0m2.147s
    @opdavies

    View full-size slide

  46. 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('Blog');
    9 $session->pageTextContains('Welcome to my blog!');
    10 }
    11
    @opdavies

    View full-size slide

  47. 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

    View full-size slide

  48. . 1 / 1 (100%)
    Time: 00:01.911, Memory: 6.00 MB
    OK (1 test, 3 assertions)
    @opdavies

    View full-size slide

  49. 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

    View full-size slide

  50. 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

    View full-size slide

  51. 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

    View full-size slide

  52. 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

    View full-size slide

  53. 1 namespace Drupal\drupalcon\Repository;
    2
    3 final class ArticleRepository {
    4
    5 public function getAll(): array {
    6 return [];
    7 }
    8
    9 }
    10
    @opdavies

    View full-size slide

  54. 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

    View full-size slide

  55. 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

    View full-size slide

  56. 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

    View full-size slide

  57. 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

    View full-size slide

  58. 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

    View full-size slide

  59. 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

    View full-size slide

  60. . 1 / 1 (100%)
    Time: 00:00.439, Memory: 6.00 MB
    OK (1 test, 11 assertions)
    @opdavies

    View full-size slide

  61. 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

    View full-size slide

  62. 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

    View full-size slide

  63. .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

    View full-size slide

  64. 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

    View full-size slide

  65. 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

    View full-size slide

  66. 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

    View full-size slide

  67. - 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

    View full-size slide

  68. 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

    View full-size slide

  69. Thanks!
    References:
    • https://phpunit.de
    • https://docs.phpunit.de
    • https://www.drupal.org/docs/automated-testing
    Me:
    • https://www.oliverdavies.uk
    • https://www.oliverdavies.uk/atdc
    @opdavies

    View full-size slide