Building Testable Services: Where Production Code Makes Testing Easier
AI generated
@test
assert
PHPUnit · Testability · Clean Code · SOLID · Magento
Building testable services
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.

19 min read Constructor Injection · SRP · Pure Functions · Value Objects · Interface Segregation PHPUnit 10/11 · PHP 8.4 · Magento 2.4.x

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.

9. FAQ: Testable services in PHP

1What makes a PHP class hard to test?
Instantiating dependencies internally with new, static methods with state, too many responsibilities, direct ObjectManager access. Each of these properties makes mocks impossible or very costly.
2Constructor property promotion for tests?
Makes dependencies immediately visible, reduces boilerplate. No effect on testability itself, but clearer classes mean clearer tests.
3When is a class too large for unit tests?
setUp() needs more than 5 mocks. Test class is longer than the production class. Mocks are configured but never called. Signal: split the production class.
4What is a pure function?
Same input equals same output, no side effects. Zero mocks, zero setup in the test. Input in, output checked, done.
5Value object vs. DTO?
Value object: immutable, self-validating, identity through value. DTO: mutable data container without logic. Value objects are more directly testable.
6Incremental refactoring toward testable code?
First characterization tests for the existing behavior. Then extract dependencies, introduce interfaces, replace new with constructor injection. Tests secure behavior during the refactoring.
7Preventing direct ObjectManager calls?
Treat it as a code smell, set up a PHPStan rule. Use factories for dynamic creation, factories themselves passed via constructor injection and mockable in tests.
8Testing static methods?
Convert static methods with state or external dependencies into instance methods. Pure static utility methods (no state, no I/O) are directly testable.
9Dependency injection vs. service locator?
DI: dependencies are passed in from outside, testable. Service locator: the class actively fetches its dependencies (ObjectManager::get()), hides dependencies, makes tests harder.
10Applying interface segregation in Magento?
Define a small custom interface for services that only need parts of a large Magento interface. ReadableProductRepositoryInterface with getById() instead of the full ProductRepositoryInterface.