Drupal Testing

How tests improve your architecture

Daniel Speicher

17 March 2022

About us

Agenda

  1. Overview
  2. Testing in Drupal
  3. Outlook

Overview

Components Sources Tools
Drupal Environment PHP Classes
PHP Traits
Schemas
PHP Code Analysis
PHP Unit
Drupal Kernel Test
Website html
css
JavaScript
Drupal Functional Test
Functional-JS Test
Backstop
Use Cases UML Diagrams
User-Stories
Feature Requests
Behat

Testing in Drupal

  • Behat
  • PHP Static Code Analysis
  • Drupal Unit
  • Drupal Kernel
  • Drupal Functional
  • Drupal Functional JavaScript
  • Backstop

Behat

  • Framework for testing business expectations
  • Behaviour-driven development
Feature: Blog entry
  In order to write a blog
  As an editor
  I need to be able to put text into a multiline field

  Rules:
    - Minimum lines of text is 10
    - Maximum lines of text is 200

  Scenario: Writing a blog under 10 lines
    Given there is a text with 9 lines
    When I submit the blog
    Then I should get an error
    And the blog must not be submitted

Example Test Code:

use Behat\Behat\Tester\Exception\PendingException;
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;

class FeatureContext implements SnippetAcceptingContext {
    /**
     * @Given there is a text with :arg1 lines
     */
    public function thereIsATextWithLines($arg1) {
        // ...
    }
    //...
}

Involved roles

  • Customer
  • Product Owner
  • Consultant
  • Developer

Using Behat in Drupal

Behat configuration file behat.yml:

default:
  extensions:
    Drupal\DrupalExtension:
      ...
  autoload:
    '': "%paths.base%/bootstrap/context/"
  suites:
    default:
      contexts:
        - Drupal\DrupalExtension\Context\DrupalContext
        - Drupal\DrupalExtension\Context\MinkContext
...

Drupal Behat test for anonymous users:

Feature: Anonymous User Tests
  In order to protect data of the company and its customers
  As an anonymous user
  I want to make sure that no data is available to anonymous visitors

  Background:
    Given I am not logged in

  Scenario: Check general availability of the site and the anonymous user site
    When I am on "user"
    Then I should see the text "Enter the password that accompanies your username."
    And I should not see the text "Access denied"

DrupalContext:

/**
* @Given I am an anonymous user
* @Given I am not logged in
* @Then I log out
*/
public function assertAnonymousUser()
{
    // Verify the user is logged out.
    $this->logout(true);
}

MinkContext:

/**
* @Then I (should )see the text :text
*/
public function assertTextVisible($text)
{
    // Use the Mink Extension step definition.
    $this->assertPageContainsText($text);
}
Feature: Anonymous User Tests
In order to protect data of the company and its customers
As an anonymous user
I want to make sure that no data is available to anonymous visitors

Background:                # features/basic/anonymous.feature:6
Given I am not logged in   # Drupal\DrupalExtension\Context\DrupalContext::assertAnonymousUser()

Scenario: Check general availability of the site and the anonymous user site      # features/basic/anonymous.feature:9
When I am on "user"                                                               # Drupal\DrupalExtension\Context\MinkContext::visit()
Then I should see the text "Enter the password that accompanies your username."   # Drupal\DrupalExtension\Context\MinkContext::assertTextVisible()
And I should not see the text "Access denied"                                     # Drupal\DrupalExtension\Context\MinkContext::assertNotTextVisible()

1 scenario (1 passed)
4 steps (4 passed)
0m0.19s (16.55Mb)

Static Code Analysis

  • PHP LOC Analysis
  • PHP Copy and Paste Detector
  • PHP Code Sniffer
  • PHP Mess Detector

PHP LOC Analysis

Execute: vendor/bin/phploc "$@"

Size
Lines of Code (LOC)                              365
Comment Lines of Code (CLOC)                     104 (28.49%)
Non-Comment Lines of Code (NCLOC)                261 (71.51%)
Logical Lines of Code (LLOC)                      80 (21.92%)
  Classes                                         80 (100.00%)
    Average Class Length                          80
    Minimum Class Length                          80
    Maximum Class Length                          80
    Average Method Length                          5
    Minimum Method Length                          1
    Maximum Method Length                         24
    Average Methods Per Class                     14
    Minimum Methods Per Class                     14
    Maximum Methods Per Class                     14
    ...

PHP Copy and Paste Detector

Execute: vendor/bin/phpcpd "$@"

Found 1 clones with 65 duplicated lines in 1 files:

- /var/www/html/web/modules/custom/mobimo/src/EventSubscriber/UserCreationSubscriber.php:215-280 (65 lines)
  /var/www/html/web/modules/custom/mobimo/src/EventSubscriber/UserCreationSubscriber.php:285-350

9.04% duplicated lines out of 719 total lines of code.
Average size of duplication is 65 lines, largest clone has 65 of lines

Time: 00:00.006, Memory: 4.00 MB

PHP Code Sniffer

Execute: vendor/bin/phpcs -standard=DrupalPractice

FILE: /var/www/html/web/modules/custom/mobimo/src/EventSubscriber/UserCreationSubscriber.php
-------------------------------------------------------------------------------------------------
FOUND 0 ERRORS AND 1 WARNING AFFECTING 1 LINE
-------------------------------------------------------------------------------------------------
 284 | WARNING | User::load calls should be avoided in classes, use dependency injection instead
-------------------------------------------------------------------------------------------------

Time: 3.56 secs; Memory: 100.01MB

PHP Mess Detection

Execute: vendor/bin/phpmd <path> json design

