Testing

Vijaya Chandran Mani

@vijaycs85

Overview

  • Introduction

  • Types

  • Sample tests

  • Lessons learned

1. Introduction

Why?

  • SDLC - Requirements are not explicit
  • Edge cases are hard to find & fix
  • Express understanding of system
  • Developer Documentation
  • Easy to write
  • Cost effective (DEV -> QA -> PROD -> Bug -> Repeat)
  • Impossible to do CI / CD
  • Security updates
  • Software version upgrade

2. Types of testing 

  1. Unit
  2. Kernel
  3. Functional
  4. Functional Javascript

System Testing

Integration Testing

Unit Testing

Acceptance Testing

BrowserTestBase

KernelTestBase

UnitTestBase

Behat

X

X

Structure

TestCase

UnitTestCase

KernelTestBase

BrowserTestBase

WebDriverTestBase

PHPUnit

Drupal

Folder Structure 

DDEV Setup

# In .ddev/commands/web/phpunit
vendor/bin/phpunit -c /var/www/html/ddev.phpunit.xml $*

# Running test
ddev phpunit -vvv web/core/modules/contact/tests/src/Functional/ContactSitewideTest.php

1. Running PHPUnit

2. Javascript test setup

ddev add-on get ddev/ddev-selenium-standalone-chrome
ddev restart

Use case

Contact module

Contact

  • CRUD contact form
  • Compose message
  • Send mail

Contact form view == message add

2.1 Unit tests

Drupal\Tests\UnitTestCase

Mail handler

namespace Drupal\contact;

/**
 * Provides an interface for assembly and dispatch of contact mail messages.
 */
interface MailHandlerInterface {
  /**
   * Sends mail messages as appropriate for a given Message form submission.
   *
   * Can potentially send up to three messages as follows:
   * - To the configured recipient;
   * - Auto-reply to the sender; and
   * - Carbon copy to the sender.
   *
   * @param \Drupal\contact\MessageInterface $message
   *   Submitted message entity.
   * @param \Drupal\Core\Session\AccountInterface $sender
   *   User that submitted the message entity form.
   *
   * @throws \Drupal\contact\MailHandlerException
   *   When unable to determine message recipient.
   */
  public function sendMailMessages(MessageInterface $message, AccountInterface $sender);
}
// contact.services.yml

  contact.mail_handler:
    class: Drupal\contact\MailHandler
    arguments: ['@plugin.manager.mail', '@language_manager',
             '@logger.channel.contact', '@string_translation', '@entity.manager']

Setup

use  Drupal\Core\Routing\RouteProvider;

class MailHandlerTest extends UnitTestCase {
  protected function setUp() {
    parent::setUp();
    $this->mailManager = $this->getMock(MailManagerInterface::class);
    $this->languageManager = $this->getMock(LanguageManagerInterface::class);
    $this->logger = $this->getMock(LoggerInterface::class);
    $this->entityManager = $this->getMock(EntityManagerInterface::class);
    $this->userStorage = $this->getMock(EntityStorageInterface::class);
    $this->entityManager->expects($this->any())
      ->method('getStorage')
      ->with('user')
      ->willReturn($this->userStorage);

    $string_translation = $this->getStringTranslationStub();

    $this->contactMailHandler = new MailHandler($this->mailManager,
      $this->languageManager, $this->logger, $string_translation, $this->entityManager);

    $language = new Language(['id' => 'en']);

    $this->languageManager->expects($this->any())
      ->method('getDefaultLanguage')
      ->will($this->returnValue($language));

    $this->languageManager->expects($this->any())
      ->method('getCurrentLanguage')
      ->will($this->returnValue($language));
  }
}

Test 1

  /**
   * Tests the children() method with an invalid key.
   *
   * @covers ::sendMailMessages
   */
  public function testInvalidRecipient() {
    $message = $this->getMock('\Drupal\contact\MessageInterface');
    $message->expects($this->once())
      ->method('isPersonal')
      ->willReturn(TRUE);

    $sender = $this->getMock('\Drupal\Core\Session\AccountInterface');
    $this->userStorage->expects($this->any())
      ->method('load')
      ->willReturn($sender);

    $this->setExpectedException(MailHandlerException::class, 'Unable to determine message recipient');

    $this->contactMailHandler->sendMailMessages($message, $sender);
  }

