Skip to content

How-to guideFor module developersDrupal 11

Testing

Drupal modules are tested with PHPUnit, extended by core with base classes that can boot anything from nothing at all up to a full site driven by a real browser. This page shows you how to pick the right test type, write each one, and run them.

Your module has automated tests at the cheapest level that can prove each behavior, and you can run them locally and in CI. The art is in the word cheapest: a functional test that boots a whole site to check string formatting wastes minutes on every run, and a unit test that mocks half of Drupal proves nothing.

  • A working module with some logic worth testing; the flagship tutorial builds one, and its final part writes tests for it.
  • PHPUnit and the Drupal testing framework, which come with the drupal/core-dev Composer package: composer require --dev drupal/core-dev.
  • A local environment (Set up your environment); the commands below assume DDEV.

Four types, four base classes, four directories. From the cheapest up:

TypeBase classDirectoryWhat exists at runtime
UnitUnitTestCasetests/src/Unit/Nothing. No container, no database.
KernelKernelTestBasetests/src/Kernel/A real container and database; only the modules you list, minimally installed.
FunctionalBrowserTestBasetests/src/Functional/A full installed site, driven by a simulated browser without JavaScript.
FunctionalJavascriptWebDriverTestBasetests/src/FunctionalJavascript/A full installed site, driven by real Chrome through WebDriver.

The namespace mirrors the directory: tests for my_module live in Drupal\Tests\my_module\Unit, ...\Kernel, and so on. Decision rule: pure logic with mockable collaborators is a unit test; anything touching services, the database, or entity APIs is a kernel test; anything about pages, forms, and access is a functional test; and only behavior that requires JavaScript execution justifies the FunctionalJavascript cost.

Test metadata is written as PHP attributes: #[Group('my_module')] on every test class, #[CoversClass(...)] to declare the class under test, and #[RunTestsInSeparateProcesses] on kernel, functional, and FunctionalJavascript tests, matching how core writes its own tests in Drupal 11.3. The old @group annotations are the previous generation; new code uses attributes.

Unit tests construct the class under test by hand and replace every collaborator with a test double. This one fakes an entire HTTP API with Guzzle’s MockHandler, so it runs in milliseconds and needs no network:

tests/src/Unit/RemoteArticleClientTest.php
<?php
declare(strict_types=1);
namespace Drupal\Tests\module_howtos_b_demo\Unit;
use Drupal\Core\Site\Settings;
use Drupal\module_howtos_b_demo\RemoteArticleClient;
use Drupal\Tests\UnitTestCase;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Psr\Log\NullLogger;
/**
* Tests the remote article client without Drupal or a real network.
*
* Unit tests have no container and no database: every collaborator is
* replaced with a mock, a stub, or a fake. Here the Guzzle client gets
* a MockHandler that replays canned responses.
*/
#[CoversClass(RemoteArticleClient::class)]
#[Group('module_howtos_b_demo')]
class RemoteArticleClientTest extends UnitTestCase {
/**
* Builds the client under test around a fake HTTP handler.
*
* @param \GuzzleHttp\Handler\MockHandler $handler
* The handler that replays the queued responses.
*
* @return \Drupal\module_howtos_b_demo\RemoteArticleClient
* The client under test.
*/
protected function createClient(MockHandler $handler): RemoteArticleClient {
// Settings is a static singleton; constructing it installs the
// values the class under test reads with Settings::get().
new Settings(['docs_demo_api_token' => 'test-token']);
$http_client = new Client(['handler' => HandlerStack::create($handler)]);
return new RemoteArticleClient($http_client, new NullLogger());
}
/**
* A successful response is decoded into an array.
*/
public function testFetchSummariesDecodesJson(): void {
$client = $this->createClient(new MockHandler([
new Response(200, [], '[{"id":"abc","title":"Hello"}]'),
]));
$this->assertSame([['id' => 'abc', 'title' => 'Hello']], $client->fetchSummaries());
}
/**
* Failures are caught and reported as an empty result.
*/
public function testFetchSummariesHandlesServerError(): void {
$client = $this->createClient(new MockHandler([
new Response(503),
]));
$this->assertSame([], $client->fetchSummaries());
}
}

If you find yourself mocking chains of Drupal services to make a unit test possible, stop: that test wants to be a kernel test.

Kernel tests give you the real container and a real database at a fraction of a functional test’s cost. You declare modules in $modules, and you install only the pieces you need: installSchema() for tables, installEntitySchema() for entity types, installConfig() for default configuration. Nothing happens implicitly; that is what makes them fast.

