Unit Test vs. Integration Test vs. Functional Test in PHP Projects
AI generated
@test
assert
PHPUnit · Unit Tests · Integration Tests · Functional Tests · Test Pyramid
Unit Test vs. Integration Test vs. Functional Test
Which test type belongs where in a PHP project?

Most PHP projects either have too many unit tests for code that depends on database state, or too few tests at the right level. The test pyramid is not an academic concept, it is a practical tool for deciding which test type solves which problem most efficiently.

22 min read Test Pyramid · Mocking Boundaries · Integration Tests · E2E · Magento PHPUnit 10/11 · PHP 8.4 · Magento 2.4.8

1. The Test Pyramid: Why the Ratio Matters

The test pyramid is a model popularized by Martin Fowler and Mike Cohn. It states that a well-structured PHP project should have many fast unit tests, a moderate number of integration tests, and few slow functional tests. The ratio is not a rigid rule, it is a feedback signal: if a project consists mainly of slow integration tests, that points to poor testability of the production code. If it consists mainly of unit tests with heavy mocking, that points to tests that do not sufficiently exercise the system under real conditions.

In PHP projects, especially in Magento 2, you often see the inverted pyramid: many integration and functional tests, few unit tests. The reason is historical: Magento code was hard to test for a long time because it depended directly on the ObjectManager and global state. Modern Magento modules built with ViewModels, service contracts, and repositories are far more unit-testable. So the test pyramid is also an architecture critique: poor testability forces higher, slower tests.

2. Unit Tests: Isolated Logic Without External Dependencies

A unit test verifies a single class or a single function completely isolated from its environment. Every dependency is replaced by a test double (mock, stub, spy). The result: unit tests are extremely fast (milliseconds), reproducible, and give precise feedback about which piece of logic is broken. They do not test database queries, HTTP requests, or filesystem state.

The decisive mistake when writing unit tests is too much mocking. If a class has ten dependencies and all ten must be mocked for the test to run, that is a signal the class has too many responsibilities. A good unit test is simple to write and read, it tests a manageable piece of logic with a few clear, understandable assertions.


<?php
// Unit test: tests pure business logic in isolation
// No database, no HTTP, no filesystem, just the calculation

declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Unit\Service;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Mironsoft\Pricing\Service\TaxCalculator;

final class TaxCalculatorTest extends TestCase
{
    private TaxCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new TaxCalculator();
    }

    #[DataProvider('taxRateProvider')]
    public function testCalculatesTaxCorrectly(
        float $net,
        float $rate,
        float $expectedGross
    ): void {
        $result = $this->calculator->addTax($net, $rate);
        self::assertEqualsWithDelta($expectedGross, $result, 0.001);
    }

    public static function taxRateProvider(): array
    {
        return [
            'standard German VAT 19%' => [100.00, 19.0, 119.00],
            'reduced German VAT 7%'   => [100.00, 7.0,  107.00],
            'zero-rated export'        => [100.00, 0.0,  100.00],
            'zero net price'           => [0.00,   19.0,   0.00],
        ];
    }

    public function testThrowsOnNegativeTaxRate(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Tax rate cannot be negative');
        $this->calculator->addTax(100.00, -5.0);
    }
}

This example illustrates what makes a good unit test: the TaxCalculator class has no external dependencies. The test needs no mocking. Data providers cover several cases in a single test. The assertions are precise and communicate the expected behavior. The test runs in under a millisecond and, on failure, immediately points to which input produced which incorrect result.

3. Integration Tests: Working Together With Real Infrastructure

An integration test verifies how several components work together, typically with a real database connection, real repositories, and real services. It does not test whether the SQL query is syntactically correct, but whether the entire chain from service through repository to database works correctly. Integration tests are slower than unit tests (seconds instead of milliseconds), but they exercise the system under more realistic conditions.

The typical scope for integration tests in PHP projects: repository implementations, service classes that combine repositories, event listeners that change database state, and complex query builder logic. Anything that depends on a specific database state is a candidate for an integration test, not for a unit test with mocked repositories.

4. Functional Tests: Behavior From the User's Perspective

Functional tests (also called end-to-end tests or acceptance tests) exercise the system like an end user: they send HTTP requests, read HTTP responses, and verify behavior from the outside. They do not test which class calls which method, but whether the system produces the correct output for a given input. In Magento 2, functional tests typically correspond to MFTF tests (Magento Functional Testing Framework) or to PHPUnit tests that dispatch HTTP requests through the Magento test client.

