Skip to content

TutorialFor module developersDrupal 11

Build your first module 7: tests and shipping

Everything in this module works because you clicked through it. Tests make that knowledge permanent: they re-verify every behavior on every change, which is what lets you refactor in six months without re-clicking anything. This final part writes a kernel test and a functional test, runs them, and ships the module.

Part 6 completed the module’s features. This part adds tests/src/Kernel/RsvpStorageTest.php, which exercises the storage service against a real database, and tests/src/Functional/RsvpFormTest.php, which installs a full Drupal site and submits the RSVP form in a test browser. Both use PHPUnit attributes for their metadata, the only supported style for new code. You finish with a green test run and a module ready to leave the project it was born in.

  • Parts 1 through 6 completed.

  • Drupal’s dev dependencies, which provide PHPUnit configured for core. If vendor/bin/phpunit is missing from your project, install them:

    Terminal window
    ddev composer require --dev drupal/core-dev

Step 1: Write a kernel test for the storage service

Section titled “Step 1: Write a kernel test for the storage service”

Drupal tests come in tiers. Unit tests run a class in isolation with mocked dependencies; kernel tests boot a minimal Drupal with a real container and database but no HTTP layer; functional tests install a full site and drive it with a browser. RsvpStorage is mostly database calls, so mocking the database would test nothing real: a kernel test is the right tier. Create web/modules/custom/event_rsvp/tests/src/Kernel/RsvpStorageTest.php:

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

The anatomy:

  • Test classes live under tests/src/, in the Drupal\Tests\event_rsvp\ namespace, mirroring Kernel/ and Functional/ subdirectories. PSR-4 again, with a test-specific root.
  • The class-level metadata is PHPUnit attributes: #[CoversClass(RsvpStorage::class)] declares what is being tested, #[Group('event_rsvp')] lets you select these tests by group, and #[RunTestsInSeparateProcesses] isolates each test in its own PHP process. Since Drupal 11.3, a kernel or functional test class without that attribute triggers a deprecation notice, and Drupal 12 refuses to run it. Write metadata as attributes, never as @group or @covers annotations; annotations are the legacy style and core has moved off them.
  • protected static $modules lists what to install into the kernel environment, and nothing you do not list exists: event_rsvp brings only itself here. Kernel installs are minimal on purpose; they skip install-time hooks, which is why setUp() calls installSchema('event_rsvp', ['event_rsvp']) explicitly to create the table from your hook_schema().
  • $this->container->get('event_rsvp.storage') is the same service the controller and form use in production. The test then exercises the public API: add, count, exists, list, delete. Note what the test never does: touch SQL directly or peek at private state. It tests the service’s contract, so the implementation stays free to change.

Step 2: Write a functional test for the form

Section titled “Step 2: Write a functional test for the form”

The form’s behavior spans routing, access, validation, configuration, and storage; only a functional test covers that whole stack. Create web/modules/custom/event_rsvp/tests/src/Functional/RsvpFormTest.php:

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

What is different at this tier:

  • The class extends BrowserTestBase, which installs a complete throwaway Drupal site for the test and tears it down afterwards. protected $defaultTheme = 'stark' is required; functional tests must state which theme to install.
  • #[RunTestsInSeparateProcesses] is required here too: without it, BrowserTestBase reports the deprecation “Functional/FunctionalJavascript test classes must specify the #[RunTestsInSeparateProcesses] attribute”, which becomes an exception in Drupal 12.
  • setUp() creates the article and page content types, because the test site is bare; nothing from your development site exists in it. The module’s default config enables RSVPs for article, which the test then relies on, proving the config/install file from part 4.
  • testRsvpSubmission() walks the visitor’s path through the feature: create an event, create a user holding rsvp to events, log in, load the form, submit, assert the confirmation message from default config, then submit again and assert the duplicate is rejected. The assertions read like part 3’s manual checkpoint, because that is what they are: your manual verification, automated.
  • testAccess() encodes part 6: 403 without the permission, 403 on a non-enabled content type, 403 on an unpublished event the user cannot view, 200 on an enabled one. Functional tests get no super user shortcut; access here is the access your visitors get.

There is a quieter assertion running underneath both tests: the test framework validates all saved configuration against its schema, so the config/schema file from part 4 is being enforced on every run.

