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.
What you’ll build
Section titled “What you’ll build”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.
Prerequisites
Section titled “Prerequisites”-
Drupal’s dev dependencies, which provide PHPUnit configured for core. If
vendor/bin/phpunitis 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:
<?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 theDrupal\Tests\event_rsvp\namespace, mirroringKernel/andFunctional/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@groupor@coversannotations; annotations are the legacy style and core has moved off them. protected static $moduleslists what to install into the kernel environment, and nothing you do not list exists:event_rsvpbrings only itself here. Kernel installs are minimal on purpose; they skip install-time hooks, which is whysetUp()callsinstallSchema('event_rsvp', ['event_rsvp'])explicitly to create the table from yourhook_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:
<?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,BrowserTestBasereports the deprecation “Functional/FunctionalJavascript test classes must specify the #[RunTestsInSeparateProcesses] attribute”, which becomes an exception in Drupal 12.setUp()creates thearticleandpagecontent types, because the test site is bare; nothing from your development site exists in it. The module’s default config enables RSVPs forarticle, which the test then relies on, proving theconfig/installfile from part 4.testRsvpSubmission()walks the visitor’s path through the feature: create an event, create a user holdingrsvp 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.
Step 3: Run the tests
Section titled “Step 3: Run the tests”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:
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_rsvpInside 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.
Step 4: Ship the module
Section titled “Step 4: Ship the module”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:
cd web/modules/custom/event_rsvpgit 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.jsonas avcsrepository 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.
Troubleshooting
Section titled “Troubleshooting”“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.
PHPUnit reports the class cannot be found
Section titled “PHPUnit reports the class cannot be found”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.
Next steps
Section titled “Next steps”- Testing covers unit tests, JavaScript testing, and running the suite in CI;
the Automated tests topicis the API reference behind it. - Create, load, and query entities is the natural next API: most modules outgrow a custom table eventually.
- Deployment workflow takes a module like this from a repository to production.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.