Functional tests are the slowest tests in the pyramid: they need a fully bootstrapped Magento stack, real database connections, and often a running web server. The area where they are indispensable: checkout flows, payment integration, multi-step forms, and anywhere correct template rendering is part of the test result. For these areas, unit and integration tests are not sufficient.


<?php
// Functional test via PHPUnit + Magento HTTP test client
// Tests the full checkout flow from cart to order

declare(strict_types=1);

namespace Mironsoft\Checkout\Test\Integration\Controller;

use Magento\TestFramework\TestCase\AbstractController;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Checkout\Test\Integration\DataBuilder\ProductBuilder;
use Mironsoft\Checkout\Test\Integration\DataBuilder\CustomerBuilder;

class CheckoutControllerTest extends AbstractController
{
    /**
     * Tests that adding a product to cart returns the correct response.
     *
     * @magentoConfigFixture default_store general/locale/code de_DE
     */
    public function testAddToCartReturnsSuccessResponse(): void
    {
        $product = ProductBuilder::aProduct()
            ->withSku('checkout-test-001')
            ->withPrice(49.99)
            ->build();

        $this->dispatch('checkout/cart/add/product/' . $product->getId() . '/qty/1');

        $this->assertSessionMessages(
            $this->containsEqual(
                'You added checkout-test-001 to your shopping cart.'
            )
        );
        $this->assertRedirect($this->stringContains('checkout/cart'));
    }

    /**
     * Tests that the checkout summary displays correct totals.
     */
    public function testCheckoutSummaryDisplaysCorrectTotals(): void
    {
        // Full integration: real cart, real totals calculation
        $response = $this->getRequest()->setMethod('GET');
        $this->dispatch('checkout/');

        $this->assertResponseCode(200);
        $body = $this->getResponse()->getBody();
        self::assertStringContainsString('Grand Total', $body);
    }
}

5. The Limits of Mocking: When Mocks Do Harm

Mocks are the single most important tool for unit tests, but they have clear limits. A mock replaces a real implementation with a preprogrammed stand-in. The problem: a mock that implements a wrong assumption about the real class's behavior lets tests pass green even though the real code is broken. The more complex the behavior of a dependency, the more dangerous mocking it becomes.

The practical rule of thumb: mock repositories and external services (HTTP clients, email senders, payment gateways) always. Do not mock your own domain objects and value objects, use real instances. Mock Magento core services (ProductRepository, CustomerRepository) in unit tests, but not in integration tests. Deciding to mock too much results in a test suite that is green but gives little real confidence.

6. Test Types in Magento 2: Specifics and Recommendations

Magento 2 has its own test framework with three levels: unit tests (in Test/Unit/), integration tests (in Test/Integration/), and MFTF tests (functional). At the unit test level: every class that implements pure business logic, price calculations, validations, transformations, is unit-testable if it is correctly built with constructor injection. ViewModels that do not perform direct database queries are ideal candidates for unit tests.

For Magento integration tests: event listeners, plugins, repository implementations, and complex service combinations are best tested with integration tests. These tests need the Magento bootstrap and a test database, but in return they are more realistic than unit tests with mocked objects. The MFTF level suits critical user flows, but should stay limited to the minimum, because MFTF tests are extremely slow and carry a high maintenance burden.

Characteristic Unit Test Integration Test Functional Test
Runtime < 1ms 0.1-10s 5-60s
Isolation Complete Partial None (real system)
Database access None Yes (test database) Yes (complete)
Feedback precision Very high Medium Low (many possible causes)
Maintenance effort Low Medium High
Recommended share 70-80% 15-25% 5-10%

7. Decision Tree: Which Test for Which Problem?

The practical decision about which test type fits which problem follows a clear logic. Does the logic under test depend on a database query? Then no unit test, either refactor the code so the database logic is isolated in a repository (the repository is mocked, the logic is unit-tested), or write an integration test. Are you testing how several classes work together through real interfaces? Integration test. Are you testing a user-facing feature that combines rendering and database state? Functional test.

The most important point: choosing a particular test type is also an architecture decision. Code that is only testable with integration tests is often poorly structured. ViewModels that do not perform database queries directly and receive every dependency through the constructor can be fully unit-tested. The result is faster feedback, simpler tests, and better architecture.

8. Common Mistakes When Choosing a Test Type