web/modules/custom/event_rsvp/tests/src/Kernel/RsvpStorageTest.php
<?php
declare(strict_types=1);
namespace Drupal\Tests\event_rsvp\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\event_rsvp\RsvpStorage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Tests the RSVP storage service.
*
* Kernel tests boot a minimal Drupal with only the modules you list,
* giving you a real container and database at a fraction of the cost
* of a functional test.
*/
#[CoversClass(RsvpStorage::class)]
#[Group('event_rsvp')]
#[Group('flagship')]
#[RunTestsInSeparateProcesses]
class RsvpStorageTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['event_rsvp'];
/**
* The service under test.
*/
protected RsvpStorage $storage;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Kernel tests don't run hook_install(), so create the module's
// tables from its hook_schema() explicitly.
$this->installSchema('event_rsvp', ['event_rsvp']);
$this->storage = $this->container->get('event_rsvp.storage');
}
/**
* Records RSVPs and reads them back.
*/
public function testAddAndCount(): void {
$this->assertSame(0, $this->storage->countForNode(1));
$this->storage->add(1, 0, 'ada@example.com');
$this->storage->add(1, 2, 'grace@example.com');
$this->storage->add(7, 0, 'ada@example.com');
$this->assertSame(2, $this->storage->countForNode(1));
$this->assertSame(1, $this->storage->countForNode(7));
$this->assertTrue($this->storage->exists(1, 'ada@example.com'));
$this->assertFalse($this->storage->exists(1, 'linus@example.com'));
$records = $this->storage->listForNode(1);
$this->assertCount(2, $records);
}
/**
* Deleting a node's RSVPs leaves other nodes untouched.
*/
public function testDeleteForNode(): void {
$this->storage->add(1, 0, 'ada@example.com');
$this->storage->add(7, 0, 'ada@example.com');
$this->storage->deleteForNode(1);
$this->assertSame(0, $this->storage->countForNode(1));
$this->assertSame(1, $this->storage->countForNode(7));
}
}

Functional tests install a complete Drupal site for every test method and click through it: drupalGet(), submitForm(), assertSession(). They are the right level for routes, forms, permissions, and anything where the answer is “what does the user see”. Each class must set $defaultTheme, because the testing system refuses to guess one. Because every method pays for a full install, group related assertions into one method rather than writing many small ones.

web/modules/custom/event_rsvp/tests/src/Functional/RsvpFormTest.php
<?php
declare(strict_types=1);
namespace Drupal\Tests\event_rsvp\Functional;
use Drupal\Tests\BrowserTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Tests the RSVP form in the browser.
*
* Functional tests install a full Drupal site and drive it with a test
* browser: the closest thing to clicking through your module by hand.
*/
#[Group('event_rsvp')]
#[Group('flagship')]
#[RunTestsInSeparateProcesses]
class RsvpFormTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'event_rsvp'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// The default config enables RSVPs for the "article" type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Page']);
}
/**
* Submits an RSVP and checks duplicates are rejected.
*/
public function testRsvpSubmission(): void {
$event = $this->drupalCreateNode(['type' => 'article', 'title' => 'DrupalCon']);
$attendee = $this->drupalCreateUser(['access content', 'rsvp to events']);
$this->drupalLogin($attendee);
$this->drupalGet('/node/' . $event->id() . '/rsvp');
$this->assertSession()->statusCodeEquals(200);
$this->submitForm(['mail' => 'ada@example.com'], 'RSVP');
$this->assertSession()->pageTextContains('Thanks! Your RSVP has been recorded.');
// A second RSVP with the same address fails validation.
$this->drupalGet('/node/' . $event->id() . '/rsvp');
$this->submitForm(['mail' => 'ada@example.com'], 'RSVP');
$this->assertSession()->pageTextContains('has already responded to this event');
}
/**
* Access requires the permission, a viewable node, and an enabled type.
*/
public function testAccess(): void {
$page = $this->drupalCreateNode(['type' => 'page', 'title' => 'About us']);
$event = $this->drupalCreateNode(['type' => 'article', 'title' => 'Meetup']);
$draft = $this->drupalCreateNode([
'type' => 'article',
'title' => 'Secret launch',
'status' => 0,
]);
// Without the permission, even enabled types are off limits.
$this->drupalGet('/node/' . $event->id() . '/rsvp');
$this->assertSession()->statusCodeEquals(403);
$attendee = $this->drupalCreateUser(['access content', 'rsvp to events']);
$this->drupalLogin($attendee);
// "page" is not in enabled_types, so the access check denies it.
$this->drupalGet('/node/' . $page->id() . '/rsvp');
$this->assertSession()->statusCodeEquals(403);
// The attendee cannot view an unpublished node, so they cannot RSVP.
$this->drupalGet('/node/' . $draft->id() . '/rsvp');
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet('/node/' . $event->id() . '/rsvp');
$this->assertSession()->statusCodeEquals(200);
}
}

