talks/drupal-testing-workshop/2018-09-19-microserve/slides.md

22 KiB

autoscale: true build-lists: true header-emphasis: #3D85C6 header: alignment(left) text: alignment(left) text-emphasis: #3D85C6 theme: poster, 8 code: Monaco, #6699FF, #999999, #6666FF, #66FF66, #66FF66, line-height(1.5)

[.header: alignment(center)]

[fit] Drupal Testing Workshop

September 2018


[.header: alignment(center)]

Why write tests?


Why write tests?

^ 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


[.header: alignment(center)]

[fit] Having tests does not mean

[fit] there will be no bugs


[.header: alignment(center)]

[fit] Testing may add time now

[fit] but save more time in the future


Testing in Drupal

  • Drupal 7 - Simpletest (testing) module provided as part of core
  • Drupal 8 - PHPUnit added as a core dependency
  • PHPUnit Initiative - Simpletest to be deprecated and removed

^ Focussing on PHPUnit today


[.header: #3D85C6]

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
  • Each method must start with test

^ Different to D7


Exercise 1

Local site setup


[.header: #3D85C6]

Docksal

  • Docker based local development environment
  • Microserve standard
  • Open source
  • Per site configuration and customisation
  • fin CLI, Apache, MySQL, Solr, Varnish, Mailhog, PHPMyAdmin etc
  • Virtualbox or native Docker
  • Can slow down tests
  • Provides consistency

^ Contains Drupal 8 with Composer, examples module


Exercise 2

Running Tests


Option 1

Simpletest module (UI)


fit


fit


fit


fit


fit


fit


fit


Option 2

Command line


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"

cd web

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

cd web/core

../../vendor/bin/phpunit \
../modules/contrib/examples/phpunit_example

Pro-tip: Add paths to $PATH

# ~/.zshrc

export PATH=$HOME/bin:/usr/local/bin:$PATH

export PATH=vendor/bin:$PATH
export PATH=../vendor/bin:$PATH
export PATH=node_modules/.bin:$PATH

Option 2

CLI with Docksal


fin bash

cd web

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

fin bash

cd web/core

../../vendor/bin/phpunit \
../modules/contrib/examples/phpunit_example

Option 3

Docksal PHPUnit addon


  • Custom Docksal command
  • Submitted to the Docksal addons repo
  • fin addon install phpunit
  • Wrapper around phpunit command
  • Copies a stub phpunit.xml file if exists, or duplicates phpunit.xml.dist
  • Shorter command, combines two actions

^ Checks for core/phpunit.xml on each test run Will create one if is not present


fin phpunit web/modules/contrib/examples/phpunit_example

Copying stubs from /var/www/.docksal/addons/phpunit/stubs

PHPUnit 6.5.8 by Sebastian Bergmann and contributors.

Testing web/modules/contrib/examples/phpunit_example
..................................                                34 / 34 (100%)

Time: 46.8 seconds, Memory: 6.00MB

OK (34 tests, 41 assertions)

fin phpunit web/modules/contrib/examples/phpunit_example


Copying /var/www/web/core/phpunit.xml.dist to /var/www/web/core/phpunit.xml.
Please edit it's values as needed and re-run 'fin phpunit'.

fin phpunit web/modules/contrib/examples/phpunit_example


PHPUnit 6.5.8 by Sebastian Bergmann and contributors.

Testing web/modules/contrib/examples/phpunit_example
..................................                                34 / 34 (100%)

Time: 48.62 seconds, Memory: 6.00MB

OK (34 tests, 41 assertions)

Option 4

IDE/text editor integration


fit


[.header: alignment(center)]

Types of tests


[.header: #3D85C6]

Functional tests

  • Tests functionality
  • Interacts with database
  • Full Drupal installation
  • With/without JavaScript

^ testing profile


[.header: #3D85C6]

Functional tests

  • Slower to run
  • Easiest to start with
  • Provide most value

^ Less setup steps No mocking etc.


Exercise

Let's write a
functional test


  • Create a web/modules/custom/workshop directory
  • Create a workshop.info.yml file

# workshop.info.yml

name: Drupal Testing Workshop
core: 8.x
type: module

  • Create a tests/src/Functional directory
  • Create an ExampleFunctionalTest.php file

// ExampleFunctionalTest.php

namespace Drupal\Tests\workshop\Functional;

use Drupal\Tests\BrowserTestBase;

class ExampleFunctionalTest extends BrowserTestBase {

}



// ExampleFunctionalTest.php

public function testExamplePageExists() {

}


// ExampleFunctionalTest.php

public function test_example_page_exists() {

}

^ Snake case test method names Still works because it has the 'test' prefix More readable than camel case? Works with Simpletest/D7



// ExampleFunctionalTest.php

/** @test */
public function example_page_exists() {

}

^ Remove the prefix, use annotation PHPUnit only



// ExampleFunctionalTest.php

/** @test */
public function example_page_exists() {
  // Arrange

  // Act

  // Assert
}


// ExampleFunctionalTest.php

/** @test */
public function example_page_exists() {
  // Given that I am an anonymous user.

  // When I go to /blog.

  // I should see the blog page.
}

// ExampleFunctionalTest.php

/** @test */
public function example_page_exists() {
  $this->drupalGet('/example-one');

  $this->assertSession()->statusCodeEquals(200);
}

// ExampleFunctionalTest.php

protected static $modules = ['workshop'];

PHPUnit 6.5.8 by Sebastian Bergmann and contributors.

Testing Drupal\Tests\workshop\Functional\ExampleFunctionalTest

Behat\Mink\Exception\ExpectationException : Current response status code is 404, but 200 expected.
 /var/www/vendor/behat/mink/src/WebAssert.php:768
 /var/www/vendor/behat/mink/src/WebAssert.php:130
 /var/www/web/modules/custom/workshop/tests/src/Functional/ExampleFunctionalTest.php:14

Time: 18.2 seconds, Memory: 6.00MB

ERRORS!
Tests: 1, Assertions: 2, Errors: 1.

  • Create a workshop.routing.yml file
  • Create a Controller

# workshop.routing.yml

workshop.example:
  path: '/example-one'
  defaults:
    _controller: 'Drupal\workshop\Controller\ExampleController::index'
  requirements:
    _access: 'TRUE'

// src/Controller/ExampleController.php

namespace Drupal\workshop\Controller;

class ExampleController {

  public function index() {
    return [];
  }

}

Time: 25.22 seconds, Memory: 6.00MB

OK (1 test, 3 assertions)

[.header: #3D85C6]

Kernel tests

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

Exercise

Let's write a
kernel test


  • Create a tests/src/Kernel directory
  • Create an ExampleKernelTest.php file
  • Create a Service
  • Use the service within the test to perform an action

// tests/src/Kernel/ExampleKernelTest.php

namespace Drupal\Tests\workshop\Kernel;

use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\user\Entity\User;

class ExampleKernelTest extends EntityKernelTestBase {

  public static $modules = ['workshop'];

}


// tests/src/Kernel/ExampleKernelTest.php

public function testUserDeleter {
  $user = $this->createUser();

  $user_deleter = \Drupal::service(UserDeleter::class);
  $user_deleter->delete($user);

  $user = $this->reloadEntity($user);

  $this->assertNull($user);
}

# workshop.services.yml

services:
  workshop.user_deleter:
    class: Drupal\workshop\Service\UserDeleter

# workshop.services.yml

services:
  Drupal\workshop\Service\UserDeleter: ~

Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException :
You have requested a non-existent service
"Drupal\Tests\workshop\Kernel\UserDeleter".

// src/Service/UserDeleter.php

namespace Drupal\workshop\Service;

use Drupal\Core\Session\AccountInterface;

class UserDeleter {

  public function delete(AccountInterface $user) {
    user_delete($user->id());
  }

}

// tests/src/Kernel/ExampleKernelTest.php

namespace Drupal\Tests\workshop\Kernel;

use Drupal\workshop\Service\UserDeleter;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\user\Entity\User;

...

^ Import new UserDeleter


Drupal\Core\Entity\EntityStorageException : SQLSTATE[HY000]:
General error: 1 no such table:
test89378988.users_data: DELETE FROM {users_data}
WHERE uid IN (:db_condition_placeholder_0); Array
(
    [:db_condition_placeholder_0] => 1
)

// tests/src/Kernel/ExampleKernelTest.php

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

  $this->installSchema('user', ['users_data']);
}

OK (1 test, 5 assertions)

[.header: #3D85C6]

Unit tests

  • Tests PHP logic
  • No database interaction
  • Fast to run
  • Tightly coupled
  • Mocking dependencies
  • Hard to refactor

Exercise

Let's write a
unit test


// tests/src/Unit/Service/ExampleUnitTest.php

namespace Drupal\Tests\workshop\Unit;

use Drupal\Tests\UnitTestCase;

class ExampleUnitTest extends UnitTestCase {

  public function testAdd() {
    $this->assertEquals(5, (new Calculator(3))->add(2)->calculate());
  }
}

Error : Class 'Drupal\Tests\workshop\Unit\Calculator' not found
 /var/www/web/modules/custom/workshop/tests/src/Unit/Service/ExampleUnitTest.php:10

// src/Service/Calculator.php

namespace Drupal\workshop\Service;

class Calculator {

  private $total;

  public function __construct($value) {
    $this->total = $value;
  }

  public function add($value) {
    $this->total += $value;

    return $this;
  }

  public function calculate() {
    return $this->total;
  }

}

// tests/src/Unit/Service/ExampleUnitTest.php

namespace Drupal\Tests\workshop\Unit;

use Drupal\workshop\Service\Calculator;
use Drupal\Tests\UnitTestCase;

...

Time: 4.55 seconds, Memory: 4.00MB

OK (1 test, 1 assertion)

[.header: alignment(center)]

Test driven
development (TDD)


Test Driven Development

  • Write a failing test
  • Write code until passes
  • Refactor
  • Repeat

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

100%


How I Write Tests - "Outside In"

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

Exercise

Let's build a blog using test driven development


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

Implementation

  • Use views module
  • Do the mininum amount at each step, make no assumptions, let the tests guide us
  • Start with functional test

Step 1

Create the module


# tdd_blog.info.yml

name: 'TDD Blog'
core: '8.x'
type: 'module'

Step 2

Ensure the blog page exists


// tests/src/Functional/BlogPageTest.php

namespace Drupal\Tests\tdd_blog\Functional;

use Drupal\Tests\BrowserTestBase;

class BlogPageTest extends BrowserTestBase {

  protected static $modules = ['tdd_blog'];

}

public function testBlogPageExists() {
  $this->drupalGet('/blog');

  $this->assertSession()->statusCodeEquals(200);
}

There was 1 error:

1) Drupal\Tests\tdd_blog\Functional\BlogPageTest::testBlogPageExists
Behat\Mink\Exception\ExpectationException: Current response status code is 404, but 200 expected.

  • The view has not been created
  • Create a new view, page display
  • Set the path
  • Export the config
  • Copy it into the module's config/install directory

fit


fit


drush cex -y

cp ../config/default/views.view.blog.yml \
  modules/custom/tdd_blog/config/install

# views.view.blog.yml

- uuid: 84305edf-7aef-4109-bc93-e87f685fb678
langcode: en
status: true
dependencies:
  config:
    - node.type.article
  module:
    - node
    - user
- _core:
-   default_config_hash: iGZkqLWpwWNORq6_fy6v_Kn_KE4BjYHqj9vpgQsWJCs
id: blog
...

1) Drupal\Tests\tdd_blog\Functional\BlogPageTest::testBlogPageExists
Drupal\Core\Config\UnmetDependenciesException: Configuration objects provided 
by <em class="placeholder">tdd_blog</em>
have unmet dependencies: <em class="placeholder">views.view.blog
(node.type.article, node, views)</em>

# tdd_blog.info.yml

name: 'TDD Blog'
description: 'A demo module to show test driven module development.'
core: 8.x
type: module

dependencies:
  - 'drupal:node'
  - 'drupal:views'

1) Drupal\Tests\tdd_blog\Functional\BlogPageTest::testBlogPageExists
Drupal\Core\Config\UnmetDependenciesException: Configuration objects provided 
by <em class="placeholder">tdd_blog</em> have unmet dependencies:
<em class="placeholder">views.view.blog (node.type.article)</em>

  • Add the article content type

OK (1 test, 3 assertions)

[.build-lists: false]

Tasks

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

Step 3

Ensure only published articles are shown


public function testOnlyPublishedArticlesAreShown() {
  // Given I have a mixture of published and unpublished articles,
  // as well as other types of content.

  // When I view the blog page.

  // I should only see the published articles.
}

Option 1

Functional tests


// modules/custom/tdd_blog/tests/src/Functional/BlogPageTest.php

public function testOnlyPublishedArticlesAreShown() {
  // Given I have a mixture of published and unpublished articles,
  // as well as other types of content.
  $node1 = $this->drupalCreateNode(['type' => 'page', 'status' => 1]);
  $node2 = $this->drupalCreateNode(['type' => 'article', 'status' => 1]);
  $node3 = $this->drupalCreateNode(['type' => 'article', 'status' => 0]);

  // When I view the blog page.
  $this->drupalGet('/blog');

  // I should only see the published articles.
  $assert = $this->assertSession();
  $assert->pageTextContains($node2->label());
  $assert->pageTextNotContains($node1->label());
  $assert->pageTextNotContains($node3->label());
}

^ Different ways to achieve this. This is taking the functional test approach.


Option 2

Kernel tests


namespace Drupal\Tests\tdd_blog\Kernel;

use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\Tests\node\Traits\NodeCreationTrait;

class BlogPageTest extends EntityKernelTestBase {

  use NodeCreationTrait;

  public static $modules = ['node'];

}


public function testOnlyPublishedArticlesAreShown() {
  $this->createNode(['type' => 'page', 'status' => 1]);
  $this->createNode(['type' => 'article', 'status' => 1]);
  $this->createNode(['type' => 'article', 'status' => 0]);
}

^ Kernel test approach Dropping down a level No need for the brower, not asserting against HTML Faster to run


1) Drupal\Tests\tdd_blog\Kernel\BlogPageTest::testOnlyPublishedArticlesAreShown
Error: Call to a member function id() on boolean

/var/www/web/core/modules/filter/filter.module:212
/var/www/web/core/modules/node/tests/src/Traits/NodeCreationTrait.php:73
/var/www/web/modules/custom/tdd_blog/tests/src/Kernel/BlogPageTest.php:13

$this->installConfig(['filter']);

public function testOnlyPublishedArticlesAreShown() {
  ...

  $results = views_get_view_result('blog');
}

public function testOnlyPublishedArticlesAreShown() {
  ...

  $results = views_get_view_result('blog');

  $this->assertCount(1, $results);
  $this->assertEquals(2, $results[0]->_entity->id());
}

public static $modules = [
  'node',
  'tdd_blog',
  'views',
];

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

  $this->installConfig(['filter', 'tdd_blog']);
}

