0

Test types: unit, integration, feature — what each tests

Beginner5 min read·php-14-002
interview

Concept

The distinction between unit, integration, and feature tests is not just semantic—it determines what you're allowed to mock, how slow your test suite can get, and how confident you can be when the suite goes green. Conflating them leads to test suites that are slow, brittle, or give false confidence.

A unit test tests a single class or function in complete isolation. All collaborators are replaced with test doubles. It should run in microseconds and have zero I/O. If your "unit test" reads from a file, hits a database, or calls a real HTTP endpoint, it is not a unit test. Unit tests answer the question: "Does this class behave correctly given controlled inputs?"

An integration test tests the collaboration between two or more real components. It might use a real database (ideally in-memory SQLite or a transaction-wrapped test database) but still avoids external network calls. Integration tests answer: "Do these components work correctly together?" A service that calls a repository that queries a real database is an integration test, not a unit test.

A feature test (sometimes called an end-to-end or acceptance test) tests a complete user-facing scenario from entry point to response. In a web application this means firing an HTTP request through the full middleware stack and asserting on the response. Feature tests are the most expensive but give the highest confidence. They should be fewer in number and reserved for critical user flows.

TypeScopeSpeedI/ODoubles
UnitSingle classMicrosecondsNoneAll collaborators mocked
Integration2+ componentsMillisecondsDB allowedExternal services mocked
FeatureFull stack100ms+Full I/OMinimal mocking

The testing pyramid says you should have many unit tests, fewer integration tests, and even fewer feature tests—not because feature tests are bad, but because they're expensive and slow to maintain.

Code Example

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;

// Unit test: PriceCalculator is tested in isolation.
// No database, no filesystem, no collaborators.
final class PriceCalculatorTest extends TestCase
{
    public function test_applies_percentage_discount(): void
    {
        $calculator = new PriceCalculator();
        $result = $calculator->applyDiscount(price: 100.00, discountPercent: 10);
        $this->assertSame(90.0, $result);
    }

    public function test_discount_cannot_exceed_price(): void
    {
        $calculator = new PriceCalculator();
        $this->expectException(\InvalidArgumentException::class);
        $calculator->applyDiscount(price: 100.00, discountPercent: 110);
    }
}

// Integration test: OrderRepository talks to a real SQLite database.
namespace Tests\Integration;

use App\Repositories\OrderRepository;
use Tests\TestCase;

final class OrderRepositoryTest extends TestCase
{
    private OrderRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        // Real database connection, schema created in setUp
        $this->repository = new OrderRepository($this->getTestConnection());
    }

    public function test_finds_orders_by_user_id(): void
    {
        // Seed data, then assert
        $this->repository->create(['user_id' => 42, 'total' => 99.99]);
        $orders = $this->repository->findByUserId(42);
        $this->assertCount(1, $orders);
        $this->assertSame(42, $orders[0]->userId);
    }
}

// Feature test: Full HTTP request through routing + middleware + controller
namespace Tests\Feature;

use Tests\TestCase;

final class CheckoutTest extends TestCase
{
    public function test_authenticated_user_can_place_order(): void
    {
        $user = $this->createUser();
        $response = $this->actingAs($user)->post('/orders', [
            'product_id' => 1,
            'quantity' => 2,
        ]);
        $response->assertStatus(201);
        $this->assertDatabaseHas('orders', ['user_id' => $user->id]);
    }
}

Interview Q&A

Q: Why is it a problem when developers write "unit tests" that actually hit the database?

Tests that touch the database are orders of magnitude slower and introduce state management problems—tests must clean up after themselves, run in a specific order, or use transactions that are rolled back. More importantly, when such a test fails, you cannot tell whether the bug is in the class under test or in the database layer, removing the diagnostic value of isolation. True unit tests fail fast and tell you exactly which line of application code is wrong.


Q: When should you write a feature test instead of just unit and integration tests?

Feature tests are most valuable for critical user journeys where the full integration of routing, middleware, authentication, validation, and business logic must all cooperate correctly. A payment checkout flow, an authentication sequence, or a multi-step form wizard deserve feature tests because a unit test would mock away everything meaningful. The rule of thumb: if a bug in this flow would cost you money or users, write a feature test. For internal utility classes, unit tests suffice.


Q: What is the testing pyramid and when is the testing trophy a better model?

The testing pyramid (Martin Fowler) says: many unit tests, fewer integration tests, even fewer E2E tests. It emerged from contexts where I/O is expensive and unit tests are cheap. The testing trophy (Kent C. Dodds) inverts the emphasis: fewer unit tests, many integration tests, because integration tests catch the failures that actually matter (component interaction bugs) without being as brittle as E2E tests. For PHP applications with fast in-memory SQLite, the trophy is often more practical—integration tests run nearly as fast as unit tests and give far more confidence.