The Test-Driven Development Workflow
Drive design with the red-green-refactor cycle.
The Test-Driven Development Workflow is a free PHP Academy lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
TDD Is a Design Tool
Test-Driven Development is often sold as a testing technique, but its real value is design pressure. By writing the test first you are forced to define an object's public interface, its dependencies, and its contract before you write any implementation. The tests are a byproduct; better design is the product.
Red, Green, Refactor
The cycle has three strict phases:
- Red — write a failing test for the next tiny behavior. It must fail for the right reason.
- Green — write the minimum code to pass, even if ugly.
- Refactor — clean up implementation and tests while staying green.
Discipline matters: never skip Red (you might be testing nothing), never over-build in Green.
Red: Write the Failing Test First
We will build a PriceCalculator that applies a percentage discount. Start with a test for a behavior that does not exist yet. Running it should fail because the class is undefined — that is a valid Red.
<?php
use PHPUnit\Framework\TestCase;
final class PriceCalculatorTest extends TestCase
{
public function test_applies_a_percentage_discount(): void
{
$calc = new PriceCalculator();
// 100 with 10% off -> 90.0
self::assertSame(90.0, $calc->withDiscount(100.0, 10));
}
}
Green: Minimum Code to Pass
Now write the smallest implementation that turns the bar green. Resist adding rounding rules, validation, or currency handling — there is no failing test demanding them yet.
<?php
final class PriceCalculator
{
public function withDiscount(float $amount, float $percent): float
{
return $amount - ($amount * $percent / 100);
}
}
$calc = new PriceCalculator();
var_dump($calc->withDiscount(100.0, 10)); // float(90)
Triangulate to Drive Generality
One test can be satisfied by hard-coding. Triangulation — adding a second, different example — forces the implementation to generalize. Add a case that the trivial solution cannot fake, pushing the real formula out.
<?php
use PHPUnit\Framework\TestCase;
final class PriceCalculatorTest extends TestCase
{
public function test_zero_percent_is_unchanged(): void
{
self::assertSame(50.0, (new PriceCalculator())->withDiscount(50.0, 0));
}
public function test_full_discount_is_free(): void
{
self::assertSame(0.0, (new PriceCalculator())->withDiscount(50.0, 100));
}
}
Drive Edge Cases as Specs
In TDD, a new requirement is a new failing test. Suppose negative discounts are invalid. Write the test that expects an exception first; it fails because nothing throws yet. The test documents the contract.
<?php
use PHPUnit\Framework\TestCase;
final class PriceCalculatorValidationTest extends TestCase
{
public function test_rejects_negative_discount(): void
{
$this->expectException(\InvalidArgumentException::class);
(new PriceCalculator())->withDiscount(100.0, -5);
}
}
Green Again, Then Refactor
Add the guard to pass the new test, then refactor with confidence — the existing tests catch regressions. Notice we also introduced rounding only once a test (or a known requirement) justified it.
<?php
final class PriceCalculator
{
public function withDiscount(float $amount, float $percent): float
{
if ($percent < 0 || $percent > 100) {
throw new \InvalidArgumentException('percent must be 0..100');
}
return round($amount - ($amount * $percent / 100), 2);
}
}
var_dump((new PriceCalculator())->withDiscount(19.99, 15)); // float(16.99)
Data Providers Compress Examples
Once a behavior is stable, collapse many example pairs into a single parameterized test with a data provider. This keeps the Red/Green loop fast and the intent table-readable.
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
final class DiscountTableTest extends TestCase
{
#[DataProvider('cases')]
public function test_discounts(float $amount, float $pct, float $expected): void
{
self::assertSame($expected, (new PriceCalculator())->withDiscount($amount, $pct));
}
public static function cases(): array
{
return [
'no discount' => [100.0, 0, 100.0],
'ten percent' => [100.0, 10, 90.0],
'free' => [100.0, 100, 0.0],
];
}
}
Keep the Loop Fast
TDD only works if the feedback loop is seconds, not minutes. Practical levers:
- Run a single file or filter:
phpunit --filter test_full_discount_is_free. - Use
--testdoxto read tests as a behavior spec. - Keep unit tests free of I/O — no DB, network, or filesystem in the inner loop.
vendor/bin/phpunit --testdox --filter PriceCalculatorRefactor Tests Too
The Refactor phase covers the test suite, not just production code. Remove duplication with helpers and setUp(), name tests by behavior, and delete tests that no longer assert anything meaningful. Tests are code you maintain forever — treat them with the same care.
<?php
use PHPUnit\Framework\TestCase;
final class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calc;
protected function setUp(): void
{
$this->calc = new PriceCalculator(); // shared setup, no duplication
}
public function test_ten_percent(): void
{
self::assertSame(90.0, $this->calc->withDiscount(100.0, 10));
}
}
TDD Shapes Dependencies
Because you write the test first, awkward dependencies become obvious immediately. If a class is hard to instantiate in a test, that is design feedback: inject collaborators instead of new-ing them inside. The discount calculator below becomes testable by accepting its rounding policy, not hard-coding it — TDD pushed that seam into existence.
<?php
interface RoundingPolicy { public function round(float $v): float; }
final class PriceCalculator
{
public function __construct(private RoundingPolicy $rounding) {}
public function withDiscount(float $amount, float $percent): float
{
return $this->rounding->round($amount - ($amount * $percent / 100));
}
}
// Tests inject a deterministic RoundingPolicy; no hidden global behavior.
Quick Check
What is the point of the Red step?
Recap
You practiced TDD as a design discipline:
- Red-Green-Refactor: failing test, minimal code, then cleanup while green.
- Triangulation forces generality; new requirements arrive as new failing tests.
- Data providers compress stable cases;
--filter/--testdoxkeep the loop fast. - Refactor your tests too — they are long-lived code.
Next: flexible test doubles with Mockery.
Frequently asked questions
Is the “The Test-Driven Development Workflow” lesson free?
Yes — the full text of “The Test-Driven Development Workflow” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.
What will I learn in “The Test-Driven Development Workflow”?
Drive design with the red-green-refactor cycle. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start PHP Academy?
No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “The Test-Driven Development Workflow” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this PHP Academy lesson?
Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- The Test-Driven Development Workflow
- Mocking and Stubbing with Mockery
- Integration and Functional Testing
- Mutation Testing with Infection