Unit Test #

Unit testing is the practice of writing code that verifies the smallest units of your application — functions, methods, classes — work correctly in isolation. PHPUnit is the de facto standard testing framework for PHP, used by almost every major library and framework including Laravel, Symfony, and Doctrine. But unit testing isn’t just about writing code so it “has tests” — bad tests actually slow down development, break during refactoring, and provide no real confidence. This article covers PHPUnit from installation to advanced practices: correct test doubles, data providers, meaningful code coverage, and the philosophy of writing tests that are genuinely useful.

Why Unit Tests #

Before getting technical, it’s important to understand the real value of unit tests:

flowchart LR
    A[Write Code] --> B[Write Test]
    B --> C{Tests Pass?}
    C -- No --> D[Debug & Fix\nCode]
    D --> C
    C -- Yes --> E[Refactor\nSafely]
    E --> F{Tests Still\nPass?}
    F -- No --> G[Find Regressions\nBefore Production]
    G --> D
    F -- Yes --> H[Deploy\nwith Confidence]

    style G fill:#fef9c3
    style H fill:#dcfce7

Good tests provide three things: living documentation (tests explain what the code is supposed to do), a refactoring safety net (change the implementation without fear of breaking behavior), and fast feedback (find bugs in seconds, not when users report them).


Installation and Configuration #

# Install PHPUnit as a development dependency
composer require --dev phpunit/phpunit "^11.0"

# Check the version
./vendor/bin/phpunit --version

phpunit.xml — Test Suite Configuration #

<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml or phpunit.xml.dist -->
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
    colors="true"
    stopOnFailure="false"
    beStrictAboutOutputDuringTests="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>

    <!-- Code coverage -->
    <coverage>
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory>src/generated</directory>
        </exclude>
        <report>
            <html outputDirectory="coverage"/>
            <clover outputFile="coverage/clover.xml"/>
            <text outputFile="coverage/coverage.txt"/>
        </report>
    </coverage>

    <!-- Environment variables for tests -->
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="CACHE_DRIVER" value="array"/>
    </php>
</phpunit>

Test Directory Structure #

tests/
  ├── Unit/                       ← isolated tests, no I/O
  │   ├── Domain/
  │   │   └── OrderTest.php
  │   ├── Service/
  │   │   └── PricingServiceTest.php
  │   └── Util/
  │       └── SlugGeneratorTest.php
  ├── Integration/                ← tests with a real database or services
  │   └── Repository/
  │       └── UserRepositoryTest.php
  └── Feature/                    ← end-to-end HTTP request tests
      └── Api/
          └── OrderControllerTest.php

Test Case Anatomy #

<?php
// tests/Unit/Service/PricingServiceTest.php
namespace Tests\Unit\Service;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use App\Service\PricingService;
use App\Exception\InvalidPriceException;

class PricingServiceTest extends TestCase
{
    // System Under Test — the object being tested
    private PricingService $service;

    // setUp() — runs before EACH test method
    protected function setUp(): void
    {
        parent::setUp();
        $this->service = new PricingService();
    }

    // tearDown() — runs after EACH test method
    protected function tearDown(): void
    {
        // clean up resources if needed
        parent::tearDown();
    }

    // setUpBeforeClass() — runs once before all tests in this class
    public static function setUpBeforeClass(): void
    {
        parent::setUpBeforeClass();
        // shared database connection, etc.
    }

    // tearDownAfterClass() — runs once after all tests in this class
    public static function tearDownAfterClass(): void
    {
        parent::tearDownAfterClass();
    }

    #[Test]
    public function calculatesPriceWithDiscount(): void
    {
        // Arrange — set up the conditions
        $basePrice = 100_000.0;
        $discount  = 0.10; // 10%

        // Act — run what's being tested
        $result = $this->service->calculateTotal($basePrice, $discount);

        // Assert — verify the result
        $this->assertSame(90_000.0, $result);
    }
}

Test Naming Conventions #

Test names must describe the behavior being tested, not the method name:

<?php
// ANTI-PATTERN: uninformative names
public function testCalculateTotal(): void { }
public function test1(): void { }
public function testCase3(): void { }

