Drupal: Automated Testing
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Every Drupal site that outlives its first launch is changed hundreds of times: a core minor update every six months, security releases, a new content type, a refactoring of a service. Automated tests are the only way to know, in minutes and without a person clicking through the site, that those changes did not break what already worked. Drupal ships its own testing framework, built on PHPUnit, that can test a class in isolation, a service against a real (but tiny) database, a complete site through a simulated browser, and a JavaScript interface through a real one. This page explains each layer, how the framework is wired together, how to configure and run it locally in DDEV, how to write tests for a custom module, and how contributed projects are tested in the GitLab CI of drupal.org.
It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), with PHPUnit 11 (the version drupal/core-dev
installs for Drupal 11), Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation,
change records and the API reference on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and moves core to
PHPUnit 12.5, and the end of life of Drupal 10 is scheduled for 2026-12-09; both are described as upcoming events. New
projects start on Drupal 11.4. PHPUnit 10 was dropped from Drupal in 11.3, so a Drupal 11.4 site always runs PHPUnit 11.
The running example is the Meridian Museum, a fictitious regional science museum. Its module meridian_collection defines
the collection_object content entity (see Custom entity types), a settings form,
an importer service, a queue worker and a Drush command (see Queue, Batch and Cron APIs
and Drush). The sections below test those pieces at the cheapest layer that can prove them.
Who writes and runs tests
-
A developer writes Unit, Kernel and Functional tests for custom modules and runs them before every commit.
-
A themer or front-end developer writes FunctionalJavascript or Nightwatch tests for behaviors and AJAX interfaces.
-
An administrator or release manager runs the same suites in a pipeline before a deployment (see Deployment and CI/CD).
-
A site builder rarely writes PHP tests, but benefits from them: a module maintainer’s pipeline is what makes an update from Drupal 11.3 to 11.4 safe to apply.
This page covers PHP-side testing. The generic PHPUnit and Pest material (assertions, data providers, mocks, coverage) is on the site already; see Testing with PHPUnit and Pest and Static analysis and refactoring. Browser-driven end-to-end testing of any web application, including a decoupled Drupal front end, is in End-to-end testing. The coding standards and static analysis that run beside the tests are in Coding standards and static analysis.
The test pyramid
Drupal’s documentation describes tests at three layers (unit, kernel and functional), with the JavaScript variant of functional tests and the separate Nightwatch framework on top. The lower a test sits, the less of Drupal it boots, so the faster and more precise it is; the higher it sits, the more it proves about the whole system, and the slower and more fragile it is. A healthy module has many Unit and Kernel tests, fewer Functional tests, and only a handful of browser-driven tests.
The following table summarises the layers. Base-class names and namespaces are those of Drupal 11.
| Layer | Base class | What is available | Typical subject in Meridian | Directory |
|---|---|---|---|---|
Unit |
|
PHP and your class only. No container, no database, no module system; collaborators are mocked. |
A value-object or pure-calculation helper; the queue worker with a mocked importer. |
|
Kernel |
|
A booted kernel and service container, a database with only the tables you ask for, and only the modules in |
The importer creating |
|
Functional |
|
A fully installed Drupal site with a web server, driven through a simulated (Mink, no JavaScript) browser. |
The settings form, routes and permissions, rendered pages. |
|
FunctionalJavascript |
|
The same installed site, driven through WebDriver in a real browser (Chrome), so JavaScript, AJAX and dialogs run. |
An AJAX-enhanced filter on the collection listing. |
|
Performance |
|
A real browser that also records database queries, cache operations and asset counts. |
The collection page staying within a query and cache budget. |
|
Nightwatch |
None (a JavaScript framework, not PHPUnit) |
Node.js, a real browser, core’s helper commands. |
Core and some contrib JavaScript behavior and accessibility checks. |
|
Source: Types of tests
Two further PHPUnit suites exist in core. Build tests (Drupal\BuildTests, directory tests/src/Build) assemble a
throw-away Composer project and are used by core for scaffolding and installer checks; a custom module almost never needs
them. The older Simpletest framework with WebTestBase and drupalPostForm() is removed; the page "Converting SimpleTests
to PHPUnit tests" in the official documentation is only useful when you inherit a Drupal 8 module.
Choosing the layer
A practical rule is to write the lowest-layer test that can fail for the reason you care about.
-
Logic in a method that takes values and returns values (a formatter, a mapper, the exception translation in a queue worker): Unit.
-
Anything that touches the database, entities, configuration, the plugin system, event subscribers or hooks: Kernel. Entity queries, field storage and access checks are Kernel territory, because a mock of the entity storage proves only that your mock behaves.
-
A route, a form submit, a permission, a rendered page, a redirect, a block placed in a region: Functional.
-
Behavior that exists only in the browser (a dialog,
#ajax, a Drupal behavior, drag and drop): FunctionalJavascript, or Nightwatch if the project’s own tooling is Node.js based. -
"This page must not run more than N queries": a performance test.
PHPUnit 11 and PHP attributes
Drupal 11.4 runs on PHPUnit 11 and PHPUnit 11 expects test metadata as PHP 8 attributes. PHPUnit 10 introduced attributes;
PHPUnit 11 deprecates the older docblock annotations (@group, @covers, @dataProvider), and PHPUnit 12 does not support
them. Drupal’s own test runner, core/scripts/run-tests.sh, learned to read attributes in Drupal 11.2.0 (change record
"Tests with PHPUnit 10 attributes are now supported", 13 June 2025). That is the reason the testing guides in the official
documentation tell two stories: the page "PHPUnit file structure, namespace, and required metadata" still shows only
@group and @coversDefaultClass docblocks, while core and current contrib projects use attributes.
| Purpose | Annotation (old) | Attribute (use on 11.2+) |
|---|---|---|
Assign a test group (the first group must be the module’s machine name) |
|
|
Declare the covered class (class level) |
|
|
Declare a data provider |
|
|
Expect deprecations (previously the Drupal convention |
|
|
Method-level coverage |
|
No attribute equivalent in the change record: remove it, or rename it |
The attributes live in the PHPUnit\Framework\Attributes namespace (Group, CoversClass, DataProvider,
RunTestsInSeparateProcesses and others) and are documented in the PHPUnit 11 manual. Four rules prevent most problems.
-
Never mix styles in one class. When PHPUnit finds any attribute it ignores the docblock metadata of that class or method, so a half-converted test silently loses its
@groupor data provider. Convert a file completely, in one commit. -
Data providers should be static methods. PHPUnit 11 deprecates non-static providers and PHPUnit 12 removes them. Older books and tutorials show
public function provider()as an instance method. -
Keep the first group equal to the module’s machine name. The drupal.org test runner and the GitLab templates use the first group to attribute a test to a project. Additional groups are free: put a second
#[Group('meridian_sync')]attribute on its own line. -
Prophecy is not part of PHPUnit 10 or later. Drupal’s
UnitTestCaseandKernelTestBaseinclude theProphecyTraitso$this->prophesize()still works in core base classes, but new code can use PHPUnit’s owncreateMock(); the pages "Using Prophecy" and "Comparison with PHPUnit mocks" in the official documentation compare them.
Older books such as Drupal 10 Development Cookbook (2023) were written against Drupal 10.0, PHPUnit 9.5 and PHP 8.1, so
their test chapters use @group and @dataProvider docblocks, $this->prophesize() everywhere and phpunit.xml.dist
copies for PHPUnit 9. The tests they show still teach the layers correctly, but a Drupal 11.4 project should use attributes,
PHP 8.3 or later and the phpunit.xml format of PHPUnit 10 and later (see below).
File layout, namespaces and naming
A module’s tests live under its own tests/ directory, autoloaded by Drupal without any registration:
web/modules/custom/meridian_collection/
├── meridian_collection.info.yml
├── src/
│ ├── CollectionObjectImporter.php
│ └── Plugin/QueueWorker/CollectionObjectSyncWorker.php
└── tests/
├── modules/
│ └── meridian_collection_test/ # test-only helper module
│ └── meridian_collection_test.info.yml
└── src/
├── Unit/CollectionObjectSyncWorkerTest.php
├── Kernel/CollectionObjectImporterTest.php
├── Functional/CollectionSettingsFormTest.php
├── FunctionalJavascript/CollectionFilterTest.php
└── Traits/CollectionObjectCreationTrait.php
-
Tests go in
tests/src/<Suite>with the namespace\Drupal\Tests\<module>\<Suite>, for exampleDrupal\Tests\meridian_collection\Kernel. Kernel tests inherit from a class in theDrupal\KernelTestsnamespace but the module’s own test classes use theDrupal\Tests\...namespace as well. -
Shared helpers go in
tests/src/Traitswith the namespace\Drupal\Tests\<module>\Traits. -
Test-only modules, themes and profiles go in
tests/modules,tests/themesandtests/profiles. Drupal finds them there when a test lists them in$modules, and since they are not undermodules/of the site they never appear on the Extend page of a production site. -
A class name must end in
Test; a test method must start withtest(or carry the#[Test]attribute). Abstract base classes end inBase. -
A class needs a doc-comment summary line; Drupal’s coding standards require it even though PHPUnit does not.
Configuring PHPUnit: phpunit.xml.dist
Core ships core/phpunit.xml.dist. You copy it, rename it phpunit.xml, and adjust three things: the bootstrap path, the
site URL and the database URL. It is phpunit.xml, not .dist, that you edit, because the .dist file is replaced on every
core update. Add phpunit.xml to .gitignore when it holds machine-specific values, and commit a phpunit.xml.dist of your
own when the project should share its configuration.
Install the test dependencies first. drupal/core-dev is a development-only Composer package that pulls in PHPUnit, Mink,
the WebDriver driver, Prophecy, PHPCS with Coder, PHPStan and related tools.
ddev composer require --dev drupal/core-dev --update-with-all-dependencies
cp web/core/phpunit.xml.dist phpunit.xml
Source: Running PHPUnit tests
For a DDEV project whose Drupal root is web/, with phpunit.xml next to composer.json, the relevant part of the
file for PHPUnit 10 and later looks like this. It is trimmed to the keys that matter; keep the other elements of core’s file
(coverage, the testsuites list, the logging extensions) unchanged.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="web/core/tests/bootstrap.php"
colors="true"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
failOnWarning="true"
cacheResult="false">
<php>
<ini name="error_reporting" value="32767"/>
<ini name="memory_limit" value="-1"/>
<!-- The URL by which the test runner reaches the site. Must be http://, never file:// or https://. -->
<env name="SIMPLETEST_BASE_URL" value="http://meridian.ddev.site" force="true"/>
<!-- Database for Kernel and Functional tests. Tests create prefixed tables and drop them again. -->
<env name="SIMPLETEST_DB" value="mysql://db:db@db/db"/>
<!-- Browser for FunctionalJavascript and performance tests (see "Running tests in DDEV"). -->
<env name="MINK_DRIVER_ARGS_WEBDRIVER" value='["chrome", {"browserName":"chrome","goog:chromeOptions":{"args":["--disable-gpu","--headless","--no-sandbox","--disable-dev-shm-usage"]}}, "http://selenium-chrome:4444"]'/>
</php>
<extensions>
<!-- Writes the HTML of every page a Functional test visited. -->
<bootstrap class="Drupal\TestTools\Extension\HtmlLogging\HtmlOutputLogger">
<parameter name="outputDirectory" value="web/sites/simpletest/browser_output"/>
<parameter name="verbose" value="true"/>
</bootstrap>
</extensions>
<testsuites>
<testsuite name="meridian-unit">
<directory>web/modules/custom/*/tests/src/Unit</directory>
</testsuite>
<testsuite name="meridian-kernel">
<directory>web/modules/custom/*/tests/src/Kernel</directory>
</testsuite>
<testsuite name="meridian-functional">
<directory>web/modules/custom/*/tests/src/Functional</directory>
</testsuite>
<testsuite name="meridian-functional-javascript">
<directory>web/modules/custom/*/tests/src/FunctionalJavascript</directory>
</testsuite>
</testsuites>
</phpunit>
Source: Running PHPUnit tests
The settings in detail:
| Setting | Meaning and pitfalls |
|---|---|
|
Path to |
|
The URL of the site under test, with the |
|
A database URL of the form |
|
A JSON array |
|
Where |
|
Test runs report every error and notice and have no PHP memory limit. Keep them: Drupal test base classes turn PHP deprecations into test results, and a memory limit causes baffling crashes during installs. |
Core’s own file defines suites named unit, kernel, functional, functional-javascript and build, and you may run
those with --testsuite. Do not run --testsuite=functional over a whole project without a filter: it means every Functional
test in core as well.
Older books show phpunit.xml.dist with <phpunit ... printerClass="\Drupal\Tests\Listeners\HtmlOutputPrinter">, listeners
and <env name="BROWSERTEST_OUTPUT_DIRECTORY"/>. Those keys belong to PHPUnit 9 and are rejected or ignored by PHPUnit 10
and later.
Running tests
All commands below run inside the DDEV web container (see the next section for the setup). Run PHPUnit from the project root
with the configuration file selected by -c.
# One test class
ddev exec ./vendor/bin/phpunit -c phpunit.xml web/modules/custom/meridian_collection/tests/src/Kernel/CollectionObjectImporterTest.php
# One test method
ddev exec ./vendor/bin/phpunit -c phpunit.xml --filter testImportCreatesEntity web/modules/custom/meridian_collection/tests
# Every test of the module, by directory
ddev exec ./vendor/bin/phpunit -c phpunit.xml web/modules/custom/meridian_collection/tests
# By group, and the available groups
ddev exec ./vendor/bin/phpunit -c phpunit.xml --list-groups
ddev exec ./vendor/bin/phpunit -c phpunit.xml --group meridian_collection
# A suite defined in phpunit.xml, readable output, stop at the first failure
ddev exec ./vendor/bin/phpunit -c phpunit.xml --testsuite meridian-kernel --testdox --stop-on-failure
# Core's own convention: from web/ with core/phpunit.xml
ddev exec ./vendor/bin/phpunit -c web/core web/core/modules/action
Source: Running PHPUnit tests
Useful options are --filter (a method or class name pattern), --group and --exclude-group, --testdox,
--display-deprecations, --fail-on-deprecation and --coverage-html (which needs Xdebug in coverage mode or PCOV;
see Project layout and local development for the Xdebug toggle in
DDEV). In DDEV, ddev xdebug on slows every PHP process, so enable it only for the debugging session.
core/scripts/run-tests.sh is the older, parallel runner that the drupal.org test infrastructure used for years. It is still
the default of the GitLab templates, because it can run tests concurrently and group the results:
ddev exec php web/core/scripts/run-tests.sh --sqlite /tmp/meridian-tests.sqlite --concurrency 4 --color --url http://meridian.ddev.site --class 'Drupal\Tests\meridian_collection\Kernel\CollectionObjectImporterTest'
Use run-tests.sh to reproduce a CI result and PHPUnit directly in day-to-day work. The documentation warns not to use
run-tests.sh for FunctionalJavascript tests that need a browser you started by hand: without a running ChromeDriver it can
report a pass although the tests never ran.
Run the core development checks (coding standards, spelling, ESLint, deprecation checks) on a patch against core with
sh ./core/scripts/dev/commit-code-check.sh; see "Run core development checks" in the official documentation. A module
should wire PHPCS and PHPStan into the same pipeline as its tests (see
Coding standards and static analysis).
Running tests in DDEV
A DDEV project from Installation with Composer and DDEV already has PHP, the web server and the database. Unit, Kernel and Functional tests need nothing else. FunctionalJavascript, performance and Nightwatch tests need a browser, and the browser must be able to reach the site. DDEV runs additional services as extra Docker containers added by an add-on.
# .ddev/config.yaml
name: meridian
type: drupal11
docroot: web
php_version: "8.3"
webserver_type: nginx-fpm
database:
type: mariadb
version: "10.11"
Source: DDEV quickstart: Drupal
# Add the Selenium Chrome service (the add-on name is "ddev-selenium-standalone-chrome").
ddev add-on get ddev/ddev-selenium-standalone-chrome
ddev restart
# Smoke test the browser from the web container, then run a browser-driven test.
ddev exec curl -s http://selenium-chrome:4444/status
ddev exec ./vendor/bin/phpunit -c phpunit.xml web/modules/custom/meridian_collection/tests/src/FunctionalJavascript
Source: Running PHPUnit Javascript tests
The official page offers two ways to run a browser next to DDEV: the ddev-selenium-standalone-chrome add-on, and a
docker-compose.chromedriver.yaml in .ddev/ taken from the DDEV contrib project (drupalci-chromedriver), which
runs the same ChromeDriver image as drupal.org’s CI. Older DDEV releases install add-ons with ddev get instead of
ddev add-on get. The container host name, the port and the MINK_DRIVER_ARGS_WEBDRIVER URL must match the service
that the add-on installed: read the add-on’s README for the values of the version you installed.
Several points explain nearly all "it works on my machine" failures with browser tests:
-
Two hosts. The test PHP process runs in the web container and the browser runs in another container. The base URL the browser must use is one that resolves from the browser container, which is why
SIMPLETEST_BASE_URLis sometimeshttp://web(the web container) rather thanhttp://meridian.ddev.site. Use the value that the add-on’s documentation gives for your version. -
Scheme. The base URL must be
http://. Anhttps://base URL causes "invalid cookie domain" in JavaScript tests. -
Headless and
--no-sandbox. In a container Chrome needs--headless,--disable-gpu,--disable-dev-shm-usageand (when it runs as root)--no-sandbox. The value inphpunit.xmlabove contains them. A test class can override the array by overridinggetMinkDriverArgs()(extendingWebDriverTestBaseorBrowserTestBase), decoding the parent’s JSON, appending an argument and returningjson_encode()of the result. -
Without a server on a plain Linux or WSL host. Install
xvfband start ChromeDriver withxvfb-run chromedriver --port=4444; keep it running while PHPUnit runs. -
Permissions. Functional tests write into
sites/simpletestandsites/default/files. When PHPUnit is run as a different user from the web server, run it as the web server user and preserve the environment:sudo -u www-data -E ../vendor/bin/phpunit .... In DDEV both are the samewww-data-compatible user, so this seldom matters there.
Debugging a stuck browser test: add $this->assertSession()->waitForElementVisible('css', '.test-wait', 100000) where you want
to pause, run the test with a visible (non-headless) browser, and inspect the page. Use dump() for output that PHPUnit does
not treat as an error, and open the HTML files that HtmlOutputLogger writes to see what every Functional request returned.
Unit tests: UnitTestCase
A Unit test instantiates a class with hand-made collaborators. There is no Drupal container, so
\Drupal::service() and any code that reaches for it throws. That is a design signal: the class under test should receive its
dependencies through the constructor (see Services and dependency injection),
which is also what makes it testable. UnitTestCase supplies helpers: getConfigFactoryStub(), getStringTranslationStub(),
getContainerWithCacheTagsInvalidator(), randomMachineName() and getRandomGenerator().
The CollectionObjectSyncWorker from Queue, Batch and Cron APIs translates
importer outcomes into the vocabulary of the queue. That mapping is pure logic around two collaborators, which is Unit-test
material:
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Unit;
use Drupal\Core\Queue\DelayedRequeueException;
use Drupal\Core\Queue\SuspendQueueException;
use Drupal\meridian_collection\CollectionObjectImporter;
use Drupal\meridian_collection\Exception\CollectionApiRateLimitedException;
use Drupal\meridian_collection\Exception\CollectionApiUnavailableException;
use Drupal\meridian_collection\Exception\CollectionObjectNotFoundException;
use Drupal\meridian_collection\Plugin\QueueWorker\CollectionObjectSyncWorker;
use Drupal\Tests\UnitTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
/**
* Tests how the sync worker reacts to importer outcomes.
*/
#[CoversClass(CollectionObjectSyncWorker::class)]
#[Group('meridian_collection')]
class CollectionObjectSyncWorkerTest extends UnitTestCase {
private CollectionObjectImporter&MockObject $importer;
private LoggerInterface&MockObject $logger;
private CollectionObjectSyncWorker $worker;
protected function setUp(): void {
parent::setUp();
$this->importer = $this->createMock(CollectionObjectImporter::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->worker = new CollectionObjectSyncWorker([], 'meridian_collection_sync', [], $this->importer, $this->logger);
}
public function testSuccessfulImportDeletesTheItem(): void {
$this->importer->expects($this->once())->method('import')->with('1932.14.7')->willReturn(TRUE);
$this->worker->processItem(['accession_number' => '1932.14.7']);
}
public function testUnavailableApiSuspendsTheQueue(): void {
$this->importer->method('import')->willThrowException(new CollectionApiUnavailableException('timeout'));
$this->expectException(SuspendQueueException::class);
$this->worker->processItem(['accession_number' => '1932.14.7']);
}
public function testRateLimitDelaysOnlyThisItem(): void {
$this->importer->method('import')->willThrowException(new CollectionApiRateLimitedException(30));
$this->expectException(DelayedRequeueException::class);
$this->worker->processItem(['accession_number' => '1932.14.7']);
}
public function testObjectGoneUpstreamIsLoggedAndDropped(): void {
$this->importer->method('import')->willThrowException(new CollectionObjectNotFoundException());
$this->logger->expects($this->once())->method('warning');
$this->worker->processItem(['accession_number' => '1932.14.7']);
}
/**
* A malformed item can never succeed, so it must be dropped, not retried.
*/
#[DataProvider('provideMalformedItems')]
public function testMalformedItemsAreDropped(mixed $item): void {
$this->importer->expects($this->never())->method('import');
$this->logger->expects($this->once())->method('error');
$this->worker->processItem($item);
}
public static function provideMalformedItems(): array {
return [
'not an array' => ['1932.14.7'],
'missing key' => [[]],
'empty string' => [['accession_number' => '']],
];
}
}
The importer is a final class in the module, and PHPUnit cannot mock a final class. Two ways out: extract an interface and
type the worker against it, or do not mock the importer and cover the worker in a Kernel test. For classes that you control
prefer the interface; for services you do not control, prefer the real thing in a Kernel test over a deep mock chain. A
Unit test whose setup is longer than its assertions is usually a Kernel test in disguise.
Older code and books create mocks with $this->prophesize(Foo::class) and ->reveal(). That still works through core’s
base classes, but PHPUnit mocks need no extra library (see the PHPUnit 11 section above).
Kernel tests: KernelTestBase
A Kernel test boots Drupal’s kernel and container, installs a minimum of modules and a database with the tables you ask for, and runs without a web server. Kernel tests are the workhorse of module testing: they exercise real services, real entity storage and real configuration at a fraction of the cost of a Functional test.
What is and is not installed matters:
-
protected static $modules = [...]lists the modules to enable. Dependencies declared in the.info.ymlare not automatically enabled in the same way as during a site install, so list every module whose services you use (for the importer:system,user,field,text,meridian_collection). -
Tables are not created for entity types until you call
$this->installEntitySchema('collection_object'). The same holds for theuserentity (installEntitySchema('user')) and for module tables (installSchema('system', ['sequences'])). -
Default configuration is not imported: call
$this->installConfig(['meridian_collection']). -
No hooks fire for an install; no routes exist unless the router is rebuilt.
-
Each test method starts from a fresh database and container.
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\CollectionApiClientInterface;
use Drupal\meridian_collection\CollectionObjectImporter;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
/**
* Tests that the importer creates and updates collection_object entities.
*/
#[CoversClass(CollectionObjectImporter::class)]
#[Group('meridian_collection')]
class CollectionObjectImporterTest extends KernelTestBase {
protected static $modules = ['system', 'user', 'field', 'text', 'meridian_collection'];
private CollectionObjectImporter $importer;
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
$this->installEntitySchema('collection_object');
$this->installConfig(['meridian_collection']);
// Replace the HTTP client with a fake: a test must never call the real API.
$this->container->set('meridian_collection.api_client', new FakeCollectionApiClient([
'1932.14.7' => [
'title' => 'Foucault pendulum bob',
'period' => '19th century',
'material' => 'brass',
'dimensions' => '24 cm',
'on_display' => TRUE,
],
]));
$this->importer = $this->container->get('meridian_collection.object_importer');
}
public function testImportCreatesEntity(): void {
$this->assertTrue($this->importer->import('1932.14.7'));
$storage = $this->container->get('entity_type.manager')->getStorage('collection_object');
$objects = $storage->loadByProperties(['accession_number' => '1932.14.7']);
$this->assertCount(1, $objects);
$object = reset($objects);
$this->assertSame('Foucault pendulum bob', $object->label());
$this->assertTrue((bool) $object->get('on_display')->value);
}
public function testSecondImportOfUnchangedRecordIsANoOp(): void {
$this->importer->import('1932.14.7');
$this->assertFalse($this->importer->import('1932.14.7'));
}
}
/**
* In-memory stand-in for the collections-management API.
*/
final class FakeCollectionApiClient implements CollectionApiClientInterface {
public function __construct(private readonly array $records) {}
public function listChangedSince(int $timestamp): array {
return array_keys($this->records);
}
public function fetchObject(string $accessionNumber): array {
return $this->records[$accessionNumber]
?? throw new \Drupal\meridian_collection\Exception\CollectionObjectNotFoundException();
}
}
Source: Setup tasks in Kernel tests
Replacing a service with $this->container->set() after the container is built works only for services that nothing has
instantiated yet. For a stable result, override the service definition in register(), as the following shows, which also
makes it available to the plugin manager that builds the queue worker.
use Drupal\Core\DependencyInjection\ContainerBuilder;
public function register(ContainerBuilder $container): void {
parent::register($container);
$container->register('meridian_collection.api_client', FakeCollectionApiClient::class)
->addArgument([/* the records */]);
}
Source: Setup tasks in Kernel tests
Common Kernel-test errors:
-
"The "collection_object" entity type does not exist" - the module is missing from
$modules. -
"Table 'test12345678collection_object' doesn’t exist" -
installEntitySchema()was not called. -
"Schema errors for meridian_collection.settings" -
$strictConfigSchemaisTRUEby default; a configuration key without a schema inconfig/schema/meridian_collection.schema.ymlfails the test. That is useful: it forces the schema to exist (see Configuration API and schema). -
Entity queries fail an "access check" error - every entity query in module code must call
accessCheck()explicitly,TRUEorFALSE, and the test simply exercises that. -
Requests: Kernel tests do not run through a web server. To test routing without one, use
$this->container->get('http_kernel')->handle(Request::create('/collection'))afterinstallConfigand a router rebuild (see "Making HTTP requests programmatically in Kernel tests" in the official documentation), or move to a Functional test.
Functional tests: BrowserTestBase
A Functional test installs a complete Drupal site for every test method,
using the database in SIMPLETEST_DB with a random table prefix and a temporary sites/simpletest/<n> directory, then
drives it by HTTP through Mink’s simulated browser. There is no JavaScript. The site starts with nothing: no content,
no users, no roles beyond those the install profile creates. Everything the test needs, it creates.
Three class properties are important:
-
protected $defaultThemeis mandatory (since Drupal 8.8).starkis the usual choice because it relies on nothing. If it is missing, the test errors before it begins. -
protected static $moduleslists the modules to install, as in Kernel tests. -
protected $strictConfigSchema = TRUEis the default, and strict schema checking applies to all configuration saved during the test.
The most-used methods are drupalCreateUser(array $permissions), drupalLogin($account), drupalGet($path_or_url),
assertSession() (returning Mink’s WebAssert extended by Drupal with statusCodeEquals(), pageTextContains(),
elementExists() and others), submitForm($edit, $submit) on the test itself and $this->getSession()->getPage() for
fillField(), selectFieldOption(), checkField(), pressButton() and clickLink(). drupalPostForm() is removed;
use submitForm().
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Functional;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
use PHPUnit\Framework\Attributes\Group;
/**
* Tests the Meridian collection settings form and its permission.
*/
#[Group('meridian_collection')]
class CollectionSettingsFormTest extends BrowserTestBase {
protected static $modules = ['meridian_collection'];
protected $defaultTheme = 'stark';
public function testSettingsFormRequiresPermission(): void {
$settings = Url::fromRoute('meridian_collection.settings');
// Anonymous visitors and ordinary editors are refused.
$this->drupalGet($settings);
$this->assertSession()->statusCodeEquals(403);
$editor = $this->drupalCreateUser(['edit collection objects']);
$this->drupalLogin($editor);
$this->drupalGet($settings);
$this->assertSession()->statusCodeEquals(403);
}
public function testCuratorCanSaveSettings(): void {
$curator = $this->drupalCreateUser(['administer meridian collection settings']);
$this->drupalLogin($curator);
$this->drupalGet(Url::fromRoute('meridian_collection.settings'));
$this->assertSession()->statusCodeEquals(200);
$this->submitForm(['sync_enabled' => TRUE], 'Save configuration');
$this->assertSession()->pageTextContains('The configuration options have been saved.');
$this->assertTrue($this->config('meridian_collection.settings')->get('sync_enabled'));
}
}
Source: PHPUnit Browser test tutorial
Pitfalls:
-
A Functional test is slow (a full install takes seconds). Reduce the install cost with a minimal profile (the default for
BrowserTestBaseis thetestingprofile, notstandard) and a short$moduleslist. Share setup in a base class. -
$this->config()in the test reads the test site’s configuration through the test’s container; the container is rebuilt after the HTTP request changed configuration, so call$this->rebuildContainer()if you see stale values. -
The HTML of every request is written by
HtmlOutputLoggerinto the output directory. A failure message that is not enough is almost always explained by opening the last file there. -
Tests that need the install profile’s configuration (content types, roles) must set
protected $profile = 'standard';explicitly. The Standard profile in Drupal 11.4 no longer creates the Article and Page content types, so a test that expectsarticlemust create it. This also concerns older books, whose test chapters rely on thearticletype. -
Never log in as user 1 in a test to bypass permission checks; it hides missing permissions. Create a user with exactly the permissions the feature requires. A matching negative test (the 403 above) is as important as the positive one.
FunctionalJavascript tests: WebDriverTestBase
Drupal\FunctionalJavascriptTests\WebDriverTestBase extends BrowserTestBase but drives a real browser through WebDriver.
Anything that happens through JavaScript, #ajax callbacks, Drupal behaviors, off-canvas dialogs and drag-and-drop, can be
tested. The price is speed and fragility, because a test must wait for asynchronous work.
Never use sleep(). Use the waiting helpers of the Mink page and the test base: $assert->waitForElementVisible(),
waitForElement(), waitForText(), waitForField() and assertWaitOnAjaxRequest(). A fixed sleep either wastes time or
fails on a slow run. The example assumes a collection view with an AJAX-enabled exposed filter, as built in
Views.
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use PHPUnit\Framework\Attributes\Group;
/**
* Tests that the collection listing filters without a page reload.
*/
#[Group('meridian_collection')]
class CollectionFilterTest extends WebDriverTestBase {
protected static $modules = ['views', 'meridian_collection'];
protected $defaultTheme = 'stark';
public function testOnDisplayFilterUsesAjax(): void {
$this->drupalLogin($this->drupalCreateUser(['edit collection objects']));
$this->drupalGet('/admin/content/collection');
$page = $this->getSession()->getPage();
$assert = $this->assertSession();
$page->checkField('On display');
$page->pressButton('Apply');
$assert->assertWaitOnAjaxRequest();
$assert->elementNotExists('css', '.views-row[data-on-display="0"]');
}
}
A JavaScript test that is skipped because the WebDriver is unreachable does not fail the PHPUnit run by default; add
--fail-on-skipped in CI so that a missing browser cannot masquerade as success. Chrome is a moving target: pin the browser
image used in CI, and treat a new Chrome release as a possible cause when a long-green test suddenly fails.
Nightwatch
Nightwatch is a Node.js end-to-end test framework that core has used for JavaScript and accessibility tests. It is not part
of PHPUnit and does not use the PHP base classes. Core’s setup is described in core/tests/README.md: install the Node.js
dependencies with Yarn in core/, copy the environment example file to core/.env, and point it at your site and browser.
Tests for a module live in <module>/tests/src/Nightwatch/Tests/, shared commands in core/tests/Drupal/Nightwatch.
-
Tag each test with
@tagsusing the module’s machine name so the drupal.org runner finds it. -
Mark tests that do not need a Drupal site with
'@unitTest': true. -
Accessibility checks use axe-core; core’s
a11yTestAdmin.jschecks several admin URLs and is a template for a project’s own checks.
The official Nightwatch page targets core development, and its examples point to the 11.x branch. For a project that does not contribute to core, Playwright or Cypress usually fit better and need no Drupal-specific helpers; see Choosing a tool and End-to-end testing. The documentation also has a page "Browser testing using Cypress". Nightwatch matters when you contribute to core or to a project that already has Nightwatch tests, and the GitLab templates have a Nightwatch job (see below).
Performance tests
Since Drupal 10.2, core includes Gander, a framework for performance tests built on FunctionalJavascript. A test extends
Drupal\FunctionalJavascriptTests\PerformanceTestBase (or uses Drupal\Tests\PerformanceTestTrait in its own base class)
and wraps only the requests to measure in collectPerformanceData(). It returns a PerformanceData object with:
-
the number of database queries and the list of queries,
-
cache get, set and delete counts, per bin,
-
the number of stylesheets and scripts (with
assertNoJavaScript()available), -
TTFB, FCP and LCP timings.
Assert only on deterministic values: query counts, cache operations, asset counts. Timings vary between runs and between
machines, so they belong on a dashboard rather than in a pass/fail assertion. Setup, such as creating users or warming caches,
is done outside the closure. Core sends results to a Grafana dashboard through OpenTelemetry when the OTEL_COLLECTOR
environment variable is set, and runs its tests of the OpenTelemetry group every two hours; the dashboard data may need a
few runs to appear.
A museum can protect its busiest page with a budget. The numbers are examples to adapt after measuring on the real site:
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\PerformanceTestBase;
use PHPUnit\Framework\Attributes\Group;
/**
* Keeps the collection page within its query and asset budget.
*/
#[Group('meridian_collection')]
#[Group('Performance')]
class CollectionPagePerformanceTest extends PerformanceTestBase {
protected static $modules = ['meridian_collection'];
protected $defaultTheme = 'stark';
public function testCollectionPageBudget(): void {
// Warm every cache once, outside the measured closure.
$this->drupalGet('/collection');
$data = $this->collectPerformanceData(function () {
$this->drupalGet('/collection');
}, 'meridianCollectionHot');
$this->assertLessThanOrEqual(15, $data->getQueryCount());
$this->assertLessThanOrEqual(1, $data->getStylesheetCount());
$this->assertSame(0, $data->getScriptCount());
}
}
Source: Performance tests
A hot-cache budget that grows silently from 15 to 150 queries is exactly what a deployment should block (see Performance and caching).
Testing the Meridian module: a plan
The tests above are not a random set; they follow the shape of the module.
| Module part | Layer | What is asserted |
|---|---|---|
|
Unit |
Each importer outcome maps to the right queue exception; malformed items are dropped. |
|
Kernel |
An entity is created, updated and left alone when unchanged; fields persist; |
Config schema, |
Kernel |
Install the module, run |
Access policy for curators, entity access |
Kernel |
Roles with and without |
Settings form and routes |
Functional |
403 for anonymous and unprivileged users; 200 and a saved configuration for a curator. |
Block "On display now", field formatter |
Kernel (render) and Functional |
Render the block with |
Queue plus Drush command |
Kernel |
Call the planner service and assert on the queue’s item count, or run the command with |
Filter on the listing, AJAX |
FunctionalJavascript |
The result list changes without a reload. |
Hot-cache budget of |
Performance |
Query, stylesheet and script counts do not increase. |
Drush commands can be tested in a Kernel or Functional test with Drush\TestTraits\DrushTestTrait from the
drush/drush package: $this->drush('meridian:sync', ['--limit=5']) runs the command in a subprocess against the test site
and $this->getOutput() returns the result. This trait needs the Drush executable of the project and an installed test site,
so it belongs in Functional tests. Commands written as attribute-based classes with autowired services (Drush 13 and 14)
are easier to Unit-test by instantiating the class: the command logic then sits in methods that take plain arguments.
Writing maintainable tests
-
Make each test independent. No test may depend on the order of the others. Create what you need in
setUp(). -
Test behavior, not implementation. A test that mocks every collaborator and asserts the order of calls breaks on every refactoring and proves nothing.
-
Use traits for repeated setup. A
CollectionObjectCreationTraitintests/src/TraitswithcreateCollectionObject()is better than copying the same ten lines. -
Use test-only modules in
tests/modulesfor fixtures: a module that defines a route, a form or a plugin used only by tests, withpackage: Testingin its.info.yml. -
Do not call external services. Replace API clients with fakes (the Kernel example) or point them at a mock server started by the test; a real call makes tests slow and flaky and may leak data.
-
Do not put secrets in tests. Use obvious placeholders such as
CHANGE_MEor random machine names fromrandomMachineName(); the database URL and base URL come fromphpunit.xmlor the CI environment. -
Keep timing out of assertions. Wait for a condition, never for a duration.
-
Fail on deprecations in CI. Add
--fail-on-deprecation(or_PHPUNIT_EXTRAin the GitLab templates) so that code using APIs that disappear in Drupal 12 shows up now. See Preparing for Drupal 12.
Common errors and how to read them
| Message or symptom | Usual cause |
|---|---|
"Class … not found" for a test base class |
Wrong |
"Drupal\Tests...\Test does not have a @group / required metadata" |
A test written with docblock annotations only, run under a runner expecting attributes, or a mix of the two. Convert fully to attributes. |
"The test did not run any tests" or a class is skipped silently |
The file name does not match the class name, or the class name does not end in |
"Failed to connect to localhost port 80" in a Functional test |
|
"Unable to create a Mink session" or "Could not open connection" in a JavaScript test |
WebDriver not running, wrong URL in |
"invalid cookie domain" |
|
"The default theme must be specified" |
|
"There are no tables" or "Table … doesn’t exist" in a Kernel test |
|
"Schema errors for …" |
A configuration key has no schema; fix |
"Unsilenced deprecation" failures after a core update |
The module uses an API that is deprecated. Fix the call, or mark a test that deliberately triggers it with
|
Intermittent failures only in CI |
Time or order dependence, a shared cache, a JavaScript race. Re-run with |
Source: Test failures
Contrib testing on the GitLab CI templates
Drupal contributed projects on drupal.org are tested in GitLab CI with a shared set of templates, maintained in the
project/gitlab_templates project (git.drupalcode.org). The templates replaced DrupalCI, the previous testbot. The pipeline
for a project is defined by a single .gitlab-ci.yml at the project root, and the documentation of the templates is at
project.pages.drupalcode.org/gitlab_templates.
Basic setup, as described on the templates' setup page:
-
Add a
.gitlab-ci.ymlfile to the project’s root. -
Copy in the Drupal Association template,
gitlab-ci/template.gitlab-ci.ymlin theproject/gitlab_templatesrepository (it is also in the GitLab UI’s template list). It includes the shared definition files by reference. -
Commit and push; a pipeline starts in the branch or merge request.
The jobs are grouped into stages. Build jobs prepare a Composer project with Drupal core and your project (a composer job
and, for Drupal CMS, an additional job). Validation jobs include composer-lint, cspell, eslint, phpcs, phpstan,
secret detection and stylelint. Test jobs include phpunit, nightwatch, test-only changes and upgrade status. Each
job runs on a variant of the environment: the current core and PHP, the previous major, the next minor, and the next major
when opted in. Variables that change between variants, such as DRUPAL_CORE and PHP_VERSION, must be scoped to the
job they belong to.
The phpunit job runs Unit, Kernel, Functional and FunctionalJavascript tests. It uses the module’s own PHPUnit
configuration if one exists, otherwise core’s core/phpunit.xml. By default it runs core/scripts/run-tests.sh with
concurrency (_PHPUNIT_CONCURRENT is 1); set it to 0 to call the phpunit binary sequentially, which allows
_PHPUNIT_EXTRA options such as --testdox, --filter, --display-deprecations, --fail-on-deprecation and
--fail-on-skipped. Drupal 11 and later use Selenium with a current W3C-compliant Chrome; Drupal 10 and earlier use the legacy
ChromeDriver. HTML output files are generated only when --testdox is not used.
# .gitlab-ci.yml of a contrib project: customizations only (the include block stays as in the template).
variables:
# Also test against the next minor and the next major core (look for deprecations early).
OPT_IN_TEST_NEXT_MINOR: 1
OPT_IN_TEST_NEXT_MAJOR: 1
# PHPStan is run in a separate step in this project.
SKIP_PHPSTAN: 1
# Run the binary sequentially and fail on deprecations and silently skipped browser tests.
_PHPUNIT_CONCURRENT: 0
_PHPUNIT_EXTRA: --fail-on-deprecation --fail-on-skipped
# Job-scoped override: a variable that differs per variant.
composer (previous major):
variables:
PHP_VERSION: "8.1"
Source: GitLab Templates: Customizations
The variable names OPT_IN_TEST_NEXT_MINOR, SKIP_PHPSTAN, _PHPUNIT_CONCURRENT, _PHPUNIT_EXTRA, _RUNTESTS_EXTRA,
_CONCURRENCY_THREADS (default 32) and OPT_IN_TEST_NEXT_MAJOR are documented on the templates' pages; the file
includes/include.drupalci.variables.yml in the templates repository lists the complete set of variables and their
defaults, and is the authoritative reference when a variable is missing from the documentation.
For a project on another GitLab instance, include the files by remote: URL or with _GITLAB_TEMPLATES_REPO and
_GITLAB_TEMPLATES_REF variables:
include:
- remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.main.yml
- remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.variables.yml
- remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.hidden-variables.yml
- remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.workflows.yml
Source: GitLab Templates: Set up
Replace default-ref with the reference (branch or tag) of the templates you want; pin it in a private project so a template
change cannot alter your pipeline unnoticed.
Reproducing the CI result locally: use the same run-tests.sh command shown earlier in DDEV, with the same PHP_VERSION
and core version as the failing job. Tests that pass on current core and fail on the next minor point to a deprecated API
that will be removed; that is the reason to opt in.
Older books and older module READMEs describe the DrupalCI testbot (drupalci.yml, .drupalci files and
the "Automated testing in DrupalCI" documentation). New projects do not need them: the same tests run from .gitlab-ci.yml.
For a private project (the museum’s site)
The Meridian site is not on drupal.org, so it does not use the contrib pipeline; but it can reuse the pieces. A GitHub
Actions or GitLab pipeline for the site repository starts a MariaDB service and a Selenium Chrome service, runs
composer install, then PHPUnit with the environment variables set in the job (never committed):
# .github/workflows/tests.yml (excerpt)
jobs:
phpunit:
runs-on: ubuntu-latest
services:
db:
image: mariadb:10.11
env:
MARIADB_DATABASE: drupal
MARIADB_USER: drupal
MARIADB_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }}
MARIADB_ROOT_PASSWORD: ${{ secrets.TEST_DB_ROOT_PASSWORD }}
chrome:
image: selenium/standalone-chrome:latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
extensions: gd, pdo_mysql
- run: composer install --no-progress
- run: ./vendor/bin/phpunit -c phpunit.xml --testsuite meridian-unit,meridian-kernel --fail-on-deprecation
env:
SIMPLETEST_DB: mysql://drupal:${{ secrets.TEST_DB_PASSWORD }}@127.0.0.1/drupal
SIMPLETEST_BASE_URL: http://127.0.0.1:8080
Source: Running PHPUnit tests
The workflow keeps Unit and Kernel on every push and the slower Functional and FunctionalJavascript suites on pull
requests, where they can be sharded by directory. The CI conventions of this site are in
Deployment and CI/CD; for running a service container in a pipeline, see
CI/CD with GitHub Actions. Pin the latest image tags in a real pipeline.
Version notes
-
Drupal 10.2: performance tests (Gander) arrive in core.
-
Drupal 10.3 to 10.6: core adds support for PHPUnit 10 beside PHPUnit 9; the HTML output logger becomes an extension parameter for PHPUnit 10 and later, replacing
BROWSERTEST_OUTPUT_DIRECTORY. -
Drupal 11.2:
run-tests.shruns tests that use PHPUnit attributes. -
Drupal 11.3: PHPUnit 10 is dropped; PHPUnit 11 is the minimum.
-
Drupal 11.4: the current stable line; the Standard profile creates no Article and Page content types.
-
Drupal 12 (scheduled week of 2026-12-07): PHPUnit 12.5, in which docblock annotations are not supported. Convert all tests to attributes now; with attributes in place the move needs no test rewrite beyond static data providers.
-
Drupal 10: reaches end of life on 2026-12-09; a Drupal 10 site still on PHPUnit 9 annotations should move to 11.x before worrying about the tests.