There was 1 failure:

1) Drupal\Tests\tdd_blog\Kernel\BlogPageTest::testOnlyPublishedArticlesAreShown
Failed asserting that actual size 2 matches expected size 1.

/Users/opdavies/Code/drupal-testing-workshop/web/modules/custom/tdd_blog/tests/src/Kernel/BlogPageTest.php:23

inline


  • There is no content type filter on the view
  • Add the filter
  • Re-export and save the view

inline


OK (1 test, 6 assertions)

[.build-lists: false]

Tasks

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

Step 4

Ensure the articles are ordered by date


// modules/custom/tdd_blog/tests/src/Kernel/BlogPageTest.php

public function testArticlesAreOrderedByDate() {
  // Given that I have numerous articles with different post dates.

  // When I go to the blog page.

  // The articles are ordered by post date.
}

// modules/custom/tdd_blog/tests/src/Kernel/BlogPageTest.php

public function testArticlesAreOrderedByDate() {
  // Given that I have numerous articles with different post dates.
  $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('+1 day'))->getTimestamp()]);
  $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('+1 month'))->getTimestamp()]);
  $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('+3 days'))->getTimestamp()]);
  $this->createNode(['type' => 'article', 'created' => (new DrupalDateTime('+1 hour'))->getTimestamp()]);

  // When I go to the blog page.

  // The articles are ordered by post date.
}

