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.
Prerequisites
Section titled “Prerequisites”- 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-devComposer package:composer require --dev drupal/core-dev. - A local environment (Set up your environment); the commands below assume DDEV.
Choose the test type
Section titled “Choose the test type”Four types, four base classes, four directories. From the cheapest up:
| Type | Base class | Directory | What exists at runtime |
|---|---|---|---|
| Unit | UnitTestCase | tests/src/Unit/ | Nothing. No container, no database. |
| Kernel | KernelTestBase | tests/src/Kernel/ | A real container and database; only the modules you list, minimally installed. |
| Functional | BrowserTestBase | tests/src/Functional/ | A full installed site, driven by a simulated browser without JavaScript. |
| FunctionalJavascript | WebDriverTestBase | tests/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.
Write a unit test
Section titled “Write a unit test”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:
<?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.
Write a kernel test
Section titled “Write 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.
<?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)); }
}Write a functional test
Section titled “Write a functional test”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.
<?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); }
}Write a FunctionalJavascript test
Section titled “Write a FunctionalJavascript test”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:
<?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.
Configure and run PHPUnit
Section titled “Configure and run PHPUnit”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:
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_modulevendor/bin/phpunit -c web/core web/modules/custom/my_module/tests/src/Kernel/RsvpStorageTest.phpInside 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.
Verify
Section titled “Verify”Run your module’s whole test directory, in the same shell with the two variables exported:
vendor/bin/phpunit -c web/core web/modules/custom/my_moduleYou 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.
Variations
Section titled “Variations”- 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, andAssertMailTraitcover the most common fixtures, andBrowserTestBasealready pulls in several (thedrupalCreateContentType()anddrupalCreateUser()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.
Related
Section titled “Related”- Build your first module 7 writes the flagship module’s tests as a guided tutorial.
- Set up your environment covers the DDEV setup the commands assume.
- API topic:
automated tests.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.