Test 2

 /**
   * Tests the sendMailMessages method.
   *
   * @dataProvider getSendMailMessages
   *
   * @covers ::sendMailMessages
   */
  public function testSendMailMessages(bool $anonymous, ?bool $auto_reply, bool $copy_sender, array $results): void {
      if ($anonymous) {
      $message = $this->getAnonymousMockMessage(explode(', ', $results[0]['to']), $auto_reply, $copy_sender);
      $sender = $this->getMockSender();
    }
    else {
      $message = $this->getAuthenticatedMockMessage($copy_sender);
      $sender = $this->getMockSender(FALSE, 'user@drupal.org');
    }
    $this->logger->expects($this->once())
      ->method('notice');
    $this->mailManager->expects($this->any())
      ->method('mail')
      ->willReturnCallback(
        function($module, $key, $to, $langcode, $params, $from) use (&$results) {
          $result = array_shift($results);
          $this->assertEquals($module, $result['module']);
          $this->assertEquals($key, $result['key']);
          $this->assertEquals($to, $result['to']);
          $this->assertEquals($langcode, $result['langcode']);
          $this->assertArrayEquals($params, $result['params']);
          $this->assertEquals($from, $result['from']);
        });
    $this->userStorage->expects($this->any())
      ->method('load')
      ->willReturn(clone $sender);
    $this->contactMailHandler->sendMailMessages($message, $sender);
  }

Test 2 - DataProvider

/**
   * Data provider for ::testSendMailMessages.
   */
  public function getSendMailMessages() {
  $default_result = [
      'module' => 'contact',
      'key' => 'page_mail',
      'to' => 'admin@drupal.org, user@drupal.org',
      'langcode' => 'en',
      'params' => [],
      'from' => 'anonymous@drupal.org',
    ];

    $autoreply_result = [
      'key' => 'page_autoreply',
      'to' => 'anonymous@drupal.org',
      'from' => NULL,
    ] + $default_result;

    yield 'anonymous, no auto reply, no copy sender' => [TRUE, FALSE, FALSE, [$default_result]];
    yield 'anonymous, auto reply, no copy sender' => [TRUE, TRUE, FALSE, [$default_result, $autoreply_result]];
    ...
  }

MockBuilder vs Prophesize

    // Create a route provider stub.
    $provider = $this->getMockBuilder(RouteProvider::class)
      ->disableOriginalConstructor()
      ->getMock();

    $provider->expects($this->any())
      ->method('getRouteByName')
      ->will($this->returnValueMap($route_name_return_map));

    $this->container = new ContainerBuilder();
    $this->container->set('router.route_provider', $provider);
    // Create a route provider stub.
    $provider =$this->prophesize(RouteProvider::class);

    $provider->getRouteByName()->willReturn($route_name_return_map);

    $this->container = new ContainerBuilder();
    $this->container->set('router.route_provider', $provider->reveal());

Refer: https://www.drupal.org/docs/8/phpunit/comparison-with-phpunit-mocks

Summary

  • Method level testing
  • Easy to add multiple data set (@dataProvider)
  • Fastest test runs.

2.2 Kernel tests

Drupal\KernelTests\KernelTestBase

Message Entity

/**
 * Defines the contact message entity.
 *
 * @ContentEntityType(
 *   id = "contact_message",
 *   label = @Translation("Contact message"),
 *   handlers = {
 *     "access" = "Drupal\contact\ContactMessageAccessControlHandler",
 *     "storage" = "Drupal\Core\Entity\ContentEntityNullStorage",
 *     "view_builder" = "Drupal\contact\MessageViewBuilder",
 *     "form" = {
 *       "default" = "Drupal\contact\MessageForm"
 *     }
 *   },
 *   admin_permission = "administer contact forms",
 *   entity_keys = {
 *     "bundle" = "contact_form",
 *     "uuid" = "uuid",
 *     "langcode" = "langcode"
 *   },
 *   bundle_entity_type = "contact_form",
 *   field_ui_base_route = "entity.contact_form.edit_form",
 * )
 */
class Message extends ContentEntityBase implements MessageInterface {

Setup

class MessageEntityTest extends EntityKernelTestBase {

  public static $modules = [
    'system',
    'contact',
    'field',
    'user',
    'contact_test',
  ];

  protected function setUp() {
    parent::setUp();
    $this->installConfig(['contact', 'contact_test']);
  }
  • installSchema - DB tables
  • installConfig - config/install
  • installEntitySchema - Storage for entities

 

Test 1