// CORRECT: names describing the scenario and expectation
public function finalPriceIncludesVatAsElevenPercent(): void { }
public function throwsExceptionWhenDiscountExceedsOneHundred(): void { }
public function returnsZeroWhenCartIsEmpty(): void { }

// Good format: [condition]_[action]_[expectation]
public function basePrice_withTenPercentDiscount_produces90000(): void { }

Assertions #

PHPUnit provides more than 60 assertions. Here are the most frequently used and when to choose them:

<?php
// Value comparison
$this->assertSame(42, $result);          // === (same type and value) — stricter
$this->assertEquals(42, $result);        // == (same value, type conversion) — looser
$this->assertNotSame(0, $result);
$this->assertNotEquals(0, $result);

// Booleans
$this->assertTrue($condition);
$this->assertFalse($condition);

// Null
$this->assertNull($value);
$this->assertNotNull($value);

// Numbers
$this->assertGreaterThan(0, $result);
$this->assertGreaterThanOrEqual(0, $result);
$this->assertLessThan(100, $result);
$this->assertEqualsWithDelta(3.14, $result, delta: 0.001); // for floats

// Strings
$this->assertStringContainsString('Hello', $sentence);
$this->assertStringStartsWith('http', $url);
$this->assertStringEndsWith('.php', $file);
$this->assertMatchesRegularExpression('/^\d{4}$/', $year);
$this->assertStringEqualsIgnoringLineEndings("content", $text);

// Arrays
$this->assertCount(3, $array);
$this->assertEmpty($array);
$this->assertNotEmpty($array);
$this->assertContains('apple', $array);
$this->assertArrayHasKey('name', $array);
$this->assertArrayNotHasKey('password', $responseData);

// Types
$this->assertIsInt($value);
$this->assertIsFloat($value);
$this->assertIsString($value);
$this->assertIsBool($value);
$this->assertIsArray($value);
$this->assertIsNull($value);
$this->assertInstanceOf(User::class, $object);

// Exceptions — see the dedicated section

// Files
$this->assertFileExists('/tmp/output.txt');
$this->assertFileNotExists('/tmp/temp.txt');
$this->assertDirectoryExists('/tmp/logs');

Choosing assertSame vs assertEquals #

<?php
// assertSame uses === (both value AND type must match)
$this->assertSame(1, 1);       // passes
$this->assertSame(1, '1');     // FAILS — different types
$this->assertSame(1, 1.0);     // FAILS — int vs float
$this->assertSame(1, true);    // FAILS — int vs bool

// assertEquals uses == (automatic type conversion)
$this->assertEquals(1, '1');   // passes — but this could be a hidden bug!
$this->assertEquals(1, true);  // passes

// BEST PRACTICE: use assertSame most of the time
// Use assertEquals only when the type genuinely doesn't matter
// or when comparing objects/arrays recursively

Data Providers #

Data providers let you run the same test with many different inputs, without duplicating code:

<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;

class PricingServiceTest extends TestCase
{
    public static function discountScenarios(): array
    {
        return [
            // [scenario name, price, discount, expected_result]
            '0% discount'  => [100_000, 0.00, 100_000.0],
            '10% discount' => [100_000, 0.10,  90_000.0],
            '50% discount' => [100_000, 0.50,  50_000.0],
            '100% discount'=> [100_000, 1.00,       0.0],
            'price 0'      => [0,       0.20,       0.0],
        ];
    }

    #[Test]
    #[DataProvider('discountScenarios')]
    public function calculatesPriceAfterDiscount(
        float $price,
        float $discount,
        float $expected,
    ): void {
        $result = $this->service->calculateTotal($price, $discount);
        $this->assertEqualsWithDelta($expected, $result, 0.01);
    }

    // Data provider for edge cases
    public static function invalidInputs(): array
    {
        return [
            'negative price'         => [-1,      0.10],
            'negative discount'      => [100_000, -0.1],
            'discount over 100%'     => [100_000,  1.5],
        ];
    }

