Drupal 10 PHPUnit Testing: Kernel and Functional Tests
Drupal 10 ships with three PHPUnit test types: Unit (no Drupal), Kernel (real services, no browser), and Functional (full browser via BrowserKit). Each type makes different trade-offs between speed and coverage depth. This guide explains when to use each and how to write effective tests for custom modules.
Key Takeaways
Use Unit tests for pure PHP logic only. Drupal Unit tests load no services and no database. They're the fastest category but can't test anything that touches the Drupal service container.
Kernel tests are the sweet spot for module testing. They boot a subset of Drupal with real services (database, config, entity system) but without a browser. Ideal for plugins, services, entity hooks, and database queries.
Functional tests are expensive — use them selectively. BrowserKit-based functional tests boot the full Drupal stack and make HTTP requests. Reserve for route access checks, form submission flows, and permission-dependent pages.
Extend the right base class. UnitTestCase → KernelTestBase → BrowserTestBase. Each adds overhead; choose the minimum needed for your test.
Install only what you need. Kernel tests let you install specific modules and schema. Installing the entire Drupal module ecosystem makes tests slow and fragile.
Drupal's Three Test Types
Drupal's testing framework — part of Drupal core — wraps PHPUnit with three abstract base classes that determine what environment is available to your test:
| Type | Base Class | Database | Services | Browser |
|---|---|---|---|---|
| Unit | UnitTestCase |
✗ | ✗ | ✗ |
| Kernel | KernelTestBase |
✓ | ✓ | ✗ |
| Functional | BrowserTestBase |
✓ | ✓ | ✓ |
The test type determines where to place the file. Drupal discovers tests based on the namespace:
modules/custom/my_module/tests/
src/
Unit/ → UnitTestCase tests
Kernel/ → KernelTestBase tests
Functional/ → BrowserTestBase testsRunning Tests
Drupal ships with a run-tests.sh script, but PHPUnit works directly:
# From Drupal root
./vendor/bin/phpunit -c core/phpunit.xml.dist \
modules/custom/my_module/tests/
<span class="hljs-comment"># Run only Kernel tests
./vendor/bin/phpunit -c core/phpunit.xml.dist \
--filter=Kernel \
modules/custom/my_module/tests/Set environment variables for the test database in phpunit.xml.dist:
<php>
<env name="SIMPLETEST_BASE_URL" value="http://localhost"/>
<env name="SIMPLETEST_DB" value="mysql://root:root@localhost/drupal_test"/>
<env name="BROWSERTEST_OUTPUT_DIRECTORY" value="/tmp/simpletest"/>
</php>Unit Tests
Unit tests extend \Drupal\Tests\UnitTestCase. They load no Drupal services — just autoloaded PHP classes. Use them for pure logic: string manipulation, calculations, data transformations.
<?php
namespace Drupal\Tests\my_module\Unit;
use Drupal\my_module\Utility\SlugGenerator;
use Drupal\Tests\UnitTestCase;
/**
* @group my_module
*/
class SlugGeneratorTest extends UnitTestCase {
private SlugGenerator $generator;
protected function setUp(): void {
parent::setUp();
$this->generator = new SlugGenerator();
}
public function test_generates_lowercase_slug(): void {
$slug = $this->generator->generate('Hello World');
$this->assertSame('hello-world', $slug);
}
public function test_removes_special_characters(): void {
$slug = $this->generator->generate('Café & Bar!');
$this->assertSame('cafe-bar', $slug);
}
public function test_truncates_to_max_length(): void {
$long = str_repeat('word-', 50);
$slug = $this->generator->generate($long, 50);
$this->assertLessThanOrEqual(50, strlen($slug));
}
}Kernel Tests
Kernel tests extend \Drupal\KernelTests\KernelTestBase. They install a real Drupal kernel with a test database but no browser. You explicitly install modules and entity schemas needed for each test.
Basic Kernel Test
<?php
namespace Drupal\Tests\my_module\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the event service with real config storage.
*
* @group my_module
*/
class EventServiceTest extends KernelTestBase {
protected static $modules = [
'system',
'user',
'my_module',
];
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
$this->installConfig(['my_module']);
}
public function test_creates_event_with_defaults(): void {
/** @var \Drupal\my_module\EventService $service */
$service = $this->container->get('my_module.event_service');
$event = $service->create([
'title' => 'Annual Conference',
'date' => '2026-09-01',
]);
$this->assertNotNull($event->id());
$this->assertSame('Annual Conference', $event->label());
$this->assertSame('draft', $event->get('status')->value);
}
public function test_publish_changes_status(): void {
$service = $this->container->get('my_module.event_service');
$event = $service->create(['title' => 'Test', 'date' => '2026-09-01']);
$service->publish($event);
$this->assertSame('published', $event->get('status')->value);
}
}Testing Config Entities
Config entities are common in Drupal modules. Kernel tests can create and load them via the entity type manager:
<?php
public function test_config_entity_crud(): void {
$this->installConfig(['my_module']);
$storage = $this->container
->get('entity_type.manager')
->getStorage('my_module_profile');
// Create
$profile = $storage->create([
'id' => 'default_profile',
'label' => 'Default Profile',
'rules' => ['max_items' => 10],
]);
$profile->save();
// Load
$loaded = $storage->load('default_profile');
$this->assertNotNull($loaded);
$this->assertSame('Default Profile', $loaded->label());
$this->assertSame(10, $loaded->get('rules')['max_items']);
// Delete
$loaded->delete();
$this->assertNull($storage->load('default_profile'));
}Testing Database Queries
Custom \Drupal\Core\Database\Query usage can be tested in kernel tests:
<?php
public function test_finds_active_records(): void {
$database = $this->container->get('database');
// Seed test data
$database->insert('my_module_events')
->fields([
'title' => 'Active Event',
'status' => 1,
'uid' => 1,
])
->execute();
$database->insert('my_module_events')
->fields([
'title' => 'Archived Event',
'status' => 0,
'uid' => 1,
])
->execute();
/** @var \Drupal\my_module\EventRepository $repo */
$repo = $this->container->get('my_module.event_repository');
$active = $repo->findActive();
$this->assertCount(1, $active);
$this->assertSame('Active Event', $active[0]->title);
}Testing Cache
Drupal's cache layer can be tested in kernel tests:
<?php
public function test_result_is_cached(): void {
$service = $this->container->get('my_module.expensive_service');
// First call should hit the source
$first = $service->getData('key1');
// Second call should return cached value
$second = $service->getData('key1');
$this->assertSame($first, $second);
// Verify cache was set
$cache = $this->container->get('cache.default');
$cached = $cache->get('my_module:data:key1');
$this->assertNotFalse($cached);
$this->assertSame($first, $cached->data);
}Functional Tests
Functional tests extend \Drupal\Tests\BrowserTestBase. They boot a full Drupal stack, install modules, create users, and make HTTP requests via an internal BrowserKit client.
Page Access Test
<?php
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests route access and page rendering.
*
* @group my_module
*/
class EventPageTest extends BrowserTestBase {
protected static $modules = ['my_module'];
protected $defaultTheme = 'stark';
public function test_admin_can_access_events_page(): void {
$admin = $this->drupalCreateUser(['administer my_module events']);
$this->drupalLogin($admin);
$this->drupalGet('/admin/content/events');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Events');
}
public function test_anonymous_user_is_denied(): void {
$this->drupalGet('/admin/content/events');
$this->assertSession()->statusCodeEquals(403);
}
}Form Submission Test
<?php
public function test_creates_event_via_form(): void {
$user = $this->drupalCreateUser(['create my_module event']);
$this->drupalLogin($user);
$this->drupalGet('/events/add');
$this->assertSession()->statusCodeEquals(200);
$this->submitForm([
'title[0][value]' => 'My Test Event',
'field_date[0][value][date]' => '2026-09-01',
], 'Save');
$this->assertSession()->pageTextContains('Event My Test Event has been created.');
$this->assertSession()->addressMatches('/\/events\/\d+/');
}
public function test_required_fields_show_validation_errors(): void {
$user = $this->drupalCreateUser(['create my_module event']);
$this->drupalLogin($user);
$this->drupalGet('/events/add');
$this->submitForm([], 'Save');
$this->assertSession()->pageTextContains('Title field is required.');
}Testing with Mocked Services
For functional tests that need to mock an external HTTP service, use a custom service override:
<?php
protected function setUp(): void {
parent::setUp();
// Replace the HTTP client with a mock
$mock = $this->getMockBuilder(\GuzzleHttp\ClientInterface::class)->getMock();
$mock->method('request')->willReturn(
new \GuzzleHttp\Psr7\Response(200, [], json_encode(['status' => 'ok']))
);
$this->container->set('http_client', $mock);
}Testing Hooks and Event Subscribers
Custom hooks and Symfony event subscribers can be tested in Kernel tests:
<?php
public function test_entity_insert_hook_fires(): void {
$called = FALSE;
// Override the hook implementation via a mock service
$this->container->set('my_module.post_save_handler', new class {
public bool $called = false;
public function handle($entity): void {
$this->called = true;
}
});
$storage = $this->container
->get('entity_type.manager')
->getStorage('node');
$node = $storage->create([
'type' => 'article',
'title' => 'Test',
]);
$node->save();
$handler = $this->container->get('my_module.post_save_handler');
$this->assertTrue($handler->called);
}CI Configuration
Run all three test tiers in CI with proper environment:
# .github/workflows/drupal-tests.yml
name: Drupal Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: drupal_test
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo_mysql, gd
- run: composer install --prefer-dist --no-progress
- name: Run Unit Tests
run: ./vendor/bin/phpunit -c core/phpunit.xml.dist modules/custom/
- name: Start Drupal server
run: php -S localhost:8080 -t web/ &
- name: Run Functional Tests
env:
SIMPLETEST_BASE_URL: http://localhost:8080
SIMPLETEST_DB: mysql://root:root@127.0.0.1/drupal_test
run: ./vendor/bin/phpunit -c core/phpunit.xml.dist modules/custom/ --filter=FunctionalTest Coverage Strategy
For a typical custom Drupal module:
- Unit tests — services with injected dependencies, utility classes, value objects
- Kernel tests — entity CRUD, config import, database queries, cache behavior, hooks
- Functional tests — route access (one per permission level), form submission (happy path + validation), admin UI flows
Aim for 80%+ of tests in the Unit/Kernel tiers. Functional tests are 10–50× slower — keep them focused on behavior that requires the full HTTP stack.
Beyond PHPUnit
PHPUnit covers your PHP logic. For end-to-end validation of a Drupal site's user interface — content creation flows, editorial workflows, public-facing pages — browser automation tools complement PHPUnit. HelpMeTest runs plain-English test scenarios against live Drupal sites, covering the user journeys that PHPUnit functional tests can't easily replicate: multi-step wizards, JavaScript interactions, and visual layout verification across viewports.
The full Drupal testing stack: PHPUnit for logic, kernel tests for integration, functional tests for HTTP behavior, and browser-level monitoring for the live site.