PHPUnit needs two environment variables: SIMPLETEST_DB, a database URL for the test site (use a throwaway database, never your real one), and, for functional tests, SIMPLETEST_BASE_URL, where the test browser can reach your web server. The tests run where PHP and the web server run, so with DDEV, open a shell in the web container first with ddev ssh; it starts at the project root. Then run, using core’s PHPUnit configuration:

Terminal window
export SIMPLETEST_DB='sqlite://localhost/sites/default/files/.test.sqlite'
export SIMPLETEST_BASE_URL='http://localhost'
vendor/bin/phpunit -c web/core web/modules/custom/event_rsvp

Inside the container, http://localhost resolves to the web container itself, and the SQLite URL needs no database service at all. The -c web/core flag points PHPUnit at web/core/phpunit.xml.dist, which wires Drupal’s bootstrap; the path argument limits the run to your module’s tests. The kernel test finishes in seconds; the functional test installs Drupal twice (once per test method), so expect a couple of minutes.

To run a single class while developing, pass its file path instead of the directory. To run everything that declares your group across the codebase: vendor/bin/phpunit -c web/core --group event_rsvp.

The module is feature-complete and tested, and nothing about it depends on your site: it never hardcoded a node ID, a theme, or a path, and its only requirements are declared in its info file. That is what makes it shippable. To move it out of the project it was born in, make the module directory its own repository:

Terminal window
cd web/modules/custom/event_rsvp
git init && git add . && git commit -m "Event RSVP 1.0.0"

From here, the distribution options in increasing order of ceremony:

  • Copy it. A module directory is self-contained; dropping it into another site’s web/modules/custom/ is a legitimate deployment for in-house code.
  • A private Git repository plus Composer. Add the repository to a project’s composer.json as a vcs repository and require it like any package. This is the standard path for client work.
  • A drupal.org project. Publishing at drupal.org/project gives you packaging, composer require drupal/event_rsvp, security-team coverage once the project is stable, and an issue queue. The contribution documentation on drupal.org walks through promoting a sandbox to a full project.

Before anything ships, hold it to the standard contributed modules are held to: the tests pass, and the code passes Drupal’s coding standards (PHPCS with the Drupal and DrupalPractice rulesets) and static analysis (PHPStan with phpstan-drupal). Testing covers wiring all three into CI so they run on every change.

Seven parts ago this was an empty directory. Now it is a module with routed pages, an injected service layer over a custom table, configuration with schema, a cache-correct block, a permission model with a custom access check, a class-based hook, and a test suite that proves all of it. Every one of those is the current Drupal 11 idiom, so what you practiced here is what you will read inside core and well-maintained contributed modules.

“Functional/FunctionalJavascript test classes must specify the #[RunTestsInSeparateProcesses] attribute”

Section titled ““Functional/FunctionalJavascript test classes must specify the #[RunTestsInSeparateProcesses] attribute””

PHPUnit lists this deprecation when a functional test class lacks the attribute (kernel test classes get the same notice, starting “Kernel test classes”). The test still runs on Drupal 11, but Drupal 12 refuses to. Add #[RunTestsInSeparateProcesses] above the class and import PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses.

“There is no database connection so no tests can be run”

Section titled ““There is no database connection so no tests can be run””

SIMPLETEST_DB is not set in the environment PHPUnit runs in. Export it in the same shell, or define it in a phpunit.xml copied from core’s phpunit.xml.dist.

Functional tests fail while kernel tests pass

Section titled “Functional tests fail while kernel tests pass”

Almost always SIMPLETEST_BASE_URL: it must be a URL the test process can fetch. With DDEV, run PHPUnit inside ddev ssh and use http://localhost. A curl error or “base URL” message in the failure output confirms this.

The test namespace must mirror the path: Drupal\Tests\event_rsvp\Kernel\RsvpStorageTest in tests/src/Kernel/RsvpStorageTest.php. Also confirm dev dependencies are installed (vendor/bin/phpunit exists).

Config schema errors during the functional test

Section titled “Config schema errors during the functional test”

The functional test validates every saved config object against its schema. If you experimented with extra keys in event_rsvp.settings.yml, declare them in config/schema/event_rsvp.schema.yml or remove them.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.