{
  "files": [
    {
      "file": "\/var\/www\/html\/web\/modules\/custom\/mobimo\/src\/EventSubscriber\/UserCreationSubscriber.php",
      "violations": [
        {
          "beginLine": 25,
          "endLine": 368,
          "package": "Drupal\\mobimo\\EventSubscriber",
          "function": null,
          "class": "UserCreationSubscriber",
          "method": null,
          "description": "The class UserCreationSubscriber has a coupling between objects value of 15. Consider to reduce the number of dependencies under 13.",
          "rule": "CouplingBetweenObjects",
          "ruleSet": "Design Rules",
          "externalInfoUrl": "https:\/\/phpmd.org\/rules\/design.html#couplingbetweenobjects",
          "priority": 2
        }
      ]
    }
  ]
}

Unit Tests

  • cheap
  • no Drupal environment needed
  • base class: UnitTestBase in Drupal\Tests\
  • always (!!) prefer unit tests to kernel and functional tests

Unit Test Example

/**
* Tests to token browser service.
*
* @group eca
*/
class TokenBrowserServiceTest extends UnitTestCase {

  public function testTokenModuleNotInstalled(): void {
    $tokenBrowserService = new TokenBrowserService();
    $this->assertEquals([], $tokenBrowserService->getTokenBrowserMarkup());
  }
}

Mock Dependencies

/**
 * {@inheritdoc}
 */
protected function setUp(): void {
  parent::setUp();
  $this->entityTypeManager = $this->createMock(EntityTypeManagerInterface::class);
}
protected function setUp(): void {
  parent::setUp();
  $currentTimestamp = 1515510000; //2018/01/09 16:00:00
  $this->time = $this->createMock(TimeInterface::class);
  $this->time->expects($this->once())->method('getRequestTime')
      ->willReturn($currentTimestamp);
}
 /**
   * Tests the get and set methods.
   *
   * @return void
   */
  public function testGetterAndSetter(): void {
    $currentTimestamp = 1515510000; //2018/01/09 16:00:00
    $this->time->expects($this->once())->method('getRequestTime')
      ->willReturn($currentTimestamp);
    $this->keyValueFactory->expects($this->once())->method('get')
      ->with('eca')->willReturn($this->keyValueStore);
    $ecaState = new EcaState($this->keyValueFactory, $this->time);
    $ecaState->setTimestamp(self::TEST_KEY);
    $this->assertEquals($currentTimestamp, $ecaState->getTimestamp(self::TEST_KEY));
  }

Code Smells

  • static calls, like \Drupal::currentUser()
  • too many mocks (too many dependencies)
    • Refactorings like extractClass, etc. needed
  • too much complexity, like nested if/else and switch cases
    • extract class, introduce interfaces and use polymorphism
  • etc.

Kernel Tests

  • technically integration tests
  • in-memory pseudo installation
  • adds database
  • no browser interaction
  • don´t install modules automatically
  • config .yml files can be used
  • base class: KernelTestBase in Drupal\Tests\

Kernel Test Example

protected static $modules = [
    'user',
  ];

public function setUp(): void {
  parent::setUp();
  $this->installEntitySchema('user');
  $this->installConfig(static::$modules);
  User::create(['uid' => 1])->save();
}

public function test(): void {
 $titleModified = $title . '_test';
 $node = Node::create([
      'type' => 'article',
      'tnid' => 0,
      'uid' => 1,
      'title' => $title,
    ]);
    $node->save();
 $this->assertEquals($node->label(), $titleModified);
}

Functional Test

  • very expensive
  • full Drupal installation (clean)
  • adds Browser interaction (Mink emulator)
  • installs specified modules
  • base class: BrowserTestBase in Drupal\Tests\

Functional Test Example

public function test(): void {
  $this->adminUser = $this->drupalCreateUser([
      'access toolbar',
      'access administration pages',
    ]);
  $this->drupalLogin($this->adminUser);
  $this->drupalGet('/admin');
  $this->assertMenuHasHref('/admin/structure/');
}

Functional JavaScript Test

  • very expensive
  • adds JavaScript behaviours or Ajax interactions
  • needs a Web Driver like Selenium
  • installs specified modules
  • base class: WebDriverTestBase in Drupal\Tests\

Functional Test Example

  $page = $this->getSession()->getPage();
  $page->selectFieldOption('plugin', 'csv');
  $this->assertSession()->assertWaitOnAjaxRequest();
  $assert->elementExists('css', 'input[name="files[plugin_configuration_csv_file]"]');
  $page->fillField('label', 'Test CSV Importer');
  $this->assertJsCondition('jQuery(".machine-name-value").html() == "test_csv_importer"');

Backstop

  • visual regression testing
  • comparing DOM screenshots over time

Viewports

"viewports": [
  {
        "label": "phone",
        "width": 320,
        "height": 480,
  },
        ...
]

Scenarios

"scenarios": [
  {
        "label": "test_screen",
        "url": "https://testurl.com",
        "referenceUrl": "https://produrl.com",
        "hideSelectors": ['chat'],
        "selectors": [],
        "misMatchThreshold": 0.1,
        ...
  },
        ...
]

Report

Features

  • Authentication
  • Keystrokes
  • Click and Hover Interactions
  • Support Cookies
  • Progressive apps, SPAs and AJAX content

Version Control

  • backstop.json
  • backstop_data/engine_scripts

Outlook

Current usage for LakeDrops

Test Tool Status
Behat In Use
PHP Static Code Analysis Partly in Use
PHP Unit Tests in Use
PHP Kernel Tests in Use
PHP Functional Tests in Use
PHP Functional JavaScript Tests X
Backstop planed

Pipeline

Thank you