$this->createNode([
  'type' => 'article',
  'created' => (new DrupalDateTime('+1 day'))->getTimestamp(),
]);

^ Array of default values


// modules/custom/tdd_blog/tests/src/Kernel/BlogPageTest.php

public function testArticlesAreOrderedByDate() {
  ...

  // When I go to the blog page.
  $results = views_get_view_result('blog');

  // The articles are ordered by post date.
}

// modules/custom/tdd_blog/tests/src/Kernel/BlogPageTest.php

public function testArticlesAreOrderedByDate() {
  ...

  // When I go to the blog page.
  $results = views_get_view_result('blog');

  $nids = array_map(function(ResultRow $result) {
    return $result->_entity->id();
  }, $results);

  // The articles are ordered by post date.
}

// modules/custom/tdd_blog/tests/src/Kernel/BlogPageTest.php

public function testArticlesAreOrderedByDate() {
  ...

  // The articles are ordered by post date.
  $this->assertEquals([4, 1, 3, 2], $nids);
}

There was 1 failure:

1) Drupal\Tests\tdd_blog\Kernel\BlogPageTest::testArticlesAreOrderedByDate
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
-    0 => 4
-    1 => 1
-    2 => 3
-    3 => 2
+    0 => '1'
+    1 => '2'
+    2 => '3'
+    3 => '4'

inline


  • There is no sort order defined on the view
  • Add the sort order
  • Re-export the view

inline


OK (1 test, 5 assertions)

[.build-lists: false]

Tasks

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

[.header: alignment(center)]

Take Aways


  • Testing has made me a better developer
  • Testing can produce better quality code
  • Use the right type of test for the right situation
  • Use the right base class, use available traits
  • Writing tests is an investment
  • OK to start small, introduce tests gradually
  • Easier to refactor
  • Tests can pass, but things can still be broken. Tests only report on what they cover.

^ Made me think about how I'm going to do something more starting to do it Less cruft, only write code that serves a purpose Spending time writing tests pays dividends later on Start by introducing tests for new features or regression tests when fixing bugs If you know things pass, then you can refactor code knowing if something is broken Manual testing is still important


[.header: alignment(center)]

Questions?


[.header: alignment(center)]

Thanks