where production code makes testing easier
The most common experience in PHP projects: tests are hard to write because the production code makes them hard. Dependencies instantiated directly, global state, too many responsibilities crammed into one class. Testability is not a random byproduct, it is the result of concrete design decisions made in the production code.
Table of Contents
- 1. Testability as a design quality, not an afterthought
- 2. Constructor injection: the foundation of testable classes
- 3. Single responsibility: small classes, simple tests
- 4. Pure functions: deterministic logic is instantly testable
- 5. Value objects: immutable data, trivial tests
- 6. Interface segregation: small interfaces, precise mocks
- 7. Testable vs. not testable: production code side by side
- 8. Summary
- 9. FAQ
1. Testability as a design quality, not an afterthought
Testability is not a property you bolt on after the fact. It is the result of design decisions made the moment the code is first written. Code that is hard to test is almost always also hard to maintain, refactor and understand, because difficult testability is a symptom of poorly thought-out dependencies and responsibilities. The reverse is equally true: code that is easy to test typically has clear dependencies, sharp responsibilities and predictable behavior.
The good news: the most important design patterns for testability are the same ones generally considered good PHP design. Constructor injection instead of internal instantiation. Single responsibility instead of god classes. Pure functions for transformation logic. Value objects instead of primitive data types. Interface segregation for precise mocks. Anyone who applies these patterns consistently writes code that is not only easier to test, but also easier to understand, extend and refactor.
2. Constructor injection: the foundation of testable classes
Constructor injection is the most fundamental design pattern for testability. When a class receives all of its dependencies through the constructor, a test can replace exactly those dependencies with mocks, without touching the production code. If a class instead instantiates dependencies internally with new ClassName() or through Magento's ObjectManager, it cannot be tested without a Magento bootstrap. That is the fundamental difference between testable and non-testable code.
PHP 8 makes constructor injection even more compact through constructor property promotion. Instead of manual property declarations, constructor parameters and assignments, everything is written in one line. This significantly reduces boilerplate and makes a class's dependencies visible at a glance. In Magento projects, constructor property promotion fits seamlessly into the DI system: the ObjectManager automatically injects all dependencies based on the type declarations in the constructor.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model;
use Mironsoft\Catalog\Api\Data\ProductInterface;
use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use Mironsoft\Catalog\Api\TaxCalculatorInterface;
use Psr\Log\LoggerInterface;
/**
* Service for enriching product data with calculated fields.
*
* Design: Constructor Injection for all dependencies.
* All dependencies are interfaces, easily mockable in tests.
* No internal 'new', no ObjectManager calls.
*
* Test setup requires only 3 lines of mock creation.
*/
final class ProductEnrichmentService
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly TaxCalculatorInterface $taxCalculator,
private readonly LoggerInterface $logger,
) {}
/**
* Enrich a product with calculated gross price.
*
* @throws \InvalidArgumentException When product has no valid price
*/
public function enrichWithGrossPrice(int $productId): ProductInterface
{
$product = $this->productRepository->getById($productId);
if ($product->getPrice() === null || $product->getPrice() < 0) {
throw new \InvalidArgumentException(
"Product {$productId} has no valid price for gross calculation"
);
}
$gross = $this->taxCalculator->calculateGross(
net: $product->getPrice(),
taxClass: $product->getTaxClassId()
);
$this->logger->debug("Enriched product {$productId}: net={$product->getPrice()}, gross={$gross}");
return $product->setData('gross_price', $gross);
}
}
The decisive difference from non-testable code: all three dependencies (ProductRepositoryInterface, TaxCalculatorInterface, LoggerInterface) are interfaces. In the unit test, all three are replaced with $this->createMock(). The test class needs no Magento bootstrap, no database connection and no real tax system. The test runs in milliseconds and checks exactly the logic of this service: the decision of whether a valid price is present, and the delegation to the tax calculator.
3. Single responsibility: small classes, simple tests
The Single Responsibility Principle (SRP) states that a class should have only one reason to change, and therefore exactly one clearly bounded task. From a testing perspective, SRP has an immediate, practical effect: classes with one task have few dependencies, and tests for them have little setup overhead. A class that loads products, calculates prices, sends emails and writes logs has four dependencies to mock. A class that only calculates prices has zero to one dependency to mock.
The symptom of an SRP violation in tests: the setUp() block of a test class is longer than the test methods themselves. If five mocks need to be created just to test one method, that is a strong sign that the class under test has too many responsibilities. The refactoring recipe: split the class into smaller services, each with a single responsibility. The result is not only simpler tests, but also more maintainable production code.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model;
use Mironsoft\Catalog\Api\Data\PriceResultInterface;
/**
* Pure tax calculation service, no external dependencies.
* Single responsibility: calculate gross from net and tax rate.
*
* Test setup: zero mocks needed. Direct instantiation.
* This is the ideal testability case: a stateless service with pure logic.
*/
final class TaxCalculator implements \Mironsoft\Catalog\Api\TaxCalculatorInterface
{
// German VAT rates
private const array TAX_RATES = [
1 => 0.19, // Standard rate
2 => 0.07, // Reduced rate (food, books)
0 => 0.00, // Tax exempt
];
/**
* Calculate gross price from net price and Magento tax class ID.
*
* Pure function: same input always produces same output.
* No side effects, no I/O, no state.
*
* @throws \InvalidArgumentException For unknown tax class IDs
*/
public function calculateGross(float $net, int $taxClass): float
{
if (!isset(self::TAX_RATES[$taxClass])) {
throw new \InvalidArgumentException(
"Unknown tax class ID: {$taxClass}. Valid: " . implode(', ', array_keys(self::TAX_RATES))
);
}
if ($net < 0.0) {
throw new \InvalidArgumentException("Net price must be non-negative, got: {$net}");
}
return round($net * (1.0 + self::TAX_RATES[$taxClass]), 2);
}
/**
* Calculate net price from gross price and tax class ID.
* Inverse of calculateGross. Round-trip should be identity within rounding.
*/
public function calculateNet(float $gross, int $taxClass): float
{
if (!isset(self::TAX_RATES[$taxClass])) {
throw new \InvalidArgumentException("Unknown tax class ID: {$taxClass}");
}
return round($gross / (1.0 + self::TAX_RATES[$taxClass]), 2);
}
}
4. Pure functions: deterministic logic is instantly testable
A pure function always returns the same output for the same input and has no side effects. It reads no global state, writes to no files or databases and sends no HTTP requests. This property makes it trivially testable: no mock, no setup, no fixtures. A call with known inputs, an assert on the output, done.
In PHP services this means concretely: transformation logic, formatting, calculations and validation should be implemented as stateless methods or as their own service classes with no external dependencies. If a method needs database access to calculate a price, that is a violation of the pure-function property, and at the same time a testability problem. The solution: separate the database access (loading the product) from the calculation (computing gross from net). The loading step gets mocked, the calculation is a pure function that can be tested directly.
5. Value objects: immutable data, trivial tests
Value objects are immutable objects that are identified only by their value, not by their identity. An amount of money, a product category, a SKU, an email address, these are typical value objects. They have no external dependency, encapsulate their validation in the constructor and offer no setters. This makes them ideally testable: directly instantiable, no mock needed, full behavior without external systems.
Value objects also improve test readability. Instead of assertSame(119.0, $result) you write assertEquals(Money::ofEur(119.0), $result). The test directly communicates the domain concepts. Value objects with an equals() method make comparisons semantically correct: two Money objects with the same amount and the same currency are equal, even if they are different instances. PHP 8 readonly classes and readonly properties make implementing value objects immutable without any extra effort.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model\ValueObject;
/**
* Value Object for monetary amounts.
* Immutable, self-validating, directly testable without any mocks.
*
* PHP 8.2+ readonly class, all properties automatically readonly.
*/
final readonly class Money
{
/**
* @throws \InvalidArgumentException When amount is negative or currency is invalid
*/
public function __construct(
public readonly float $amount,
public readonly string $currency,
) {
if ($amount < 0.0) {
throw new \InvalidArgumentException("Money amount must be non-negative, got: {$amount}");
}
if (!in_array($currency, ['EUR', 'USD', 'GBP', 'CHF'], true)) {
throw new \InvalidArgumentException("Unknown currency: {$currency}");
}
}
public static function ofEur(float $amount): self
{
return new self(amount: $amount, currency: 'EUR');
}
/** Add two monetary values, only possible if same currency. */
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Cannot add {$this->currency} and {$other->currency}"
);
}
return new self(amount: round($this->amount + $other->amount, 2), currency: $this->currency);
}
/** Apply a percentage: 0.19 = 19% */
public function applyPercentage(float $percentage): self
{
return new self(
amount: round($this->amount * (1.0 + $percentage), 2),
currency: $this->currency
);
}
public function equals(self $other): bool
{
return $this->currency === $other->currency
&& abs($this->amount - $other->amount) < 0.001;
}
public function __toString(): string
{
return number_format($this->amount, 2) . ' ' . $this->currency;
}
}
6. Interface segregation: small interfaces, precise mocks
The Interface Segregation Principle (ISP) requires that interfaces be small and specific, so each client only implements what it actually needs. From a testing perspective this has a direct benefit: small interfaces are simple to mock. A mock for an interface with five methods must configure all five methods or cover them with willReturn(null). A mock for an interface with one method configures exactly the one method the test needs.
In Magento projects you frequently see large interfaces like ProductInterface with dozens of methods. For services that only need getSku() and getPrice(), a separate interface PriceableProductInterface with exactly those two methods is the more testable solution. The service declares the dependency on the small interface; the mock implements only the two methods. The result: more precise mocks, clearer tests and production code that explicitly communicates which capabilities it needs from its dependencies.
7. Testable vs. not testable: production code side by side
The following comparisons show concretely how production code decisions determine testability. The left column shows patterns that make tests harder or impossible; the right column shows the testable alternative.
| Pattern | Not testable | Testable | Test-effort reduction |
|---|---|---|---|
| Dependencies | new Dependency() in the constructor |
Constructor injection via interface | No bootstrap, no real object needed |
| Responsibility | 5+ methods, 5+ dependencies | One task, 1-2 dependencies | setUp() in 2 lines instead of 15 |
| Side effects | DB + log + mail in one method | Pure calculation logic extracted | Pure function: zero mocks, zero setup |
| Data handling | Primitives (float $price, string $currency) | Value object (Money $price) | Type safety, self-validating |
| Interface size | Mocking a 50-method interface | Precise 2-method interface | Mock configures only the used area |
Every row in this table is a concrete design decision with a measurable effect on test effort. Teams that build systematically around these patterns report, after 3-6 months, that writing new tests becomes significantly faster, not because the tests changed, but because the production code became easier to test. Testability is not an end in itself: it is a reliable indicator of good software design.
Mironsoft
Testable PHP design, clean code and refactoring for Magento teams
Want your production code to be testable?
We analyze your existing PHP code, identify the central testability problems, and refactor services, view models and repositories into testable clean code, with phpdoc, constructor injection and clear interface boundaries.
Code analysis
Identify testability problems: internal instantiation, god classes, global state
Refactoring
Introduce constructor injection, SRP, value objects and interface segregation
Team coaching
Establish design patterns for testability across the team, structure code reviews
8. Summary
Building testable services is not a separate task performed after writing production code, it is part of writing good production code in the first place. The five most important design patterns are clear: Constructor injection for all dependencies, so tests can replace them with mocks. Single responsibility for small, focused classes that need little mock setup. Pure functions for transformation logic that is testable without any mock at all. Value objects for immutable domain values that are directly instantiable and self-validating. Interface segregation for precise, minimal mocks.
The indicator principle: if a test class needs to create more than 5 mocks, the production code is probably too complex. If a setUp() block is longer than 20 lines, the class under test is probably too large. If a test does not work without the full Magento bootstrap, the production code is reaching directly into Magento infrastructure instead of using abstractions. These warning signs point to design problems in the production code, not weaknesses in the tests. The right response then is not to write more tests, but to refactor the production code.
Building testable services: the essentials at a glance
Constructor injection
All dependencies via the constructor as interfaces. No new, no direct ObjectManager. Allows full mock control in tests.
Single responsibility
One class, one task. A test setUp() with more than 5 mocks is a warning sign. Split the class and test each smaller class individually.
Pure functions & value objects
Extract calculations into pure methods. Value objects for domain values, directly instantiable, self-validating, no mock needed.
Interface segregation
Small, precise interfaces instead of large general-purpose interfaces. Mocks configure only the methods the test actually needs.