From drupal-tdd
Test-driven development for Drupal modules: red/green/refactor cadence, outside-in test ordering, test-first feature growth. Use when the user says "TDD this", "write the test first", "red-green-refactor", or wants a failing test next rather than code next. Pairs with drupal-testing (which covers base classes and boilerplate) -- this skill answers "in what order do I grow the code?".
How this skill is triggered — by the user, by Claude, or both
Slash command
/drupal-tdd:drupal-tddThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Test-driven development in Drupal. You write a failing test, watch it fail for the reason you expect, write the simplest code that makes it pass, then refactor with the tests as a safety net.
Test-driven development in Drupal. You write a failing test, watch it fail for the reason you expect, write the simplest code that makes it pass, then refactor with the tests as a safety net.
This skill covers the cadence and discipline. For base-class reference (UnitTestCase vs KernelTestBase vs BrowserTestBase, assertion API, setUp patterns), use the companion drupal-testing skill.
Based on Oliver Davies' "Test-Driven Drupal" book — an outside-in approach where you start with a Functional test for user-visible behavior, drop to Kernel tests when the thing you need to assert is awkward to express through a browser, and reach for Unit tests only at the edges.
Only two things:
Everything else below — per-assertion commits, batched cycles, the exact shape of "simplest green" — is a spectrum, not a compliance checklist. See "Cycle granularity" below before using this skill to audit a plan or PR.
The classic rhythm:
This rhythm only works if you trust the tests. After you get a test to pass, break it on purpose (change the expected value, delete the new code) and confirm it goes red. Then put it back. Confidence comes from tests that fail when they should.
"One red→green→refactor cycle at a time with a commit between each" is the tightest expression of TDD and produces the cleanest git history. It's not the only legitimate form.
| Style | What it looks like | When it fits |
|---|---|---|
| Strict per-cycle | One assertion → minimal code → commit → repeat. 10–15 tiny commits per feature. | Unfamiliar problem, risky refactor, paired programming, learning a new codebase — whenever you want maximum safety and fine-grained revert points. |
| Batched cycles | Write several related failing tests as a cohesive set, then implement until all green. Each set is one commit (or a small handful). | Small, well-understood features where the assertions clearly form one spec. "TDD planning ahead." |
| Task-level test-first | Write the tests for a task, watch them fail, implement the task, commit once. | Feature slices in a larger plan (e.g. phase/task workflows) where the plan is the unit of review. |
| Test-after | Implement first, write tests to cover existing code. | Anti-pattern. Not TDD. Tests won't shape the design and tend to mirror the code rather than the contract. |
The first three are all TDD. The line is drawn at test-after, not at commit granularity.
If you're auditing a plan or PR with this skill: the substantive checks are (a) tests exist before the implementation is written, (b) tests were run and observed red for the right reason, (c) outside-in ordering. Pushing a plan from "batched" to "strict per-cycle" multiplies commits without changing shipped code — only do it when the team wants the finer git history for its own sake, or when the feature is risky enough to benefit from tighter revert points.
Per-assertion commits are a practice, not a principle. Don't mandate a structural re-plan for a feature that's already test-first and outside-in — that's rearranging chairs around the wins.
Start where the user sees the feature, drop to lower levels only when the assertion is awkward at the higher level.
Functional → Kernel → Unit
(browser) (services) (pure PHP + mocks)
$mock->willReturn(...) setup, you're testing the mock, not the code — prefer a Kernel test instead.WHY outside-in: a Functional test exercises the real stack — routing, permissions, controller DI, theme, render pipeline. If it passes, the feature works. Lower-level tests narrow in on behavior that's hard to pin down from outside, but they can all pass while the integrated feature is broken. Functional tests catch wiring mistakes the inner tests can't.
When a test goes red, your job is to get it green with minimum code — not the final design. You'll refactor next.
Example — "the /blog page returns 200." The simplest green: a route that points at an empty controller returning []. Not a repository. Not a query. An empty array.
Example — "posts are visible on /blog." Now empty [] fails. The simplest green: load all nodes and render their titles. Not filtering, not ordering, not a repository. Just the loop.
Example — "only published posts are shown." Now the loop is too broad. Add 'status' => TRUE to the query. Still in the controller; still no repository.
Refactoring to a PostNodeRepository comes after the behavior is green — and you only refactor when a new test makes the current shape awkward (e.g. "posts are ordered by created date" is easier to pin down on a repository method than through the browser).
Trying to write the "right" design up front is the opposite of TDD. Let the tests pull the design out of you.
Caveat for small, well-understood features: when the final shape is obvious and cheap (e.g. a 3-card block with a neutral default), jumping straight to the final implementation after a batched red set is fine. "Simplest thing first" guards against over-designing a repository/abstraction you don't yet need — not against writing the obvious 10-line controller in one pass. Use judgment; don't fragment trivial implementations into artificial red-green steps just to perform the ceremony.
When you're asserting a specific order, count, or value, set up the test data so the naive implementation fails. Then fix it.
// Create posts deliberately in the WRONG order so the default
// load order can't accidentally pass the test.
PostBuilder::create()->setCreatedDate('-1 week')->setTitle('Post one')->getPost();
PostBuilder::create()->setCreatedDate('-8 days')->setTitle('Post two')->getPost();
PostBuilder::create()->setCreatedDate('yesterday')->setTitle('Post three')->getPost();
// Expect them sorted by created date ascending — 'Post two' first.
self::assertNodeTitlesAreSame(['Post two', 'Post one', 'Post three'], $nodes);
If you create the data already sorted and the code happens to preserve insertion order, the test passes without the sort logic. You proved nothing. Shuffle the arrangement so the expected output only occurs if the code actually sorts.
Treat this as a recipe. Each step produces a commit.
/blog and see post titles").[]. Run tests. Red changes to "expected text not found."Keep the feedback loop tight:
# Run one test method while iterating
vendor/bin/phpunit --filter testBlogPage
# Stop on first failure so you don't wade through noise
vendor/bin/phpunit --stop-on-failure
# Human-readable output grouped by class
vendor/bin/phpunit --testdox
# Run only the file you're working on
vendor/bin/phpunit web/modules/custom/atdc/tests/src/Functional/BlogPageTest.php
Once the feature is green, re-run the whole module's suite to confirm you didn't regress anything.
When a Functional test fails with a status code you don't expect, dump the response before guessing:
var_dump($this->getSession()->getPage()->getContent());
This prints the rendered HTML (including Drupal error messages) so you can see why the page returned 500 or 403. Remove the var_dump before committing.
For Kernel tests, if you see "Table … not found," the schema isn't installed — installSchema() / installEntitySchema() is missing. If you see "Service not found," a module is missing from $modules.
Both can often cover the same behavior. Pick the lower level only when the higher level would be painful.
| What you're asserting | Prefer |
|---|---|
| Page exists at URL X / returns status N | Functional |
| Specific text is visible on the page | Functional |
| Unauthorized user is blocked | Functional |
| Form submission produces an entity | Functional |
| Ordering of items in a collection | Kernel |
| Repository returns only published / of bundle X | Kernel |
| Exact shape of a service method's return value | Kernel |
| Pure transformation with no Drupal deps | Unit |
| Guard that throws InvalidArgumentException | Unit |
If you're unsure, start Functional. You can always drop down later.
Duplicate coverage is usually wasted effort. If the functional test already proves "unpublished posts don't appear," a kernel test for the same behavior adds nothing — you can't make the kernel test fail without making the functional test fail too. Pick the level that uniquely pins down the behavior.
Inside the third or fourth test you'll feel the pain of repeating createNode(['type' => 'post', 'title' => …, 'created' => (new DrupalDateTime(…))->getTimestamp(), 'status' => …]). That's the signal to extract a Builder.
PostBuilder::create()
->setTitle('Post one')
->setCreatedDate('-1 week')
->isPublished()
->setTags(['Drupal', 'PHP'])
->getPost();
See references/test-data-builders.md for the full PostBuilder pattern (class layout, chaining via return $this, guarding unset fields).
When the same assertion block appears in multiple tests, extract it into a private static method with a name that describes what is being checked. Tests read like prose instead of array-map gymnastics.
self::assertNodeTitlesAreSame(
['Post two', 'Post one', 'Post three'],
$nodes,
);
See references/custom-assertions.md for the extraction pattern.
Fresh Drupal installs created per test don't have your fields or content types. You have two options:
$this->installConfig(['my_module'])) if the config ships with your real module.my_module/modules/my_module_test) whose config/install/ holds fields, content types, and vocabularies the tests need. It stays invisible to site admins but can be listed in $modules inside your test class.See references/test-modules-and-config.md for the hidden-submodule layout, exporting configs via drush config:export, and resolving "Base table or view not found" errors.
If the project has no phpunit.xml.dist, see references/phpunit-setup.md for the minimum config (bootstrap path, SIMPLETEST_BASE_URL, SQLite SIMPLETEST_DB, test suite directory).
For a full walkthrough — from empty project to a blog module with Functional, Kernel, and Unit tests, driven test-first — see references/worked-example-blog.md. It mirrors Oliver Davies' book step by step: 200-on-/blog, posts-visible, ordering, published-only, bundle-filter, tags-with-test-config, Unit + mocks.
references/worked-example-blog.md — end-to-end TDD example (start here if unfamiliar with the flow)references/test-data-builders.md — PostBuilder pattern, chaining, null-guardsreferences/custom-assertions.md — extracting private static assertion methodsreferences/test-modules-and-config.md — hidden sub-modules, config/install, schema installationreferences/phpunit-setup.md — phpunit.xml.dist from scratchReal anti-patterns — not stylistic preferences.
$mock->willReturn(...) is an echo chamber. Either test real collaborators (Kernel) or assert something the code did with the mock, not something the mock returned to itself.expects($this->once())) on internals. Couples the test to implementation. Refactor becomes impossible without rewriting tests. Only assert call counts when the number of calls is the contract under test (rare — think caching or rate-limiting).testBlogPageWorks() that sets up 15 things and has 20 assertions tells you nothing when it goes red. Split into testBlogPageIsReachable, testPostsAreVisible, etc. This is about method granularity, not commit granularity — two different things.setUp() or a Builder — don't cross-contaminate.Not anti-patterns (common misreadings):
Provides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
npx claudepluginhub proofoftom/drupal-skills --plugin drupal-tdd