instead of just talking about it
Test-Driven Development is often either demonized or praised as a cure-all. The reality in PHP projects is more nuanced: TDD works brilliantly for certain classes of problems and creates unnecessary overhead for others. This article shows where the difference lies, with code, not theory.
Table of Contents
- 1. The promise of TDD, and the reality
- 2. The Red-Green-Refactor cycle explained concretely
- 3. First TDD cycle: price calculation from scratch
- 4. TDD for services with dependencies
- 5. Where TDD really helps
- 6. Where TDD creates overhead
- 7. TDD in Magento projects, pragmatically
- 8. Introducing TDD to a team without dogmatism
- 9. TDD vs. test-after compared
- 10. Summary
- 11. FAQ
1. The promise of TDD, and the reality
Test-Driven Development promises that writing tests before production code leads to better design. This is not an empty claim; the mechanism behind it is real: when you are forced to write a test before a class exists, you have to design that class's interface from the perspective of its user. This tends to produce more focused classes with clearer responsibilities, because you no longer build in more features than the test demands.
The reality in PHP projects often looks different. Developers try TDD, write three tests for a new feature, then run into database access, complex Magento dependencies or external APIs, and give up. The problem does not lie in TDD itself, but in the fact that TDD does not work without testable code. TDD is not a tool you apply to arbitrary code. It is a design feedback system that assumes the architecture enables testability. That is why you first need to understand where TDD works and where it does not.
Another point of frustration: pace. TDD feels slower at first. You write more code per feature, because tests and production code are created in parallel. This overhead is real; it pays off through less debugging, less regression and clearer design. But you only notice that after weeks or months, not after the first sprint.
2. The Red-Green-Refactor cycle explained concretely
The Red-Green-Refactor cycle is the heart of TDD and consists of three strictly separated phases. Red: a test is written that describes the desired behavior, and it fails, because the production code does not exist yet. That is the intended starting point. A test that is immediately green, without production code, usually tests nothing meaningful. Green: just enough production code is written to make the test pass. No more. No premature optimization, no extra features, no "we'll need that later." The only rule: the test turns green.
Refactor: with green tests as a safety net, the code is improved. Duplication is removed, names are clarified, abstractions are introduced. After every refactoring step, the tests run. If a test turns red, a behavior change was introduced, either intentionally, in which case the test must be adjusted, or it was a refactoring mistake. This cycle runs in small steps, typically every two to five minutes.
3. First TDD cycle: price calculation from scratch
A concrete example illustrates the cycle better than any abstract description. Task: a service that calculates the gross price from the net price and the VAT rate. TDD starts with the first test, before the class exists.
<?php
declare(strict_types=1);
namespace Mironsoft\Pricing\Test\Unit\Service;
use Mironsoft\Pricing\Service\TaxCalculator;
use PHPUnit\Framework\TestCase;
final class TaxCalculatorTest extends TestCase
{
private TaxCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new TaxCalculator();
}
/** @test */
public function it_calculates_gross_price_from_net_and_tax_rate(): void
{
// Red: TaxCalculator does not exist yet, test fails with class not found
$gross = $this->calculator->calculateGross(net: 100.00, taxRate: 19.0);
$this->assertEqualsWithDelta(119.00, $gross, 0.001);
}
/** @test */
public function it_handles_zero_tax_rate(): void
{
$gross = $this->calculator->calculateGross(net: 50.00, taxRate: 0.0);
$this->assertEqualsWithDelta(50.00, $gross, 0.001);
}
/** @test */
public function it_throws_for_negative_net_price(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Net price must not be negative');
$this->calculator->calculateGross(net: -10.00, taxRate: 19.0);
}
}
Only after all tests have been written is the class created, with exactly as much code as the tests demand. The result is a class with a clear interface that fulfills exactly the documented requirements and nothing more.
4. TDD for services with dependencies
As soon as services have external dependencies, repositories, APIs, databases, TDD becomes impossible without mocking. The test would work against real infrastructure on every run and would therefore be slow, fragile and environment-dependent. PHPUnit offers two mechanisms with createMock() and createStub(): stubs supply configured return values without behavior verification, mocks additionally verify how dependencies are called.
The TDD approach for services with dependencies: the test first defines which dependencies the service needs and how it interacts with them. This forces a clear separation of business logic and infrastructure. A service that directly contains database queries cannot be meaningfully developed with TDD, the necessary infrastructure gets in the way. Here, TDD enforces the right design: repository pattern, dependency injection, clear interfaces.
<?php
declare(strict_types=1);
namespace Mironsoft\Inventory\Test\Unit\Service;
use Mironsoft\Inventory\Api\StockRepositoryInterface;
use Mironsoft\Inventory\Service\LowStockNotifier;
use PHPUnit\Framework\TestCase;
final class LowStockNotifierTest extends TestCase
{
/** @test */
public function it_triggers_notification_when_stock_falls_below_threshold(): void
{
// Arrange: define dependency behavior before writing the service
$repository = $this->createMock(StockRepositoryInterface::class);
$repository->method('findBelowThreshold')
->with(5) // threshold
->willReturn(['SKU-001', 'SKU-042']);
$mailer = $this->createMock(\Mironsoft\Notification\MailerInterface::class);
$mailer->expects($this->once())
->method('sendLowStockAlert')
->with(['SKU-001', 'SKU-042']);
// Act
$notifier = new LowStockNotifier($repository, $mailer);
$notifier->checkAndNotify(threshold: 5);
}
/** @test */
public function it_does_not_send_notification_when_stock_is_sufficient(): void
{
$repository = $this->createStub(StockRepositoryInterface::class);
$repository->method('findBelowThreshold')->willReturn([]);
$mailer = $this->createMock(\Mironsoft\Notification\MailerInterface::class);
$mailer->expects($this->never())->method('sendLowStockAlert');
$notifier = new LowStockNotifier($repository, $mailer);
$notifier->checkAndNotify(threshold: 5);
}
}
5. Where TDD really helps
TDD delivers its greatest benefit for business logic with clear input-output relationships: price calculations, discount rules, validation logic, state machines, parsers. In all of these cases, the expected output for given inputs can be defined clearly in advance. TDD forces you to name every edge case explicitly: what happens with negative values? With zero? When an empty array is passed in? With TDD you are forced to ask these questions, because the test has to represent them. With test-after, they are often overlooked.
A second strong use case: bug fixes. When a bug is reported, the first step under TDD is to write a test that reproduces the bug. The test is red. Then the bug is fixed, the test turns green. This test stays in the suite permanently and prevents the same bug from reappearing. That is concrete, demonstrable regression prevention, not a theoretical benefit, but a directly measurable effect in the next deployment.
6. Where TDD creates overhead
TDD is not a universal tool. For UI components, database schema migrations and the initial setup of configuration files, it creates more overhead than benefit. That is not a flaw of TDD, but a question of scope. Magento layouts, XML configuration files and deployment scripts are typically not good TDD candidates, their correct behavior depends on integration with the overall system, not isolated logic.
TDD also often does not pay off for exploratory code: if you do not yet know what the solution will look like, it is hard to write meaningful tests. In such phases, prototyping without tests is often faster, with the caveat that the mature code is then consistently tested retroactively or rewritten from scratch in a test-driven way. The rule of thumb: TDD for stable requirements with a clear specification, prototyping for exploration, tests afterward for UI and infrastructure.
7. TDD in Magento projects, pragmatically
Magento has a complex architecture with a deep dependency injection container, event system and plugin mechanisms. That makes TDD harder for Magento-specific components than for plain PHP services. The pragmatic strategy: apply TDD consistently to your own business logic, services, calculator classes, repositories, transformers, and rely on integration tests that work with the container for Magento framework integration.
ViewModels are a particularly good TDD candidate in Magento: they often contain formatting logic, price calculation and data preparation that can be tested fully in isolation. A ViewModel that formats prices, calculates availability or structures product data has clear inputs and outputs, ideal for TDD. Plugin classes, observers and layout modifications, by contrast, are better covered with integration tests.
8. Introducing TDD to a team without dogmatism
Introducing TDD to a team most often fails due to excessive ambition: you announce that from now on every piece of feature code starts with tests, and you run into resistance from developers who are under time pressure and feel like they now need twice as long. The pragmatic starting point: make TDD mandatory for bug fixes first. Every bug gets a reproducing test first. That is a small, concrete change with immediately visible benefit, the test shows that the bug exists, and it prevents its recurrence.
The next step: TDD for new, isolated services and calculator classes. These are typically free of framework dependencies and are ideal for getting started. Adding tests to existing code is often more laborious than developing new code in a test-driven way. Teams that introduce TDD this way, first bug fixes, then new isolated logic, build a culture over time in which tests become a natural practice instead of an imposed burden.
9. TDD vs. test-after compared
Both approaches have their place. The direct comparison helps decide which approach to choose for which task.
| Criterion | TDD (test-first) | Test-after | Recommendation |
|---|---|---|---|
| Design feedback | Direct, poor design shows up immediately | Delayed, design is already fixed | TDD for new services |
| Speed (short term) | Slower due to double writing effort | Faster in the first sprint | Test-after for exploration |
| Edge case coverage | High, tests define edge cases upfront | Lower, often based on gut feeling | TDD for business logic |
| Bug fix regression | Built in, bug first as a test | Possible, but not enforced | TDD for all bug fixes |
| Framework-heavy code | Difficult without infrastructure | Integration tests better suited | Test-after for Magento integration |
Mironsoft
PHP development, TDD coaching and Magento testing
Want to introduce TDD pragmatically in your PHP project?
We help teams introduce Test-Driven Development realistically, without dogmatism, with clear boundaries between TDD candidates and test-after code, and with hands-on coaching directly on production code.
TDD workshop
Hands-on workshop with your production code, Red-Green-Refactor directly on the real project
Test architecture
Clear separation of unit, integration and functional tests for Magento projects
Code review
Review of existing tests for design feedback quality and meaningful test coverage
10. Summary
TDD in PHP is neither a cure-all nor a myth, but a design feedback system that works brilliantly in certain contexts and creates overhead in others. The Red-Green-Refactor cycle leads to clearer interfaces, better edge case coverage and built-in regression protection for bug fixes. For Magento projects, that means: TDD consistently for business logic, services and ViewModels, and pragmatic integration tests for framework integration.
The most important insight: TDD only works on testable code. Anyone who wants to introduce TDD must simultaneously optimize the architecture for testability, dependency injection, repository pattern, clear interfaces. Teams that understand this report, after six months of consistent TDD practice, significantly fewer regression bugs and greater confidence in refactoring. That is the real benefit, measurable, not dogmatic.
TDD in PHP, the essentials at a glance
Red-Green-Refactor
Write a test, let it fail, make it minimally green, refactor. Keep each phase strictly separate.
Best use cases
Business logic, price calculations, validation, bug fixes. Not for UI, migrations or framework integration.
Magento strategy
TDD for services and ViewModels. Integration tests for the DI container and plugin mechanisms. Keep the separation clear.
Team introduction
Start with bug fixes: every bug first as a reproducing test. Then new isolated services. No big-bang approach.