Skip to content

PHPUnit (Drupal)

Goal

PHPUnit runs the Unit, Kernel, Functional and FunctionalJavascript tests a Drupal project writes, and reports each assertion that did not hold. Drupal does not declare phpunit/phpunit on its own; drupal/core-dev pulls in the version core itself is tested against, along with the other packages Drupal's own test base classes need. With it installed, a test-first claim about a Drupal codebase can be run rather than asserted.

The PHPUnit constraint drupal/core-dev carries, by core branch (Packagist):

Core branch drupal/core-dev requires
11.3–11.4 ^11.5.50
11.2 ^10.5.19 \|\| ^11.5.3
11.0–11.1 ^10.5.19
10.6 ^9.6.34
10.2–10.5 ^9.6.13
10.0–10.1 ^9.5

Install

drupal/core-dev pulls in open-telemetry/sdk, and that package requires tbachert/spi — a Composer plugin. Core's own root composer.json sets tbachert/spi to false, and an unattended composer require cannot answer the interactive prompt Composer would otherwise show for an unlisted plugin, so it throws PluginBlockedException instead. Record the same answer first. core-dev's dependency tree carries three more Composer plugins — phpstan/extension-installer directly, dealerdirect/phpcodesniffer-composer-installer through drupal/coder, and php-http/discovery through open-telemetry/exporter-otlp — which a project built from drupal/recommended-project already allows; only tbachert/spi is missing there. Allowing all four here keeps the step correct for a project that was not built from that template.

ddev composer config --no-plugins allow-plugins.tbachert/spi false
ddev composer config --no-plugins allow-plugins.phpstan/extension-installer true
ddev composer config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
ddev composer config --no-plugins allow-plugins.php-http/discovery true

Require drupal/core-dev as a dev dependency, with the flag drupal.org's own "Running PHPUnit tests" documentation gives so Composer resolves the whole dependency set against what the project already requires, rather than only against core-dev itself.

ddev composer require drupal/core-dev --dev --update-with-all-dependencies

That flag lets Composer move packages the project already has, not only core-dev's own. On one fresh drupal/recommended-project install it moved Symfony, Twig and nikic/php-parser, and took sebastian/diff down a major version. The command does not show this, so tell the person approving the install, and read the lock-file diff afterwards.

Do not add phpunit/phpunit to the project's own composer.json. core-dev already carries the constraint core itself is tested against, so a direct requirement is redundant and can drift from core's — it conflicts with the pin only when its own range excludes what core-dev allows.

PHPUnit also needs a phpunit.xml at the project root, beside composer.json and vendor/ — the location that same documentation places it at. Copy the file core ships, <docroot>/core/phpunit.xml.dist, to phpunit.xml at the project root once. The path assumes a web/ docroot; read the project's own docroot from composer.json's extra.drupal-scaffold.locations.web-root first if it differs, and adjust the source path to match.

The copy is not usable as it stands. Every relative path inside it — bootstrap, each testsuite's <directory>, the browser-output directory — is written for core/ and resolves to nothing once the file sits at the project root, and the database and base-URL environment variables ship empty. Making it usable is several edits inside one XML file: which lines change, to what, and why the docroot has to be repeated in front of each one is not a command an argv-safe step can express without a shell to carry it, so it is done by hand. Follow PHPUnit Configuration for the rewrite, line by line. That rewrite includes the two values Functional tests need, SIMPLETEST_BASE_URL and SIMPLETEST_DB, which the guide gives for DDEV. Until the file exists, this recipe's own Run fails, as ## Run describes.

Run

ddev exec vendor/bin/phpunit -c phpunit.xml --list-suites

Exit 0 with an Available test suite: header (PHPUnit prints the plural Available test suites: once more than one is listed) means the file at the project root parsed, its bootstrap path resolved, and PHPUnit reached its <testsuites> block — proof the tool is installed and the configuration is at least structurally sound. Verified on PHPUnit 11.5.56: a -c target that does not exist prints Could not read XML from file and exits 2, and a bootstrap path left unrewritten after the copy above prints Cannot open bootstrap script and exits 2 — each names the specific defect rather than a generic failure.

The header can print with nothing listed under it and still exit 0 — PHPUnit lists a suite only where its <directory> glob matches at least one test class, so an empty list on a project with no tests yet is expected, verified the same way. It stops being expected once a module under the configured directories carries a test file and still does not appear; that combination is a finding.

If the command itself is not found, the package is absent: install, then run it again.