Drupal's testing infrastructure is built on PHPUnit, but it layers three distinct test types on top of it — each with different bootstrapping, speed, and fidelity trade-offs. Most Drupal developers have run tests but haven't written them deliberately. This guide covers what each test type is actually good for, how to set up a working test environment, and real module test examples that demonstrate the patterns you'll use daily.
The Three Test Types
| Type | Bootstrap | Database | HTTP | Speed | Best for |
|---|---|---|---|---|---|
| Unit | None | No | No | Very fast (<1 s/test) | Pure PHP logic, value objects, utilities |
| Kernel | Drupal kernel + DB | Yes | No | Moderate (1–5 s/test) | Services, plugins, entity CRUD, config |
| Functional (BrowserTest) | Full Drupal install | Yes | Simulated | Slow (5–30 s/test) | Forms, routing, access control, UI flows |
The rule of thumb: write unit tests wherever possible, kernel tests when you need Drupal services or the database, and functional tests only when you need to verify routing, form submission, or access control end-to-end.
Setup
Install dev dependencies
composer require --dev drupal/core-dev --update-with-all-dependenciesConfigure phpunit.xml
cp web/core/phpunit.xml.dist phpunit.xmlEdit the relevant environment variables in phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="web/core/tests/bootstrap.php"
colors="true">
<php>
<!-- Site URL for functional tests -->
<env name="SIMPLETEST_BASE_URL" value="http://localhost:8080"/>
<!-- Test database (separate from dev DB -- gets wiped!) -->
<env name="SIMPLETEST_DB" value="mysql://drupal:drupal@db/drupal_test"/>
<!-- Functional test HTML output -->
<env name="BROWSERTEST_OUTPUT_DIRECTORY" value="/var/www/html/web/sites/simpletest/browser_output"/>
<env name="MINK_DRIVER_ARGS_WEBDRIVER" value='["chrome", {"browserName":"chrome","goog:chromeOptions":{"args":["--disable-gpu","--headless","--no-sandbox"]}},"http://chromedriver:4444/wd/hub"]'/>
</php>
<testsuites>
<testsuite name="unit">
<directory>web/modules/custom/*/tests/src/Unit</directory>
</testsuite>
<testsuite name="kernel">
<directory>web/modules/custom/*/tests/src/Kernel</directory>
</testsuite>
<testsuite name="functional">
<directory>web/modules/custom/*/tests/src/Functional</directory>
</testsuite>
</testsuites>
</phpunit>Directory Structure
web/modules/custom/my_module/
├── my_module.info.yml
├── src/
│ └── ...
└── tests/
└── src/
├── Unit/
│ └── PriceCalculatorTest.php
├── Kernel/
│ └── ProductStorageTest.php
└── Functional/
└── ProductFormTest.phpUnit Tests
Unit tests extend PHPUnit\Framework\TestCase. No Drupal bootstrap, no database. Anything that needs the service container must be mocked.
<?php
// tests/src/Unit/PriceCalculatorTest.php
declare(strict_types=1);
namespace Drupal\Tests\my_module\Unit;
use Drupal\my_module\PriceCalculator;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass \Drupal\my_module\PriceCalculator
* @group my_module
*/
class PriceCalculatorTest extends TestCase {
/**
* @covers ::calculateWithTax
* @dataProvider taxProvider
*/
public function testCalculateWithTax(int $price, float $rate, int $expected): void {
$calculator = new PriceCalculator();
$this->assertSame($expected, $calculator->calculateWithTax($price, $rate));
}
public static function taxProvider(): array {
return [
'standard DK VAT 25%' => [10000, 0.25, 12500],
'zero rate' => [10000, 0.00, 10000],
'fractional rounding' => [999, 0.25, 1249], // 1248.75 → floor
];
}
public function testNegativePriceThrows(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Price must be non-negative');
(new PriceCalculator())->calculateWithTax(-1, 0.25);
}
}<?php
// src/PriceCalculator.php
namespace Drupal\my_module;
class PriceCalculator {
public function calculateWithTax(int $priceMinorUnits, float $taxRate): int {
if ($priceMinorUnits < 0) {
throw new \InvalidArgumentException('Price must be non-negative');
}
return (int) floor($priceMinorUnits * (1 + $taxRate));
}
}vendor/bin/phpunit --testsuite unitUnit Testing with Mocked Drupal Services
<?php
namespace Drupal\Tests\my_module\Unit;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\my_module\FeatureFlags;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass \Drupal\my_module\FeatureFlags
* @group my_module
*/
class FeatureFlagsTest extends TestCase {
public function testIsEnabledReturnsTrueWhenConfigSet(): void {
$config = $this->createMock(ImmutableConfig::class);
$config->method('get')
->with('features.new_checkout')
->willReturn(TRUE);
$factory = $this->createMock(ConfigFactoryInterface::class);
$factory->method('get')
->with('my_module.settings')
->willReturn($config);
$flags = new FeatureFlags($factory);
$this->assertTrue($flags->isEnabled('new_checkout'));
}
}Kernel Tests
Kernel tests extend Drupal\KernelTests\KernelTestBase. They boot a minimal Drupal kernel with a real database (created fresh for each test class). Use them when you need the entity system, config system, or any Drupal service.
<?php
// tests/src/Kernel/ProductStorageTest.php
declare(strict_types=1);
namespace Drupal\Tests\my_module\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the Product entity storage.
*
* @group my_module
*/
class ProductStorageTest extends KernelTestBase {
/**
* Modules to enable for this test.
*
* @var array
*/
protected static $modules = ['my_module', 'user', 'system', 'field', 'text'];
protected function setUp(): void {
parent::setUp();
// Install the schema for modules we depend on
$this->installEntitySchema('user');
$this->installEntitySchema('product'); // Our custom entity
$this->installConfig(['my_module']);
$this->installSchema('system', ['sequences']);
}
public function testCreateAndLoadProduct(): void {
$storage = $this->container->get('entity_type.manager')
->getStorage('product');
// Create a product entity
$product = $storage->create([
'title' => 'Test Widget',
'sku' => 'TW-001',
'price' => 4999,
'status' => 1,
]);
$product->save();
$this->assertNotNull($product->id());
// Load it back
$loaded = $storage->load($product->id());
$this->assertSame('Test Widget', $loaded->label());
$this->assertSame('TW-001', $loaded->get('sku')->value);
$this->assertSame(4999, (int) $loaded->get('price')->value);
}
public function testQueryByStatus(): void {
$storage = $this->container->get('entity_type.manager')
->getStorage('product');
$storage->create(['title' => 'Published', 'sku' => 'A', 'price' => 100, 'status' => 1])->save();
$storage->create(['title' => 'Draft', 'sku' => 'B', 'price' => 100, 'status' => 0])->save();
$ids = $storage->getQuery()
->condition('status', 1)
->accessCheck(FALSE)
->execute();
$this->assertCount(1, $ids);
$product = $storage->load(reset($ids));
$this->assertSame('Published', $product->label());
}
}vendor/bin/phpunit --testsuite kernelTesting Custom Services in Kernel Tests
<?php
public function testEmailNotificationService(): void {
// Override the mailer service with a spy
$mailer = $this->createMock(\Drupal\Core\Mail\MailManagerInterface::class);
$mailer->expects($this->once())
->method('mail')
->with('my_module', 'order_confirmation', $this->anything());
$this->container->set('plugin.manager.mail', $mailer);
// Trigger the code path that sends mail
$service = $this->container->get('my_module.order_service');
$service->processOrder(1);
}Functional Tests
Functional tests extend Drupal\Tests\BrowserTestBase. They install a full Drupal site into the test database and simulate browser interactions. They are slow but necessary for testing routing, forms, access, and the full request cycle.
<?php
// tests/src/Functional/ProductFormTest.php
declare(strict_types=1);
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests the product add/edit forms.
*
* @group my_module
*/
class ProductFormTest extends BrowserTestBase {
protected static $modules = ['my_module'];
protected $defaultTheme = 'stark';
public function testAdminCanCreateProduct(): void {
// Create a user with the right permissions
$admin = $this->drupalCreateUser([
'administer products',
'access administration pages',
]);
$this->drupalLogin($admin);
// Navigate to the add form
$this->drupalGet('admin/content/products/add');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->fieldExists('title[0][value]');
// Submit the form
$this->submitForm([
'title[0][value]' => 'Test Widget',
'sku[0][value]' => 'TW-001',
'price[0][value]' => '49.99',
'status[value]' => 1,
], 'Save');
// Verify we were redirected and the entity was saved
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Product Test Widget has been created.');
}
public function testAnonymousCannotAccessAdminPage(): void {
$this->drupalGet('admin/content/products');
$this->assertSession()->statusCodeEquals(403);
}
public function testProductListingShowsPublishedOnly(): void {
// Create published and unpublished products via API
$storage = \Drupal::entityTypeManager()->getStorage('product');
$storage->create(['title' => 'Visible', 'sku' => 'A', 'price' => 100, 'status' => 1])->save();
$storage->create(['title' => 'Invisible', 'sku' => 'B', 'price' => 100, 'status' => 0])->save();
$this->drupalGet('products');
$this->assertSession()->pageTextContains('Visible');
$this->assertSession()->pageTextNotContains('Invisible');
}
}Useful BrowserTestBase Assertions
// HTTP status
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->statusCodeEquals(403);
// Page content
$this->assertSession()->pageTextContains('Expected text');
$this->assertSession()->pageTextNotContains('Absent text');
// Fields and elements
$this->assertSession()->fieldExists('title[0][value]');
$this->assertSession()->elementExists('css', '.product-card');
$this->assertSession()->linkExists('Read more');
// Response headers
$this->assertSession()->responseHeaderEquals('Content-Type', 'application/json');
// Navigation
$this->drupalGet('/path/to/page');
$this->clickLink('Click me');
$this->submitForm(['field' => 'value'], 'Submit button text');Running Tests
# All tests in a custom module
vendor/bin/phpunit web/modules/custom/my_module/
# Just unit tests
vendor/bin/phpunit --testsuite unit
# Specific test class
vendor/bin/phpunit web/modules/custom/my_module/tests/src/Kernel/ProductStorageTest.php
# Tests in a group
vendor/bin/phpunit --group my_module
# Verbose output
vendor/bin/phpunit -v web/modules/custom/my_module/
# With code coverage (requires Xdebug or pcov)
vendor/bin/phpunit --coverage-html /tmp/coverage web/modules/custom/my_module/Running in Docker
docker compose exec php \
vendor/bin/phpunit \
--configuration /var/www/html/phpunit.xml \
--testsuite unit \
web/modules/custom/my_module/GitHub Actions Integration
# .github/workflows/tests.yml
name: Drupal Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: drupal_test
MYSQL_USER: drupal
MYSQL_PASSWORD: drupal
ports: ["3306:3306"]
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_mysql, gd, zip
coverage: pcov
- run: composer install --prefer-dist --no-progress
- name: Run unit tests
env:
SIMPLETEST_DB: "mysql://drupal:drupal@127.0.0.1/drupal_test"
SIMPLETEST_BASE_URL: "http://localhost"
run: vendor/bin/phpunit --testsuite unit --log-junit test-results.xml
- name: Run kernel tests
env:
SIMPLETEST_DB: "mysql://drupal:drupal@127.0.0.1/drupal_test"
run: vendor/bin/phpunit --testsuite kernel
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results.xmlSummary Checklist
- Install test dependencies with
composer require --dev drupal/core-dev --update-with-all-dependencies - Copy
web/core/phpunit.xml.distto project root; setSIMPLETEST_DBandSIMPLETEST_BASE_URL - Use Unit tests (
PHPUnit\Framework\TestCase) for pure PHP logic — they run in milliseconds - Use Kernel tests (
KernelTestBase) for entity CRUD, services, and config — list all required modules in$modulesand callinstallEntitySchema()insetUp() - Use Functional tests (
BrowserTestBase) for form submission, routing, and access control — always set$defaultTheme = 'stark' - Annotate test methods with
@coversand@group—@grouplets you run a module's tests in isolation - Mock Drupal services in unit tests with
createMock(); override them in the service container for kernel tests with$this->container->set() - Use
@dataProviderto cover multiple edge cases with one test method - Always call
accessCheck(FALSE)on entity queries inside tests — test users lack session context - Run unit tests in CI on every push; gate merges on kernel tests; run functional tests nightly or pre-release