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.

flowchart TB P["Performance tests<br/>PerformanceTestBase, query and cache counts<br/>~ a few per site"] N["FunctionalJavascript + Nightwatch<br/>real browser, JavaScript and AJAX<br/>~ tens, seconds each"] F["Functional - BrowserTestBase<br/>installed site, simulated browser<br/>~ hundreds, a second or more each"] K["Kernel - KernelTestBase<br/>container + database, minimal modules<br/>~ hundreds, tens of milliseconds each"] U["Unit - UnitTestCase<br/>one class, no container, no database<br/>~ thousands, milliseconds each"] P --- N --- F --- K --- U

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

Drupal\Tests\UnitTestCase

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.

tests/src/Unit

Kernel

Drupal\KernelTests\KernelTestBase

A booted kernel and service container, a database with only the tables you ask for, and only the modules in $modules.

The importer creating collection_object entities; configuration; hooks; queries.

tests/src/Kernel

Functional

Drupal\Tests\BrowserTestBase

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.

tests/src/Functional

FunctionalJavascript

Drupal\FunctionalJavascriptTests\WebDriverTestBase

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.

tests/src/FunctionalJavascript

Performance

Drupal\FunctionalJavascriptTests\PerformanceTestBase

A real browser that also records database queries, cache operations and asset counts.

The collection page staying within a query and cache budget.

tests/src/FunctionalJavascript

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.

tests/src/Nightwatch/Tests

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)

@group meridian_collection

#[Group('meridian_collection')]

Declare the covered class (class level)

@coversDefaultClass \Drupal\meridian_collection\CollectionObjectImporter

#[CoversClass(CollectionObjectImporter::class)]

Declare a data provider

@dataProvider provideRecords

#[DataProvider('provideRecords')]

Expect deprecations (previously the Drupal convention @group legacy)

@group legacy

#[IgnoreDeprecations]

Method-level coverage

@covers ::import

No attribute equivalent in the change record: remove it, or rename it @legacy-covers

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.

  1. 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 @group or data provider. Convert a file completely, in one commit.

  2. 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.

  3. 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.

  4. Prophecy is not part of PHPUnit 10 or later. Drupal’s UnitTestCase and KernelTestBase include the ProphecyTrait so $this->prophesize() still works in core base classes, but new code can use PHPUnit’s own createMock(); 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 example Drupal\Tests\meridian_collection\Kernel. Kernel tests inherit from a class in the Drupal\KernelTests namespace but the module’s own test classes use the Drupal\Tests\... namespace as well.

  • Shared helpers go in tests/src/Traits with the namespace \Drupal\Tests\<module>\Traits.

  • Test-only modules, themes and profiles go in tests/modules, tests/themes and tests/profiles. Drupal finds them there when a test lists them in $modules, and since they are not under modules/ 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 with test (or carry the #[Test] attribute). Abstract base classes end in Base.

  • 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

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>

The settings in detail:

Setting Meaning and pitfalls

bootstrap

Path to core/tests/bootstrap.php, relative to the file. It registers the test namespaces and sets up $_SERVER for Drupal. A wrong path gives "Class Drupal\Tests\UnitTestCase not found".

SIMPLETEST_BASE_URL

The URL of the site under test, with the http:// scheme. With https:// a JavaScript test fails with "invalid cookie domain"; with a host the PHP process cannot resolve, every Functional test fails with a connection error. The value is forced (force="true") so that a shell variable cannot override it.

SIMPLETEST_DB

A database URL of the form driver://user:password@host/database. Kernel and Functional tests install a copy of the site into tables with a random prefix (test12345678) inside this database and delete them at the end; they never touch the live tables. In DDEV the values of the db service are db, db and db; they are the well-known local defaults of DDEV and not a secret. For any shared environment, put the real value in the CI job’s environment, not in a committed file. SQLite (sqlite://localhost//tmp/meridian.sqlite) works for Kernel tests and makes them faster.

MINK_DRIVER_ARGS_WEBDRIVER

A JSON array [browser, capabilities, webdriver-url] used by FunctionalJavascript and performance tests. MINK_DRIVER_ARGS is the equivalent for plain Functional tests and usually stays empty.

outputDirectory (extension parameter)

Where HtmlOutputLogger writes the HTML of each page a Functional test loaded. With PHPUnit 9 (Drupal 10 and earlier) this was the environment variable BROWSERTEST_OUTPUT_DIRECTORY; for PHPUnit 10 and later it is an extension parameter. The directory must exist and be writable by the user running PHPUnit: mkdir -p web/sites/simpletest/browser_output.

error_reporting, memory_limit

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

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"
# 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

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_URL is sometimes http://web (the web container) rather than http://meridian.ddev.site. Use the value that the add-on’s documentation gives for your version.

  • Scheme. The base URL must be http://. An https:// base URL causes "invalid cookie domain" in JavaScript tests.

  • Headless and --no-sandbox. In a container Chrome needs --headless, --disable-gpu, --disable-dev-shm-usage and (when it runs as root) --no-sandbox. The value in phpunit.xml above contains them. A test class can override the array by overriding getMinkDriverArgs() (extending WebDriverTestBase or BrowserTestBase), decoding the parent’s JSON, appending an argument and returning json_encode() of the result.

  • Without a server on a plain Linux or WSL host. Install xvfb and start ChromeDriver with xvfb-run chromedriver --port=4444; keep it running while PHPUnit runs.

  • Permissions. Functional tests write into sites/simpletest and sites/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 same www-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.yml are 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 the user entity (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();
  }

}

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 */]);
}

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" - $strictConfigSchema is TRUE by default; a configuration key without a schema in config/schema/meridian_collection.schema.yml fails 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, TRUE or FALSE, 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')) after installConfig and 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 $defaultTheme is mandatory (since Drupal 8.8). stark is the usual choice because it relies on nothing. If it is missing, the test errors before it begins.

  • protected static $modules lists the modules to install, as in Kernel tests.

  • protected $strictConfigSchema = TRUE is 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'));
  }

}