    #[Test]
    #[DataProvider('invalidInputs')]
    public function throwsExceptionForInvalidInput(
        float $price,
        float $discount,
    ): void {
        $this->expectException(\InvalidArgumentException::class);
        $this->service->calculateTotal($price, $discount);
    }
}

Testing Exceptions #

<?php
class OrderServiceTest extends TestCase
{
    #[Test]
    public function rejectsOrderWithEmptyCart(): void
    {
        // Way 1: expectException() — verify the exception type
        $this->expectException(\DomainException::class);
        $this->service->processOrder([]);
    }

    #[Test]
    public function errorMessageMustMentionTheReason(): void
    {
        // Way 2: verify the exception message
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Insufficient stock');
        $this->expectExceptionCode(409);

        $this->service->addItem($productId, qty: 999);
    }

    #[Test]
    public function catchesExceptionAndChecksItsProperties(): void
    {
        // Way 3: catch manually if you need to check specific properties
        try {
            $this->service->addItem($productId, qty: 999);
            $this->fail('Exception was not thrown though expected');
        } catch (\App\Exception\InsufficientStockException $e) {
            $this->assertSame('Laptop', $e->getProductName());
            $this->assertSame(999, $e->getRequested());
            $this->assertLessThan(999, $e->getAvailable());
        }
    }
}

Test Doubles — Stubs and Mocks #

When the unit under test depends on other objects (database, API, email), we replace them with test doubles so the test stays isolated and deterministic.

flowchart LR
    Test[Test] --> SUT[System Under\nTest]
    SUT --> TDouble[Test Double\nStub / Mock / Spy]
    TDouble -.->|replaces| RealDep[Real\nDependency\nDB / API / Email]

    style TDouble fill:#fef9c3
    style RealDep fill:#fee2e2

Stubs — Return Predefined Values #

A stub only returns pre-programmed values — it doesn’t verify how it was called:

<?php
class OrderServiceTest extends TestCase
{
    #[Test]
    public function calculatesOrderTotalFromRepository(): void
    {
        // Create a stub for UserRepository
        $repoStub = $this->createStub(UserRepository::class);

        // Program the stub to return a specific user
        $repoStub->method('findById')
                 ->willReturn(new User(id: 1, name: 'Budi', discount: 0.10));

        // Can also return different values for different arguments
        $repoStub->method('findById')
                 ->willReturnMap([
                     [1, new User(id: 1, discount: 0.10)],
                     [2, new User(id: 2, discount: 0.20)],
                 ]);

        // Can also return sequential values for sequential calls
        $repoStub->method('findAll')
                 ->willReturnOnConsecutiveCalls(
                     [new User(id: 1)],
                     [],                 // empty on the second call
                 );

        $service = new OrderService($repoStub);
        $total   = $service->calculateTotalForUser(userId: 1, price: 100_000);

        $this->assertSame(90_000.0, $total); // 10% discount
    }
}

Mocks — Verify Calls #

Mocks not only return values but also verify that methods are called in the correct way:

<?php
class NotificationServiceTest extends TestCase
{
    #[Test]
    public function sendsEmailWhenOrderCompletes(): void
    {
        // Create a mock for Mailer
        $mailerMock = $this->createMock(MailerInterface::class);

        // Expectation: send() MUST be called EXACTLY ONCE
        $mailerMock->expects($this->once())
                   ->method('send')
                   ->with(
                       $this->equalTo('[email protected]'),   // argument 1
                       $this->stringContains('Order #'),      // argument 2
                       $this->anything(),                      // argument 3 (not cared about)
                   );

        $service = new NotificationService($mailerMock);
        $service->notifyOrderCompleted(new Order(
            id:    42,
            email: '[email protected]',
        ));

        // Verification happens automatically when the test finishes
    }

    #[Test]
    public function doesNotSendEmailWhenOrderCancelled(): void
    {
        $mailerMock = $this->createMock(MailerInterface::class);

        // Expectation: send() MUST NOT be called at all
        $mailerMock->expects($this->never())
                   ->method('send');

        $service = new NotificationService($mailerMock);
        $service->notifyOrderCancelled(new Order(
            id:     42,
            email:  '[email protected]',
            status: 'cancelled',
        ));
    }

