🇬🇧 My first Drupal Kernel test: baby steps into PHPUnit with ECK and submodules

Baby steps.
That’s the only way to actually learn something new.
I had been writing Drupal for years. I understood modules, hooks, services, entities. But PHPUnit Kernel tests? I had always delegated or just skipped them. No phpunit.xml configured, no idea what command to run, no mental model of how Drupal’s test bootstrap actually worked.
That changed this week. Here’s exactly what I built — and more importantly, why it’s structured the way it is.
Step 1: The simplest possible test
Before anything complex, I needed to answer one question: can I actually run a PHPUnit Kernel test at all?
So I created the most ridiculous test imaginable:
public function testRoutesHaveNoCacheOption(): void {
$this->assertTrue(TRUE);
}
That’s it. A test that asserts that TRUE is TRUE. It will always pass. It cannot fail. And that’s the point — if this fails, the problem is in the setup, not in any logic.
The command to run it:
ddev exec bash -c 'SIMPLETEST_DB="mysql://db:db@db/db" ./vendor/bin/phpunit -c web/core \
web/modules/custom/eck_opportunities/tests/src/Kernel/EckOpportunitiesSimpleKernelTest.php'

Let me break down every piece of this because each word matters.
ddev exec bash -c — Run a shell command inside the DDEV web container. Not on your host machine. Inside the container where PHP, Drupal, and Composer actually live.
SIMPLETEST_DB="mysql://db:db@db/db" — The test runner needs to know where the test database is. This environment variable tells it: MySQL, user db, password db, host db (the DDEV MySQL service), database db. Without this, PHPUnit doesn’t know where to bootstrap Drupal.
./vendor/bin/phpunit — The PHPUnit binary from the project’s Composer vendor directory.
-c web/core — Use the phpunit.xml configuration bundled with Drupal core. This file sets up the test environment correctly: autoloading, test listeners, bootstrap sequence. You don’t write this yourself — Drupal core provides it.
The path to the test file — Tell PHPUnit exactly which class to run.
When that first assertTrue(TRUE) came back green, it was genuinely exciting. Not because the test was meaningful — it wasn’t — but because the entire pipeline worked. Environment → Bootstrap → Drupal → PHPUnit → Result.
Step 2: A real test with a real ECK entity
The next step was something with actual logic. I built a test that:
- Creates a
member_carECK entity withfield_titleset to"1234567" - Saves it
- Asks: is the length of that title exactly 7?
public function testMemberCarTitleLength(): void {
$storage = \Drupal::entityTypeManager()->getStorage('opportunities');
$member_car = $storage->create([
'type' => 'member_car',
'field_title' => '1234567',
]);
$member_car->save();
$this->assertEquals(7, strlen($member_car->get('field_title')->value));
}
Simple. But to make this work, Drupal needs to know what opportunities is. What member_car is. What field_title is. The ECK entity type, the bundle, the field storage, the field instance — all of it needs to be defined before the test runs.
This is where the structure became interesting.
Step 3: The submodule pattern
Here’s the architecture decision that I think scales to real projects.
Instead of defining test fixtures inline in PHP, or polluting your main module’s config, you create a test submodule. A tiny Drupal module, only enabled during tests. It lives inside the module it supports:
eck_opportunities/
└── tests/
└── modules/
└── custom/
└── eck_opportunities_test/
├── config/
│ └── install/
│ ├── eck.eck_entity_type.opportunities.yml
│ ├── eck.eck_type.opportunities.member_car.yml
│ ├── field.storage.opportunities.field_title.yml
│ ├── field.field.opportunities.member_car.field_title.yml
│ ├── core.entity_view_mode.opportunities.member_car.default.yml
│ └── core.entity_view_display.opportunities.member_car.default.yml
├── tests/
│ └── src/
│ └── Kernel/
│ └── MemberCarTitleValidationTest.php
└── eck_opportunities_test.info.yml
The config/install/ directory contains real Drupal config YAML files. When the test runs and calls $this->installConfig(['eck_opportunities_test']), Drupal installs all of that config into the test database — the ECK entity type, the bundle, the field storage, the field instance, the view modes and displays.
The test then has a fully operational ECK entity to work against. No mocks. No stubs. Real Drupal, real ECK, real field API.
In the test class, the setup looks like this:
protected static $modules = [
'system',
'field',
'text',
'eck',
'eck_opportunities_test',
];
protected function setUp(): void {
parent::setUp();
$this->installConfig(['eck_opportunities_test']);
}
$modules — Drupal modules to enable, in order of dependencies. system and field are Drupal core. eck is the ECK contrib module. eck_opportunities_test is our test submodule.
$this->installConfig() — Actually installs the YAML config from that module’s config/install/ directory into the temporary test database. Without this step, the entity type and field definitions don’t exist when the test tries to create an entity.
Why this pattern matters at scale
On small projects, it’s tempting to hardcode stuff in the test, use mocks, move on.
On large projects — enterprise Drupal platforms serving thousands of users — this pattern is how you keep tests maintainable.
The config YAML files are the source of truth. If the entity type changes, you update the config. If you need a new field, you add it to config/install/. The test itself stays focused on behavior, not on setup boilerplate.
And because the test submodule is a real Drupal module, it can declare its own dependencies, ship its own test data, and be independently enabled in CI. It scales.
The mental model I keep coming back to: the test submodule is a fixture, not a hack. It’s the controlled environment you test against — carefully maintained as the code it tests.
The command that runs the real test
ddev exec bash -c 'SIMPLETEST_DB="mysql://db:db@db/db" ./vendor/bin/phpunit -c web/core \
web/modules/custom/eck_opportunities/tests/modules/custom/eck_opportunities_test/tests/src/Kernel/MemberCarTitleValidationTest.php'

Same structure as before. The only difference is the path — now pointing to the test inside the submodule, not the parent module.
Green. ✅
What I learned
I’ve been writing Drupal for a long time. I knew the concepts. But there’s a difference between knowing something and having run it yourself.
The baby steps matter. Starting with assertTrue(TRUE) before writing any real assertion. Understanding every word of the shell command before running it. Building the submodule structure before worrying about what it tests.
This is the foundation. Kernel tests give you real entities, real Drupal — but contained, isolated, and fast. No browser. No full Drupal install. Just the parts you need, bootstrapped and torn down in seconds.
The same pattern that made this test work is the one I’d use on platforms serving millions of users. You just add more modules, more config, more assertions.
Baby steps. Then scale.