In the last lesson, unit tests proved fast but limited — no database, no entities, no real Drupal underneath them. That's fine for testing a plain PHP class like an addition function, but useless the moment you need to test something that actually creates a node or checks a user's permissions. This lesson covers the middle tier: the kernel test.
What you'll learn in this lesson
- What a kernel test actually boots, and why it's slower than a unit test but far faster than a full browser test
- The difference between a module being "loaded" versus "installed" in a kernel test
- How to manually install schema, entity tables, and configuration in
setUp() - How to create test users and nodes using Drupal's built-in creation traits
Where kernel tests sit
Think of Drupal's three test types as a spectrum. Unit tests, on one end, run in plain PHP with nothing booted. Functional tests, on the other end (which we'll cover in the next lesson), spin up an entire simulated browser session hitting real HTTP routes. Kernel tests sit in between: they boot a real, working Drupal kernel — the service container, the entity system, a real database — but skip the HTTP layer entirely. You get real entities and real services to test against, without paying for a full page request every time.
We'll study ExampleFixtureManagementTest from the testing_example module, which demonstrates something every kernel test eventually needs to do: build up a small, consistent set of test data (a "fixture") — a user, a node — and then assert things about it.
The source file
Path: modules/testing_example/tests/src/Kernel/ExampleFixtureManagementTest.php
<?php
namespace Drupal\Tests\testing_example\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* Demonstrate manipulating fixture data in a kernel test.
*
* Kernel tests are used where APIs will be invoked, but the results of an HTTP
* request do not need to be examined.
*
* This example will show some techniques for manipulating a fixture and then
* testing the result. A 'fixture' is some data you set up in a consistent way,
* so that you can run tests against them.
*
* @group testing_example
* @group examples
*
* @ingroup testing_example
*/
class ExampleFixtureManagementTest extends KernelTestBase {
// Additional traits can be imported for more prebuilt tools in the tests.
use NodeCreationTrait;
use UserCreationTrait;
/**
* {@inheritdoc}
*
* Any modules added here will be loaded, along with anything in $modules in
* parent classes.
*
* These modules are not installed, but their services and hooks are
* available.
*
* @var string[]
*/
protected static $modules = ['user', 'system', 'field', 'node', 'text', 'filter'];
/**
* An 'owner' user object.
*
* @var \Drupal\user\UserInterface
*/
protected $owner;
/**
* {@inheritdoc}
*
* Use setUp() to do anything that is common to all the tests in this class.
*
* Group tests in a class so they can take advantage of setUp() activities
* as much as possible.
*
* In a Kernel test, setUp() can be responsible for creating any schema or
* database configuration which must exist for the test.
*/
protected function setUp(): void {
parent::setUp();
// Since kernel tests do not install modules, we have to install whatever
// schema and config we expect to be present in those modules.
//
// Figuring out what schema and EntitySchema and config to install is not
// always easy. Use core kernel tests for examples. The baseline is that you
// have to install everything in the database that is needed.
//
// Sequences table is prerequisite of the 'node' schema.
$this->installSchema('system', ['sequences']);
// Install *module* schema for node/user modules.
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
// Install *entity* schema for the node entity.
$this->installEntitySchema('node');
$this->installEntitySchema('user');
// Install any config provided by the enabled.
$this->installConfig(['field', 'node', 'text', 'filter', 'user']);
// Finally, create an owner account.
$this->owner = $this->createUser([], 'test_user');
}
/**
* Create a node by using createNode() from NodeCreationTrait.
*/
public function testNodeCreation() {
// Unless there's a specific reason to do so, strings in tests should not be
// translated with t().
$nodeTitle = 'Test Node!';
/** @var \Drupal\node\NodeInterface $node */
$node = $this->createNode([
'title' => $nodeTitle,
'type' => 'page',
'uid' => $this->owner->id(),
]);
// Assert that the node we created has the title we expect.
$this->assertEquals($nodeTitle, $node->getTitle());
}
/**
* Create a user account using createUser() from the UserCreation trait.
*/
public function testUserCreation() {
// Create a user account'.
$account = $this->createUser([], 'extra_user');
// Assert that this user account exists.
$this->assertEquals('extra_user', $account->getAccountName());
// Assert that the logged-in account is not the one stored in $account.
$this->assertNotEquals($this->owner->getAccountName(), $account->getAccountName());
}
}
How it works
Namespace and file location
The namespace Drupal\Tests\testing_example\Kernel follows the same PSR-4 convention you saw for unit tests, just with Kernel in place of Unit. Kernel test classes live under tests/src/Kernel/ inside the module. That single word in the namespace is what tells the test runner "boot the kernel for this one."
Extending KernelTestBase
KernelTestBase (from Drupal\KernelTests) boots a minimal but genuinely real Drupal kernel, backed by a real database (SQLite in-memory by default for speed). That gets you the service container, the entity type manager, and a working database connection — none of which exist in a unit test. The cost is real but modest: kernel tests run in a fraction of a second each, still far faster than a full functional test.
Importing creation traits
Two use statements inside the class body pull in reusable helpers:
NodeCreationTraitprovidescreateNode()— programmatically creates and saves a node with sensible defaults, so your test only has to specify the fields it actually cares about.UserCreationTraitprovidescreateUser()— creates and saves a user account, optionally with specific permissions.
Composing behavior from traits like this keeps test classes flat and reusable instead of building deep, fragile inheritance chains.
The $modules property: loaded is not installed
protected static $modules = ['user', 'system', 'field', 'node', 'text', 'filter'];
$modules makes its services and hook implementations available, but it does not create its database tables or import its configuration. That's entirely your job in setUp(). Forget a module here and services silently aren't there when you need them; get the schema/config installation wrong and you'll see "table does not exist" errors that don't obviously point back to this list.The shared $owner fixture
protected $owner;
A protected class property holding a user account shared by every test method in the class. Populating shared fixtures once in setUp() and storing them on the object (rather than recreating them inside every test method) keeps tests consistent and avoids duplicated setup code.
setUp(): building the database by hand
setUp() runs before every individual test method, and parent::setUp() must always be the first line so KernelTestBase can do its own bootstrapping first. From there, four distinct installation steps build up exactly the environment this test needs:
installSchema() — raw, hook-defined tables
$this->installSchema('system', ['sequences']);
$this->installSchema('node', ['node_access']);
$this->installSchema('user', ['users_data']);
installSchema($module, $tables) creates specific database tables defined in a module's hook_schema() — non-entity, auxiliary tables. sequences is a prerequisite for auto-incrementing entity IDs; node_access stores node grant records; users_data stores arbitrary per-user key/value data. You have to know, ahead of time, which of these your code path actually touches — reading core's own kernel tests for the modules you depend on is the standard way to figure this out.
installEntitySchema() — tables derived from entity definitions
$this->installEntitySchema('node');
$this->installEntitySchema('user');
This is a different mechanism from installSchema(): instead of reading a static hook_schema() array, it generates and creates every table an entity type needs (base table, revision table, field data tables) directly from that entity type's and its fields' definitions. Entity tables are dynamic, so they need their own installation path.
installConfig() — default configuration from config/install/
$this->installConfig(['field', 'node', 'text', 'filter', 'user']);
This imports each listed module's default shipped configuration — the YAML files under its config/install/ directory. Field storage definitions, text format settings, and content type definitions all live here, and the entity system depends on them being present before you can reliably create content.
Creating the fixture user
$this->owner = $this->createUser([], 'test_user');
With all the schema and config now in place, a user named test_user is created and stashed in $this->owner for every test method to use. The empty array means "no specific permissions requested" — just a plain authenticated user.
testNodeCreation() — creating and asserting a node
public function testNodeCreation() {
$nodeTitle = 'Test Node!';
$node = $this->createNode([
'title' => $nodeTitle,
'type' => 'page',
'uid' => $this->owner->id(),
]);
$this->assertEquals($nodeTitle, $node->getTitle());
}
createNode() saves a full node entity and hands back the saved object, letting you assert against it directly — here, that the title round-tripped through the database correctly. Notice the title string is plain text, not wrapped in t(): test strings generally shouldn't be translated, since translation adds overhead and can make output unpredictable across environments.
testUserCreation() — two assertion styles in one test
public function testUserCreation() {
$account = $this->createUser([], 'extra_user');
$this->assertEquals('extra_user', $account->getAccountName());
$this->assertNotEquals($this->owner->getAccountName(), $account->getAccountName());
}
Two different assertions doing two different jobs: assertEquals() confirms the new account actually saved with the username you asked for, and assertNotEquals() confirms this new account is genuinely a distinct entity from $this->owner — guarding against the class of bug where two variables accidentally end up pointing at the same object.
Annotation groups
* @group testing_example
* @group examples
PHPUnit's @group annotations let you filter which tests actually run. Running phpunit --group testing_example executes only tests tagged with that specific group, while the broader examples group covers every test across the whole Examples module set — handy for running just this module's suite while you're actively working on it, versus a full regression pass before a release.
See it for yourself
Visit the testing_example module's description page on your DDEV site. It lays out a full reference table of every Drupal test type — unit, kernel, functional, and functional JavaScript — with each one's directory, namespace, and required base class, including Drupal\KernelTests\KernelTestBase for the kernel tests covered in this lesson.
Quick check: if you removed the
installEntitySchema('node')line but kept everything else, what would happen whentestNodeCreation()runs? The node's underlying database tables would never get created, socreateNode()would fail — most likely with a database error about a missing table, not a friendly Drupal-level message. This is exactly the kind of cryptic failure the note above warns about.
Key takeaways
- Kernel tests extend
KernelTestBaseto get a real database and service container without the overhead of a full HTTP stack — ideal for testing entities, services, and API-level logic. - Modules listed in
$modulesare loaded (their services and hooks become available) but not installed — you must explicitly callinstallSchema(),installEntitySchema(), andinstallConfig()yourself insetUp(). installSchema()creates raw, hook-defined tables;installEntitySchema()generates tables from entity type and field definitions — confusing the two is a common source of "table does not exist" errors.- Traits like
NodeCreationTraitandUserCreationTraitprovide reusablecreateNode()/createUser()helpers without deep class inheritance. - Build shared fixtures once in
setUp()and store them as class properties so every test method in the class starts from the same known state. - Avoid wrapping test strings in
t()— it adds unnecessary overhead and can produce inconsistent output depending on the test environment.
Coming up next
Kernel tests get you a real database and real entities, but they still never make an actual HTTP request — no forms get submitted, no pages get rendered through the theme layer, no user actually clicks anything. In the final testing lesson, we'll look at functional tests: Drupal's way of simulating a real visitor clicking through a real browser session, end to end.