Pitfalls:

  • A Functional test is slow (a full install takes seconds). Reduce the install cost with a minimal profile (the default for BrowserTestBase is the testing profile, not standard) and a short $modules list. 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 HtmlOutputLogger into 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 expects article must create it. This also concerns older books, whose test chapters rely on the article type.

  • 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.

sequenceDiagram participant T as PHPUnit test (web container) participant S as Web server (Drupal under test) participant W as WebDriver (selenium-chrome) participant B as Chrome T->>S: install test site (prefixed tables) T->>W: new session (MINK_DRIVER_ARGS_WEBDRIVER) W->>B: start headless browser T->>W: visit /collection W->>B: navigate B->>S: GET /collection S-->>B: HTML + behaviors T->>W: click "On display now" B->>S: AJAX request S-->>B: AJAX commands T->>W: waitForElementVisible, then assert

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 @tags using 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.js checks 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());
  }

}

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

CollectionObjectSyncWorker

Unit

Each importer outcome maps to the right queue exception; malformed items are dropped.

CollectionObjectImporter, entity storage

Kernel

An entity is created, updated and left alone when unchanged; fields persist; accessCheck() is honoured.

Config schema, hook_update_N, post-update

Kernel

Install the module, run meridian_collection_update_10001() or the post-update function, check the stored result (see Update API and post-updates).

Access policy for curators, entity access

Kernel

Roles with and without edit collection objects get the expected AccessResult (see Access control and permissions in code).

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 renderRoot() for markup and cache metadata; place it and load a page for the integration.

Queue plus Drush command meridian:sync

Kernel

Call the planner service and assert on the queue’s item count, or run the command with DrushTestTrait (see below).

Filter on the listing, AJAX

FunctionalJavascript

The result list changes without a reload.

Hot-cache budget of /collection

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 CollectionObjectCreationTrait in tests/src/Traits with createCollectionObject() is better than copying the same ten lines.

  • Use test-only modules in tests/modules for fixtures: a module that defines a route, a form or a plugin used only by tests, with package: Testing in 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_ME or random machine names from randomMachineName(); the database URL and base URL come from phpunit.xml or 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_EXTRA in 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 bootstrap path in phpunit.xml, or drupal/core-dev not installed.

"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 Test.

"Failed to connect to localhost port 80" in a Functional test

SIMPLETEST_BASE_URL does not point at a URL the PHP process can reach. In DDEV use the project URL or the service host name.

"Unable to create a Mink session" or "Could not open connection" in a JavaScript test

WebDriver not running, wrong URL in MINK_DRIVER_ARGS_WEBDRIVER, or the browser container is not on the network.

"invalid cookie domain"

SIMPLETEST_BASE_URL uses https:// or a host the browser cannot resolve.

"The default theme must be specified"

$defaultTheme is missing in a BrowserTestBase or WebDriverTestBase subclass.

"There are no tables" or "Table …​ doesn’t exist" in a Kernel test

installEntitySchema(), installSchema() or installConfig() not called.

"Schema errors for …​"

A configuration key has no schema; fix config/schema, do not turn strict checking off.

"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 #[IgnoreDeprecations].

Intermittent failures only in CI

Time or order dependence, a shared cache, a JavaScript race. Re-run with --filter, read the HTML output files and the browser console, add proper waits.

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:

  1. Add a .gitlab-ci.yml file to the project’s root.

  2. Copy in the Drupal Association template, gitlab-ci/template.gitlab-ci.yml in the project/gitlab_templates repository (it is also in the GitLab UI’s template list). It includes the shared definition files by reference.

  3. 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"

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

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

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.sh runs 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.