  public function testMessageMethods() {

    $message_storage = $this->container->get('entity.manager')->getStorage('contact_message');
    $message = $message_storage->create(['contact_form' => 'feedback']);

    // Check for empty values first.
    $this->assertEquals($message->getMessage(), '');
    $this->assertEquals($message->getSenderName(), '');

    // Set some values and check for them afterwards.
    $message->setMessage('welcome_message');
    $message->setSenderName('sender_name');

    // Check set values.
    $this->assertEquals($message->getMessage(), 'welcome_message');
    $this->assertEquals($message->getSenderName(), 'sender_name');

Summary

  • Functional integration testing
  • Minimal/on-demand bootstrap
  • Prefilled service container & DB
  • Much faster compared to Browser test

Background

Behat

(Gherkin syntax)

Mink

(Browser emulator)

Driver

Goutte

Zombie

Selenium2

Sahi

E

C

E

Zombie.JS

(Node.js)

Chrome

Guzzle

(PHP)

C

Sahi

(Java)

Ctrl / Emulator

2.3 Function Tests

Drupal\Tests\BrowserTestBase

TestBase

Mink

(Browser emulator)

Drivers

Goutte

E

Guzzle

(PHP )

Ctrl / Emulator

Setup

class ContactSitewideTest extends BrowserTestBase {

  use FieldUiTestTrait;
  use AssertMailTrait;

  public static $modules = [
    'text',
    'contact',
    'field_ui',
    'contact_test',
    'block',
    'error_service_test',
    'dblog',
  ];

  protected function setUp() {
    parent::setUp();
    $this->drupalPlaceBlock('system_breadcrumb_block');
    $this->drupalPlaceBlock('local_actions_block');
    $this->drupalPlaceBlock('page_title_block');
  }
}

Test

  public function testSiteWideContact() {
    // Create and log in administrative user.
    $admin_user = $this->drupalCreateUser([
      'access site-wide contact form',
      ...
      ...
    ]);
    $this->drupalLogin($admin_user);

    // Check the presence of expected cache tags.
    $this->drupalGet('contact');
    $this->assertCacheTag('config:contact.settings');

    $edit['contact_default_status'] = TRUE;
    $this->drupalPostForm('admin/config/people/accounts', $edit, t('Save configuration'));

    $this->drupalGet('admin/structure/contact');
    // Default form exists.
    $this->assertLinkByHref('admin/structure/contact/manage/feedback/delete');

Summary

  • Focuses on end-to-end functionality
  • Runs in fresh installation
  • Full bootstrap
  • Less code to cover most of functional elements
  • Slow compare to Unit and Kernel tests

2.4 Function Javascript Tests

Drupal\FunctionalJavascriptTests\WebDriverTestBase

TestBase

Mink

(Browser emulator)

Drivers

Selenium

C

Chrome

Ctrl / Emulator

Setup

use Drupal\FunctionalJavascriptTests\WebDriverTestBase;

/**
 * Tests for the machine name field.
 *
 * @group field
 */
class MachineNameTest extends WebDriverTestBase {

  public static $modules = ['node', 'form_test'];

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

    $account = $this->drupalCreateUser([
      'access content',
    ]);
    $this->drupalLogin($account);
  }

}

Test

  public function testMachineName() {

    // Visit the machine name test page which contains two machine name fields.
    $this->drupalGet('form-test/machine-name');

    // Get page and session.
    $page = $this->getSession()->getPage();

    $title = $page->findField('machine_name_1_label');
    $machine_name_field = $page->findField('machine_name_1');

    $machine_name_wrapper = $machine_name_field->getParent();
    $machine_name_value = $page->find('css', '#edit-machine-name-1-label-machine-name-suffix .machine-name-value');

    // Assert field is initialized correctly.
    $this->assertNotEmpty($machine_name_value, 'Machine name field must be initialized');

    // Set the value for the field, triggering the machine name update.
    $title->setValue('Test value !0-9@');
 
   // Wait the set timeout for fetching the machine name.
   $this->assertJsCondition('jQuery("#edit-machine-name-1-label-machine-name-suffix .machine-name-value").html() == "test_value_0_9_"');

   // Validate the generated machine name.
   $this->assertEquals('test_value_0_9_', $machine_name_value->getHtml(), $test_info['message']);

Summary

  • Tests browser level interactions
  • Needs ChromeDriver running
  • Generates Screenshots!

3. Lessons learned

3.1 Code

  1. Do not use public property
  2. Do not use deprecated APIs
  3. Avoid utility class/service
  4. Write code -> write test -> refactor -> repeat

1. Do not use public property

Class Message {

  public $body;
}
Class Mail {
  public function send() {
    mail($message->body, ....)
  }
Class Message {
  protected $body;

  public function getBody() {
    return $this->body;
  }
Class Mail {

  public function send() {
    return $message->getBody();
  }

2. Do not use deprecated APIs

  /**
   * @see \Drupal\simpletest\TestBase::assertEqual()
   *
   * @deprecated Scheduled for removal in Drupal 9.0.0. Use self::assertEquals()
   *   instead.
   */
  protected function assertEqual($actual, $expected, $message = '') {
    $this->assertEquals($expected, $actual, $message);
  }

3. Avoid Utility Class/Service

namespace Drupal\module_name\Utility

Class Cows {}
Class Elemphents{}
Class Buses{}

namespace Drupal\module_name\Animals

Class Cow extend DomesticAnimalBase implements AnimalInterface {}
Class Elemphent implements AnimalInterface{}


namespace Drupal\module_name\Motors
Class Bus extends WheelBasedVehicleBase implement TransportInterface, MotorVehicleInterface {
 use SeatsTrait;
 use NavigationTrait;
}
  1. No clarity on grouping
  2. Become complex so quick
  3. No way to re-use

Option 1: Convert to core component:

  •  Plugin
  •  EvenDispatcher/subscriber

Option 2:

3.2 Run Tests

 ddev phpunit -vvv web/core/modules/contact/tests/src/Functional/ContactSitewideTest.php

3.3 Annotations

  • @group
  • @runTestsInSeparateProcesses
  • @preserveGlobalState

Tools

References

Thank you!