TDD Workflow: RED-GREEN-REFACTOR
When to Use
Applying the Test-Driven Development cycle to Drupal module development. Use this workflow when building new features, refactoring existing code, or fixing bugs where the expected behavior can be specified upfront.
Decision
| If you're working on... | Smallest tier that proves it | Notes |
|---|---|---|
| Service business logic (calculations, transformations) | Unit or Kernel | Fast, behavior well-defined |
| Plugin implementations (blocks, field formatters) | Kernel | Clear input/output contract |
| Access control logic | Kernel | Security-critical, explicit pass/fail |
| Entity/form validation | Kernel | Test the rule, not the form chrome |
| Admin UI configuration forms | Kernel for the custom logic; Functional only for a submit path you own | Test the 10% that is yours; framework CRUD is not your behavior |
| Theme templates/preprocessing | Kernel for preprocess logic; templates are outer verification (visual regression) | Markup is not a promised behavior; a preprocess return value is |
| Migration plugins | Unit for transforms | The full pipeline is outer verification |
Pattern
The RED-GREEN-REFACTOR cycle -- TDD's core rhythm:
1. RED -> Write a failing test (5-10 minutes)
Assert expected behavior before implementation exists
2. GREEN -> Write minimal code to pass (5-15 minutes)
Don't optimize, don't add features -- just make it green
3. REFACTOR -> Clean up while tests stay green (5-10 minutes)
Extract methods, improve names, remove duplication
4. REPEAT -> Next requirement or edge case
Cycle rhythm, not a quota: a healthy cycle runs in minutes, so 3-6 per hour for simple features and 1-2 for complex integrations is what the pace tends to look like. It is a description, not a target. Do not write extra tests to reach it -- the number of cycles is set by the number of behaviors you were asked to build.
Step 1: RED -- Write a failing test:
namespace Drupal\Tests\my_module\Kernel;
use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
#[RunTestsInSeparateProcesses]
class DiscountCalculatorTest extends KernelTestBase {
protected static $modules = ['my_module'];
public function testTenPercentDiscountApplied(): void {
$calculator = $this->container->get('my_module.discount_calculator');
// This WILL fail -- service doesn't exist yet
$result = $calculator->calculate(100, 'SAVE10');
$this->assertEquals(90, $result);
}
}
Every Kernel, Functional and FunctionalJavascript class carries #[RunTestsInSeparateProcesses]. Core's KernelTestBase and BrowserTestBase each trigger a deprecation when it is absent — "Kernel test classes must specify the #[RunTestsInSeparateProcesses] attribute, not doing so is deprecated in drupal:11.3.0 and will throw an exception in drupal:12.0.0" (change record), with the matching wording for Functional and FunctionalJavascript. Unit tests have no such check, so UnitTestCase subclasses do not carry it.
The attribute is not inherited. PHPUnit reads it off the concrete class only, so a project's own test base class cannot carry it for its children — decorate every concrete class, which is what core does.
Run test -> RED (failure expected). Output: "Service 'my_module.discount_calculator' not found."
Step 2: GREEN -- Minimal implementation:
// my_module.services.yml
services:
my_module.discount_calculator:
class: Drupal\my_module\Service\DiscountCalculator
// src/Service/DiscountCalculator.php
namespace Drupal\my_module\Service;
class DiscountCalculator {
public function calculate($price, $code) {
// Hardcoded to pass THIS test only
if ($code === 'SAVE10') {
return $price * 0.9;
}
return $price;
}
}
Run test -> GREEN (test passes). Don't add validation, error handling, or other codes yet.
Step 3: REFACTOR -- Clean up:
// Add type hints, extract constant, improve readability
class DiscountCalculator {
private const DISCOUNT_RATES = [
'SAVE10' => 0.10,
];
public function calculate(float $price, string $code): float {
$discount_rate = self::DISCOUNT_RATES[$code] ?? 0;
return $price * (1 - $discount_rate);
}
}
Run test -> Still GREEN. Now add next test for different discount code, repeat cycle.
Step 4: REPEAT -- Next requirement:
public function testTwentyPercentDiscountApplied(): void {
$calculator = $this->container->get('my_module.discount_calculator');
$result = $calculator->calculate(100, 'SAVE20');
$this->assertEquals(80, $result);
}
RED -> add 'SAVE20' to DISCOUNT_RATES -> GREEN -> refactor if needed.
Test Naming Conventions for Drupal
Test method naming: test{Action}{Condition}{ExpectedOutcome}
- testUnpublishedNodeNotVisibleToAnonymous() -- readable as sentence
- testNodeCreation() -- acceptable for simple cases
- testSave() -- too vague, AVOID
Group attributes: Use #[Group('module_name')] for filtering
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
#[Group('my_module')]
#[Group('commerce')]
#[RunTestsInSeparateProcesses]
class DiscountCalculatorTest extends KernelTestBase {
// ...
}
Covers attributes: Document what code is tested (optional but useful for coverage)
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
#[CoversClass(DiscountCalculator::class)]
#[RunTestsInSeparateProcesses]
class DiscountCalculatorTest extends KernelTestBase {
// ...
}
When the behavior is not yours
Some Drupal work has no behavior to specify: a config form that is 100% framework CRUD, a Twig change with no preprocess logic, a one-off Drush script that runs once during a migration. That work has no failing test because there is no promise to state, not because TDD is low value. The moment the change adds a branch, a return value, or an access decision, that behavior gets its RED. "Too simple to test" is not a reason -- see the universal guide's When Not to Write a Test.
Spikes and prototypes are exploration. Throw them away; the real change starts at RED.
Common Mistakes
- Writing tests after implementation -- loses TDD benefits (design feedback, confidence)
- GREEN phase does too much -- adds features not covered by test, defeats safety net
- Skipping REFACTOR -- technical debt accumulates, code becomes unmaintainable
- Tests too large (testing entire feature in one test) -- hard to debug when RED, slow feedback
- Not running tests frequently -- accumulate failures, lose the RED-GREEN rhythm
- Perfectionism in GREEN phase -- spend 30 minutes optimizing before REFACTOR (just make it pass, then improve)
- Editing a test in GREEN to make it pass -- GREEN is for production code only; if the test is wrong, return to RED and change it deliberately
- Growing assertions on tests you did not write while reviewing -- a reviewer emits findings, not commits
See Also
- Test Type Decision Matrix
- PHPUnit Configuration
- Universal TDD guide: TDD & Spec-Driven Development
- Red, Green, Refactor | Codecademy
- The Cycles of TDD - Uncle Bob
- Related: TDD & Spec-Driven Development — Changing Existing Tests -- who may change a test, and when
- Related: TDD & Spec-Driven Development — Fixing Bugs with TDD