The most common mistake: writing integration tests when the code would actually be unit-testable. This happens when classes use the ObjectManager directly, rely on singleton patterns, or read global state. The fix is not in test design, it is in refactoring the production code. A second common mistake: writing functional tests for logic that could be tested more efficiently with an integration test. Functional tests for price calculations that involve no template rendering aspects are overkill.


<?php
// WRONG: Integration test for pure logic that should be a Unit Test
// This test starts the full Magento stack to test a simple calculation

use Magento\TestFramework\TestCase\AbstractController;

class WrongTaxTest extends AbstractController  // full Magento bootstrap!
{
    public function testTaxCalculation(): void
    {
        // Pure math, no database needed, but we're paying full bootstrap cost
        $result = 100.00 * 1.19;
        self::assertEqualsWithDelta(119.00, $result, 0.001);
    }
}

// RIGHT: Pure Unit Test, no bootstrap, runs in <1ms
use PHPUnit\Framework\TestCase;

final class CorrectTaxTest extends TestCase  // plain PHPUnit, no Magento
{
    public function testTaxCalculation(): void
    {
        $calculator = new TaxCalculator();
        $result = $calculator->addTax(100.00, 19.0);
        self::assertEqualsWithDelta(119.00, $result, 0.001);
    }
}

9. Test Types Side by Side

Every test type has its optimal scope. The challenge is not knowing the types, it is consistently choosing the right type for the problem at hand in day-to-day project work. The table below shows the decision basis for PHP projects in a Magento context.

10. Summary

The test pyramid is not an academic concept, it is a practical feedback instrument. A PHP project that consists mainly of integration tests signals that its production code is hard to test. A project with many unit tests that require heavy mocking shows that the classes carry too many responsibilities. The test pyramid reflects architecture quality.

Unit tests are fast, precise, and give direct feedback. Integration tests verify collaboration with real infrastructure. Functional tests verify the system the way a user would. All three levels are necessary, but in the right proportion. For Magento 2 that means: unit-test ViewModels and services, integration-test repositories and event listeners, functionally test critical user flows.

Unit vs. Integration vs. Functional Test: The Essentials at a Glance

Unit Tests (70-80%)

Test pure logic in isolation. No database connection, no HTTP. Mock dependencies. Runs in milliseconds. Gives precise feedback on individual classes.

Integration Tests (15-25%)

Collaboration with real infrastructure. Repositories, event listeners, plugins. Real test database with rollback isolation. Runs in seconds.

Functional Tests (5-10%)

Critical user flows from the user's perspective. Full stack. Only where unit and integration are not enough. MFTF or HTTP client. Runs in minutes.

Architecture Feedback

Code that is hard to test is poorly structured code. If only integration tests are possible, refactoring the production code is the fix, not more mocking.

11. FAQ: Unit Test vs. Integration Test vs. Functional Test in PHP

1What is the main difference between a unit test and an integration test?
Unit test: single class in isolation, all dependencies mocked, milliseconds. Integration test: several components with real infrastructure, seconds. Feedback precision drops as the level rises.
2When a functional test instead of an integration test?
When template rendering is part of the test or a complete HTTP request-response cycle needs to be verified. Only for critical user flows, functional tests are expensive.
3Why are too many mocks a problem?
Mocks implement assumptions about real behavior. Wrong assumptions keep tests green even though real code is broken. The more mocks, the bigger the gap to reality.
4What does the test pyramid say about the architecture?
Mainly integration tests = poor testability of the production code. The test pyramid is also an architecture critique, not just a test design model.
5Which Magento components are suited to unit tests?
ViewModels without direct DB queries, price calculation services, validation classes, data transformers, DTO mapping. Every class with pure logic and constructor injection.
6Which Magento components need integration tests?
Repository implementations, event listeners, plugins with DB state, complex query builder logic, services that combine several repositories.
7How many tests of each type should a PHP project have?
Guideline: 70-80% unit, 15-25% integration, 5-10% functional. Strong deviations are a signal for architecture or test design problems.
8Are test doubles allowed in integration tests?
Yes, for external services (HTTP clients, payment gateways). In Magento, swap them via di.xml in the test context, while the database layer stays real.
9Difference between functional tests and MFTF in Magento?
MFTF uses Selenium/WebDriver for browser-based tests. PHPUnit functional tests use Magento's internal HTTP client without a browser, faster, but no real browser rendering.
10How do you recognize a test at the wrong level?
A unit test that needs DB or HTTP = wrong level. An integration test that only tests pure logic = wrong level, it pays integration overhead without integration benefit.