When the behavior under test only exists with JavaScript running (Ajax, #states, machine name fields), use WebDriverTestBase. The test drives real Chrome, so every assertion about JavaScript-produced state must wait rather than assert immediately:

tests/src/FunctionalJavascript/MachineNameJavascriptTest.php
<?php
declare(strict_types=1);
namespace Drupal\Tests\module_howtos_b_demo\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Demonstrates a FunctionalJavascript test driving a real browser.
*
* WebDriverTestBase installs a full site like BrowserTestBase, but the
* test browser is a real Chrome instance behind WebDriver, so JavaScript
* (Ajax, #states, machine name fields) actually runs.
*/
#[Group('module_howtos_b_demo')]
#[RunTestsInSeparateProcesses]
class MachineNameJavascriptTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The machine name fills itself in as the user types a label.
*/
public function testMachineNameIsDerivedFromLabel(): void {
$this->drupalLogin($this->drupalCreateUser(['administer content types']));
$this->drupalGet('/admin/structure/types/add');
$this->getSession()->getPage()->fillField('Name', 'Press release');
// JavaScript updates the machine name preview while the user types.
// Wait for the result instead of asserting immediately: WebDriver
// and the browser run asynchronously to the test process.
$this->assertNotNull($this->assertSession()->waitForText('press_release'));
}
}

These tests need a WebDriver-capable Chrome alongside your environment, with its address in the MINK_DRIVER_ARGS_WEBDRIVER environment variable; core/tests/README.md documents the setup, and DDEV has a Selenium add-on that provides it.

PHPUnit reads core’s configuration from web/core/phpunit.xml.dist, which wires Drupal’s test bootstrap; point at it with -c web/core. Two environment variables do the heavy lifting:

  • SIMPLETEST_DB: the database tests may create tables in, as a URL. A SQLite file needs no database service. Required by kernel and functional tests.
  • SIMPLETEST_BASE_URL: the site URL the test browser talks to, for functional tests.

Tests run where PHP and the web server run, so with DDEV, open a shell in the web container with ddev ssh first. Then run the suite, a directory, or one test from the project root:

Terminal window
export SIMPLETEST_DB='sqlite://localhost/sites/default/files/.test.sqlite'
export SIMPLETEST_BASE_URL='http://localhost'
vendor/bin/phpunit -c web/core web/modules/custom/my_module
vendor/bin/phpunit -c web/core web/modules/custom/my_module/tests/src/Kernel/RsvpStorageTest.php

Inside the container, http://localhost reaches the web server directly. To keep the variables and options in a file instead, copy web/core/phpunit.xml.dist to phpunit.xml in your project root, adjust its paths into web/, and drop the -c flag. In CI the structure is identical: a job that installs dependencies, exports the two variables, and calls vendor/bin/phpunit. The examples project behind these docs runs exactly that on every merge request.

Run your module’s whole test directory, in the same shell with the two variables exported:

Terminal window
vendor/bin/phpunit -c web/core web/modules/custom/my_module

You see one progress dot per test and a summary like OK (12 tests, 34 assertions). A missing SIMPLETEST_DB announces itself on the first kernel test with “There is no database connection so no tests can be run”, not with silent skips.

  • Test-only modules. When a test needs a route, plugin, or config that the real module should not ship, create a helper module under tests/modules/ and list it in $modules. Core does this everywhere.
  • Reusable setup traits. Before writing setup code, check core’s traits: ContentTypeCreationTrait, UserCreationTrait, and AssertMailTrait cover the most common fixtures, and BrowserTestBase already pulls in several (the drupalCreateContentType() and drupalCreateUser() calls above come from them).
  • JavaScript-only tests. Core also has a Nightwatch.js suite (core/tests/Drupal/Nightwatch) for testing JavaScript with JavaScript; for module work, FunctionalJavascript tests are usually the better-supported choice.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.