    #[Test]
    public function sendsEmailToEveryone(): void
    {
        $mailerMock = $this->createMock(MailerInterface::class);

        // Called exactly 3 times (arguments not cared about)
        $mailerMock->expects($this->exactly(3))
                   ->method('send');

        $service = new NotificationService($mailerMock);
        $service->broadcastAnnouncement([
            '[email protected]',
            '[email protected]',
            '[email protected]',
        ], 'Important announcement');
    }
}

Stub vs Mock — When to Use Which #

Use a Stub when:
  ✓ The dependency needs to return data so the SUT can run
  ✓ You don't care how many times or how the dependency is called
  ✓ Example: a repository returning user data for calculations

Use a Mock when:
  ✓ You want to verify that interactions with the dependency happen correctly
  ✓ Side effects are the core of what's being tested (email sent, log written)
  ✓ Example: mailers, loggers, event dispatchers

Good Tests vs Bad Tests #

Test quality matters as much as production code quality:

<?php
// ANTI-PATTERN 1: too many assertions in one test (testing everything)
#[Test]
public function testProcessOrder(): void
{
    $order = $this->service->process($data);
    $this->assertNotNull($order);
    $this->assertIsArray($order);
    $this->assertArrayHasKey('id', $order);
    $this->assertArrayHasKey('status', $order);
    $this->assertArrayHasKey('total', $order);
    $this->assertSame('pending', $order['status']);
    $this->assertGreaterThan(0, $order['total']);
    $this->assertArrayHasKey('items', $order);
    $this->assertCount(2, $order['items']);
    // ... 10 more assertions
}
// If one assertion fails, it's hard to tell which aspect has the problem

// CORRECT: one test, one behavior
#[Test]
public function newOrderHasPendingStatus(): void
{
    $order = $this->service->process($data);
    $this->assertSame('pending', $order['status']);
}

#[Test]
public function newOrderHasTheCorrectTotal(): void
{
    $order = $this->service->process(['items' => [['price' => 50000, 'qty' => 2]]]);
    $this->assertSame(100_000.0, $order['total']);
}

// ANTI-PATTERN 2: tests depending on other tests
#[Test]
public function test2NeedsTest1First(): void
{
    // Depends on state created by test1 — very fragile
    $this->assertNotNull($this->userFromTest1);
}

// CORRECT: each test stands alone, create its own data in setUp()
#[Test]
public function everyTestIsIndependentAndSelfContained(): void
{
    $user   = $this->createNewUser();
    $result = $this->service->process($user);
    $this->assertTrue($result);
}

// ANTI-PATTERN 3: tests testing implementation, not behavior
#[Test]
public function ensuresRedisCacheIsCalledTwice(): void
{
    $cache = $this->createMock(Redis::class);
    $cache->expects($this->exactly(2))->method('get'); // tied to the implementation!
    // If the implementation changes (e.g. to 1 more efficient call), the test fails
}

// CORRECT: test behavior observable from the outside
#[Test]
public function returnsUserDataFromCache(): void
{
    // Call twice
    $user1 = $this->service->getUser(1);
    $user2 = $this->service->getUser(1);
    // Verify the results are the same (not how many times cache was called)
    $this->assertEquals($user1, $user2);
}

Code Coverage #

Code coverage measures what percentage of the source code gets executed when tests run. Useful as an indicator, but not the end goal:

# Run tests with code coverage
./vendor/bin/phpunit --coverage-html coverage/
./vendor/bin/phpunit --coverage-text
./vendor/bin/phpunit --coverage-clover coverage/clover.xml

# Needs Xdebug or PCOV active
# Install PCOV (faster than Xdebug for coverage)
sudo apt install php8.3-pcov
# or via PECL: pecl install pcov

Setting a Minimum Coverage #

<!-- phpunit.xml -->
<coverage>
    <report>
        <html outputDirectory="coverage"/>
    </report>
    <!-- Fail tests if coverage is below the threshold -->
    <coverage requireCoverageMetadata="false">
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>
</coverage>
# Run and fail if coverage < 80%
./vendor/bin/phpunit --coverage-text --min-coverage-statements=80

