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
-
Unit
-
Kernel
-
Functional
-
Functional Javascript
System Testing
Integration Testing
Unit Testing
Acceptance Testing
BrowserTestBase
KernelTestBase
UnitTestBase
Behat
Note: Read more at http://softwaretestingfundamentals.com/unit-testing/
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.php1. Running PHPUnit
2. Javascript test setup
ddev add-on get ddev/ddev-selenium-standalone-chrome
ddev restartUse 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
- Do not use public property
- Do not use deprecated APIs
- Avoid utility class/service
- 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;
}
- No clarity on grouping
- Become complex so quick
- 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.php3.3 Annotations
- @group
- @runTestsInSeparateProcesses
- @preserveGlobalState
Tools
-
Behat for core: https://github.com/vijaycs85/drupal-core-extension
-
Quality checker: https://github.com/vijaycs85/drupal-quality-checker
- Drupal 8 testing gitbook: https://drupadocs.gitbooks.io/testing/content/
References
Thank you!
Drupal 8 Testing
By Vijay Chandran Mani
Drupal 8 Testing
Automated testing framework available in Drupal 8 core and integration with external frame works.
- 1,249
