How to Write Unit Tests for Drupal Custom Modules
Unit tests give Drupal developers a fast way to verify small pieces of custom code without installing a complete website, creating content, or loading every service in the container. They are especially useful for custom modules that transform data, validate configuration, calculate values, or apply business rules that may otherwise be difficult to check through the administration interface.
A good test suite also protects a site during Drupal core updates, PHP upgrades, and refactoring. For an agency working across Sydney, Melbourne, Brisbane, or regional Australia, automated checks can catch regressions before a change reaches a client’s production website. The most effective approach is to test behaviour in small, predictable units and use Drupal-specific test types only where the code genuinely depends on the framework.
Understanding Drupal’s Testing Layers
Drupal uses PHPUnit as its main testing framework. The three commonly used test base classes are UnitTestCase, KernelTestBase, and BrowserTestBase. Each one provides a different level of Drupal integration, so choosing the smallest suitable layer keeps tests faster and easier to maintain.
A unit test runs PHP code in isolation. It does not boot the full Drupal kernel, create database tables, or render a page. This makes it a strong choice for a service that receives an array and returns a result, a plugin that applies a calculation, or a validator that reports errors. A kernel test boots a limited Drupal environment and is suitable for entity queries, typed configuration, and database-backed services. A browser test goes further by checking routes, forms, permissions, and rendered pages in a simulated browser.
For example, a module that calculates a delivery surcharge based on postcode can usually be tested with UnitTestCase. A module that loads nodes through an entity query needs a kernel test. A custom checkout form that must display an error to an anonymous user belongs in a browser test. Treating every test as a browser test creates slow suites and makes failures harder to diagnose.
This distinction also matters when testing features built with configuration-heavy tools. If a module supplies a block used in a landing page, the block’s calculation may deserve a unit test, while its placement and final markup may need a kernel or browser test. Developers can use a Layout Builder guide to understand the site-building context, then test only the custom behaviour that the module owns.
Preparing A Custom Module For Testing
A custom module should keep business logic in services, plugins, or small classes rather than placing substantial logic in hooks and controllers. Code in a service can be instantiated directly and tested with ordinary PHP values. Code spread across procedural hooks often requires more setup and encourages tests that check implementation details instead of outcomes.
A typical module test directory is located at modules/custom/booking_tools/tests/src/Unit. The namespace should match the module’s namespace and the test class should end with Test. A minimal test file may look like this:
<?php
namespace Drupal\Tests\booking_tools\Unit;
use Drupal\booking_tools\Service\FeeCalculator;
use Drupal\Tests\UnitTestCase;
final class FeeCalculatorTest extends UnitTestCase {
public function testBusinessDayFee(): void {
$calculator = new FeeCalculator();
$this->assertSame(12.50, $calculator->calculate(100.00, 'business'));
}
}
The class extends Drupal’s UnitTestCase, which extends PHPUnit’s test case and provides Drupal-specific helpers. Public methods beginning with test are discovered automatically. Modern Drupal projects can also use the #[Test] attribute where supported by the project’s PHPUnit version, but consistent naming remains widely understood across Drupal teams.
Before writing tests, identify the dependencies of the class under test. A pure calculator may need no dependency. A service that uses the current user, a logger, a time service, or an entity storage handler should receive those objects through dependency injection. This design makes the service easier to isolate and allows each dependency to be replaced with a mock or stub.
Writing Assertions For Real Behaviour
A useful unit test describes a business rule in plain terms. Arrange the input and dependencies, act by calling the method, and assert the result. Test names such as testRejectsAnExpiredBooking communicate more than names based on internal methods such as testValidateDateField. The test should fail when the promised behaviour changes, not when an unrelated private implementation is reorganised.
Consider a validator that rejects dates in the past. A focused test suite might cover a valid future date, an invalid past date, and the boundary at the current date. Drupal’s time service is valuable here because it avoids depending on the actual clock:
public function testRejectsPastDate(): void {
$time = $this->createMock(\Drupal\Component\Datetime\TimeInterface::class);
$time->method('getCurrentTime')->willReturn(1704067200);
$validator = new DateValidator($time);
$this->assertFalse($validator->isValid('2023-12-31'));
}
The exact timestamps and business rules will vary, but the principle is stable: control changing inputs and assert an observable result. Avoid asserting that a particular private method was called or that an internal array has a specific temporary shape. Those checks make refactoring expensive without proving that users receive the correct outcome.
Mocks should represent collaborators, not replace the class being tested. A logger mock can confirm that an exceptional path records a warning, while a configuration mock can return a selected threshold. If a test requires many mocks, complicated setup, or knowledge of half the service container, the production class may have too many responsibilities. Splitting it into smaller services often improves both the design and the tests.
Data providers are useful when the same rule has several inputs. They keep a test compact while making edge cases visible:
/**
* @dataProvider postcodeProvider
*/
public function testAustralianPostcodeIsAccepted(string $postcode, bool $expected): void {
$validator = new PostcodeValidator();
$this->assertSame($expected, $validator->isValid($postcode));
}
public static function postcodeProvider(): array {
return [
['2000', TRUE],
['3000', TRUE],
['0800', TRUE],
['999', FALSE],
['ABCD', FALSE],
];
}
The examples include Sydney, Melbourne, and Darwin postcode formats, but production validation should follow the actual requirements of the project. A national retailer may need rules that differ from a local service provider, particularly where overseas addresses are accepted.
Managing Drupal Services And Configuration
Drupal unit tests often need mocks for interfaces such as AccountInterface, LoggerInterface, ConfigFactoryInterface, or EntityTypeManagerInterface. Prefer mocking the narrow interface used by the service. For configuration, a small stub can be clearer than creating an entire Drupal configuration system. The goal is to supply the class with the values it expects and then verify its response.
A common mistake is trying to test a service that calls global helpers, static service lookups, or \Drupal::service() directly. Such code is harder to isolate and can force a unit test into an imitation of the entire Drupal runtime. Inject the dependency instead:
public function __construct(
private readonly ConfigFactoryInterface $config_factory,
private readonly TimeInterface $time,
) {}
When configuration or entity storage is central to the behaviour, move to KernelTestBase rather than constructing fragile mocks. Kernel tests can install the required schema and modules, create entities, and exercise real Drupal services. They are slower than unit tests, but they provide confidence that service definitions, storage handlers, and configuration schemas work together.
Keep the test environment explicit. A kernel test commonly declares modules in a protected $modules property and uses setUp() to install a schema or create test entities. Avoid loading unrelated modules because this increases runtime and can hide which dependency the custom module truly requires. A module that works only because another project module happens to be enabled has a deployment risk.
Australian projects also need to test privacy-sensitive behaviour carefully. If a custom module stores customer names, phone numbers, or delivery details, tests should verify that only necessary values are logged and that access checks are enforced. The Privacy Act 1988 and the Australian Privacy Principles influence how many organisations handle personal information, even when the test itself runs with fake data. Never place real customer records in fixtures or committed test files.
Building A Reliable Test Workflow
A test suite becomes valuable when it runs frequently. Developers can run one class while working on a feature, then run the complete suite in CI before merging. Typical commands depend on the project’s Composer setup, but a Drupal project commonly uses:
vendor/bin/phpunit -c web/core/phpunit.xml.dist \
web/modules/custom/booking_tools/tests/src/Unit
Some projects define a root-level phpunit.xml, a DDEV command, or a CI-specific configuration file. Use the project’s documented command so that autoloading, database settings, and environment variables remain consistent. Run coding standards and static analysis alongside PHPUnit; a passing test does not detect every type error or insecure API usage.
Tests should be deterministic. Do not depend on the current date, a remote API, a developer’s timezone, or the order in which tests happen to run. This is particularly important for Australian teams working across Australian Eastern, Central, and Western time zones. A date test that passes in Perth but fails in Melbourne is usually exposing an uncontrolled timezone assumption rather than a valid business rule.
The following comparison helps determine where each test belongs:
| Test Type | Drupal Runtime | Suitable For | Typical Speed |
|---|---|---|---|
| Unit test | No full kernel | Pure services, validators, calculators, plugins with mocked dependencies | Fast |
| Kernel test | Limited kernel and database | Entities, configuration, schemas, Drupal service integration | Moderate |
| Browser test | Full Drupal site and browser simulation | Routes, forms, permissions, redirects, rendered pages | Slowest |
Use coverage reports as a guide rather than a target to chase blindly. High line coverage can still miss important outcomes, while a smaller suite with clear edge cases may offer stronger protection. A module used by an Australian government supplier may require formal evidence and traceability, whereas a small local business site may prioritise fast feedback and the most financially significant workflows.
Practical Testing Habits For Drupal Teams
The strongest test suites grow from the risks in a module. Start with calculations, permissions, validation, and integrations that could cause lost data, incorrect charges, or privacy problems. Then add regression tests whenever a production defect is fixed. That habit turns real incidents into permanent safeguards rather than temporary patches.
Useful practices include:
- Keep pure business rules in injectable services so they can be tested without booting Drupal.
- Give each test one clear reason to fail and use names that describe the expected behaviour.
- Include boundary values, empty input, invalid input, and permission-sensitive cases.
- Use mocks for external collaborators, but prefer kernel tests when real Drupal integration is the behaviour under examination.
- Control dates, timezones, random values, and API responses so tests behave consistently in every Australian CI environment.
- Use fake personal data and review logs, access checks, and exported configuration for privacy risks.
- Run focused tests during development and the full PHPUnit suite before deployment.
A custom module does not need hundreds of tests to be dependable. A compact set of meaningful unit tests can protect a fee calculator, booking rule, content moderation decision, or access check for years. As the module grows, the test suite should remain readable enough for another developer to understand the intended behaviour without opening every implementation file.