Coverage Attributes #

<?php
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\UsesClass;

// Mark which class this test class covers
#[CoversClass(PricingService::class)]
#[UsesClass(Order::class)] // classes used but not the test focus
class PricingServiceTest extends TestCase
{
    // ...

    #[CoversMethod(PricingService::class, 'calculateTotal')]
    public function calculatesTotalCorrectly(): void
    {
        // test...
    }
}
100% coverage isn’t the goal — code coverage measures executed lines, not logic correctness. Code with 100% coverage can still have bugs if its assertions are weak. Better to have 70% coverage with meaningful tests than 100% coverage with tests that merely call methods without proper assertions.

PHPUnit 11 Test Attributes #

PHPUnit 11 uses PHP Attributes instead of docblock annotations:

<?php
use PHPUnit\Framework\Attributes\{
    Test, DataProvider, Group, Skip, Depends,
    Before, After, BeforeClass, AfterClass,
    CoversClass, UsesClass, RequiresPhp,
    WithoutErrorHandler, RunInSeparateProcess,
};

#[CoversClass(OrderService::class)]
#[Group('order')]
class OrderServiceTest extends TestCase
{
    #[Test]
    #[Group('smoke')]
    public function orderCanBeCreated(): void { }

    #[Test]
    #[Skip('Feature under development')]
    public function newFeature(): void { }

    #[Test]
    #[Depends('orderCanBeCreated')]
    public function orderCanBeProcessed(): void
    {
        // This test only runs if orderCanBeCreated passes
    }

    #[Test]
    #[RequiresPhp('8.2')]
    public function php82Feature(): void { }

    #[Test]
    #[RunInSeparateProcess]
    public function testThatModifiesGlobalState(): void { }
}
# Run only tests in a specific group
./vendor/bin/phpunit --group smoke
./vendor/bin/phpunit --group order
./vendor/bin/phpunit --exclude-group slow

CI/CD Integration #

Tests should run automatically every time code changes:

# .github/workflows/test.yml (GitHub Actions)
name: PHP Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        php: ['8.2', '8.3']  # test on several PHP versions

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: pcov, pdo, sqlite3
          coverage: pcov

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run tests
        run: |
          ./vendor/bin/phpunit \
            --coverage-clover coverage/clover.xml \
            --log-junit coverage/junit.xml          

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: coverage/clover.xml
// composer.json — shortcuts for running tests
{
    "scripts": {
        "test":          "./vendor/bin/phpunit",
        "test:coverage": "./vendor/bin/phpunit --coverage-html coverage/",
        "test:unit":     "./vendor/bin/phpunit --testsuite Unit",
        "test:watch":    "fswatch -r src tests | xargs -I{} ./vendor/bin/phpunit"
    }
}
# Run tests
composer test
composer test:unit
composer test:coverage

Summary #

  • PHPUnit is PHP’s standard testing framework — install it via Composer as require-dev, configure via phpunit.xml, and organize tests into Unit, Integration, and Feature folders.
  • One test, one behavior — every test method should verify one specific aspect of behavior. Tests with too many assertions are hard to debug when they fail.
  • Test names describe behavior — not method names. rejectsOrderWithEmptyCart() is far more informative than testProcess().
  • The Arrange-Act-Assert pattern — clearly separate condition setup, the execution being tested, and result verification.
  • assertSame is stricter than assertEquals — use assertSame as the default because it compares value and type. Use assertEquals only when type conversion genuinely doesn’t matter.
  • Stubs for data, Mocks for interactions — stubs return pre-programmed values; mocks verify that methods are called in the correct way.
  • Data Providers for testing many inputs — one test method with #[DataProvider] is far cleaner than many duplicated methods.
  • Code coverage is an indicator, not a goal — 70% coverage with meaningful assertions is better than 100% coverage with tests that verify nothing.
  • Integrate into CI/CD — tests should run automatically on every push and pull request. Tests that aren’t automated often don’t get run.

← Previous: Web Server   Next: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact