Senior 3 min · March 06, 2026

PHPUnit — Green Tests Hide Gateway Timing Bugs

All PHPUnit tests pass but staging payments time out? Mock returned instantly — real gateway delays up to 30s.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • PHPUnit verifies individual units of PHP code in isolation, not your full stack.
  • Mocks replace real dependencies (DB, API) to test logic without side effects.
  • Data providers run one test method with many input sets — kills duplication.
  • Code coverage tells you what's exercised, not what's correct.
  • CI integration catches regressions before merge — pointless without a failing test gate.
Plain-English First

Imagine you're building a car, but instead of waiting until the whole car is assembled to test whether it drives, you test each individual part on a workbench — the engine alone, the brakes alone, the steering alone. PHPUnit is that workbench for PHP code. It lets you prove, in isolation, that each small piece of your application works exactly as promised — before you bolt everything together and ship it to real users.

Every PHP application eventually reaches a point where a single 'harmless' change in one file breaks something completely unrelated three layers deep. That's not bad luck — that's the natural consequence of untested code. PHPUnit is the industry-standard testing framework for PHP, and at an advanced level it's not just about writing a few assert statements. It's about architecting a safety net so tight that you can refactor aggressively, onboard new developers fearlessly, and deploy on a Friday afternoon without your stomach dropping.

The real problem PHPUnit solves isn't catching bugs after the fact — it's making bugs impossible to hide in the first place. Without a proper test suite, every code review is guesswork, every deployment is a gamble, and 'it works on my machine' becomes a team-wide coping mechanism. PHPUnit forces your code to be testable, which in turn forces it to be modular, loosely coupled, and honest about its dependencies. The act of writing tests often reveals design flaws that code review misses entirely.

By the end of this article you'll know how to structure a professional PHPUnit test suite from scratch, mock external dependencies so your unit tests never hit a database or API, leverage data providers to eliminate test duplication, enforce meaningful code coverage thresholds, and wire everything into a CI/CD pipeline. You'll also walk away knowing the edge cases and anti-patterns that trip up even experienced PHP developers.

Setting Up PHPUnit and Structuring Your Test Suite

PHPUnit ships via Composer: composer require --dev phpunit/phpunit. Your tests live in a tests/ directory mirroring the source structure. A typical phpunit.xml configures autoloading, coverage filters, and test suites.

Start with a base test case that extends PHPUnit\Framework\TestCase. Name your test class with the same name as the class under test, plus a Test suffix. The convention in the PHP ecosystem is SomeClassTest. Use namespaces that match the source:

```php // src/Service/PaymentProcessor.php namespace io\thecodeforge\Service;

class PaymentProcessor { ... }

// tests/Service/PaymentProcessorTest.php namespace io\thecodeforge\Test\Service;

use PHPUnit\Framework\TestCase; use io\thecodeforge\Service\PaymentProcessor;

class PaymentProcessorTest extends TestCase { ... } ```

This structure makes autoloading trivial and keeps production and test code side by side.

phpunit.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
         colors="true"
         cacheResult="false"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>
</phpunit>
Use `--prefer-dist` for speed
Running composer install with --prefer-dist downloads stable packages faster. On CI, cache the vendor/ directory to skip downloads.
Production Insight
If your phpunit.xml sets cacheResult="true", stale cache can block test discovery after renaming a test class.
Clear it with phpunit --clear-cache or disable caching in CI pipelines.
Rule: treat phpunit.xml as a checked-in configuration, not a local-only file.
Key Takeaway
A well-namespaced test mirror structure removes guesswork.
Keep phpunit.xml in version control.
Test files should not require manual loading — Composer handles it.
Which Test Suite Structure to Use
IfSmall project with <10 classes
UseUse a single tests/ directory with flat test files.
IfApplication follows DDD or layered architecture
UseMirror the src/ structure in tests/ and split Unit, Integration, Functional suites.
IfMonorepo with multiple packages
UseEach package has its own tests/ and phpunit.xml — root config aggregates them.

Writing Expressive Assertions and Organising Tests

PHPUnit provides over 100 assertion methods. Beyond assertTrue() and assertEquals(), the expressive ones reduce test reading time:

  • assertCount(3, $items) — clearer than assertEquals(3, count($items))
  • assertContains('apple', $fruits) — explicit intent
  • assertInstanceOf(SomeInterface::class, $obj) — type checks
  • assertArrayHasKey('id', $data) — avoids undefined index warnings

For order-dependent tests, use @depends to pass results between test methods. But be careful — a failed test in a chain marks all dependent tests as skipped, not failed. That masks failures.

tests/Unit/OrderCalculatorTest.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php
namespace io\thecodeforge\Test\Unit;

use PHPUnit\Framework\TestCase;
use io\thecodeforge\Service\OrderCalculator;

class OrderCalculatorTest extends TestCase
{
    public function test_discount_above_threshold(): void
    {
        $calc = new OrderCalculator();
        $total = $calc->applyDiscount(200.0); // 10% off
        $this->assertEqualsWithDelta(180.0, $total, 0.01);
    }

    public function test_invalid_quantity_throws(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $calc = new OrderCalculator();
        $calc->setQuantity(-1);
    }

    /** @depends test_discount_above_threshold */
    public function test_discount_chain(): void
    {
        $this->assertTrue(true); // placeholder
    }
}
`@depends` creates hidden coupling
When a test higher in the chain fails, downstream tests are skipped — not failed. That hides real regressions. Prefer independent test methods unless absolutely necessary.
Production Insight
Floating-point comparisons with assertEquals on doubles will randomly fail due to precision.
Use assertEqualsWithDelta with a tiny delta (0.0001).
Your CI logs will thank you when the numbers are identical to 15 decimal places but one is 8.9999999999999999.
Key Takeaway
Use expressive assertions to encode intent.
Avoid @depends chains — they mask failures.
Floating-point comparisons always need a delta.

Mocks and Test Doubles — Controlling Dependencies

Unit tests must never hit external systems. That's where mocks, stubs, and spies come in. PHPUnit's built-in createMock() generates a proxy that returns default values for all methods. You configure expectations with expects() and willReturn().

But there's a critical distinction
  • Stub: returns canned answers but doesn't assert behaviour.
  • Mock: asserts that specific methods were called with specific arguments.
  • Spy: wraps the real object and records interactions (PHPUnit doesn't have built-in spies, but you can use willReturnCallback to log calls).

Never mock what you don't own — mock interfaces, not concrete classes. This keeps tests decoupled from implementation details. If you mock a concrete class, a new __construct argument or a renamed method breaks your mock silently.

tests/Unit/PaymentProcessorTest.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php
namespace io\thecodeforge\Test\Service;

use PHPUnit\Framework\TestCase;
use io\thecodeforge\Service\PaymentProcessor;
use io\thecodeforge\Client\PaymentGatewayInterface;

class PaymentProcessorTest extends TestCase
{
    public function test_charge_success(): void
    {
        $gateway = $this->createMock(PaymentGatewayInterface::class);
        $gateway->expects($this->once())
                ->method('charge')
                ->with(49.99, 'USD')
                ->willReturn('txn_abc123');

        $processor = new PaymentProcessor($gateway);
        $result = $processor->processPayment(49.99, 'USD');

        $this->assertSame('txn_abc123', $result);
    }

    public function test_retry_on_timeout(): void
    {
        $gateway = $this->createMock(PaymentGatewayInterface::class);
        $gateway->expects($this->exactly(2))
                ->method('charge')
                ->willReturnOnConsecutiveCalls(
                    $this->throwException(new \RuntimeException('timeout')),
                    'txn_def456'
                );

        $processor = new PaymentProcessor($gateway, maxRetries: 1);
        $result = $processor->processPayment(99.99, 'EUR');

        $this->assertSame('txn_def456', $result);
    }
}
Mocks as surveillance cameras
  • It records every call made to the dependency.
  • It compares actual calls against your expectations.
  • If your subject doesn't call the dependency as expected, the camera 'alerts' — the test fails.
  • Stubs, by contrast, are just stunt doubles that say the right thing but don't report back.
Production Insight
Mocking a concrete class that has side effects in its constructor can give false positives.
Example: new Logger() writes to a file — your mock never touches disk.
Always mock interfaces or abstract classes. Use createStub() if you don't need call assertions.
Key Takeaway
Mock interfaces, not concrete classes.
Use expects() only when behaviour must be verified.
Stubs fake data; mocks assert contracts.
When to Use Each Test Double
IfNeed to fake a return value without checking how it's used
UseUse a stub — createStub(Interface::class) and method('foo')->willReturn('bar')
IfMust verify method was called with specific arguments
UseUse a mock with expects() and with()
IfNeed to wrap a real object and record interactions
UseUse a spy (not built-in) — implement by composing the real object and logging calls in a callback

Data Providers — Eliminate Test Duplication

Data providers are methods annotated with @dataProvider that return an array of arrays. Each inner array is unpacked as arguments to the test method. This lets you test the same logic across many input sets without writing separate test methods.

A common mistake: loading data providers from a file or database. Keep them static and deterministic — the test should never depend on external state. If you need a large set of test data, generate it with a helper function, but keep the provider method itself simple.

You can also chain data providers with @depends, but that's rarely useful. Prefer independent tests with clear provider names.

tests/Unit/TaxCalculatorTest.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
namespace io\thecodeforge\Test\Service;

use PHPUnit\Framework\TestCase;
use io\thecodeforge\Service\TaxCalculator;

class TaxCalculatorTest extends TestCase
{
    /** @dataProvider taxableAmountProvider */
    public function test_calculate_tax(
        float $amount,
        string $country,
        float $expectedTax
    ): void {
        $calc = new TaxCalculator();
        $tax = $calc->calculate($amount, $country);
        $this->assertEqualsWithDelta($expectedTax, $tax, 0.001);
    }

    public static function taxableAmountProvider(): array
    {
        return [
            'US standard'     => [100.00, 'US', 7.00],
            'US exempt'       => [0.00,   'US', 0.00],
            'DE standard'     => [100.00, 'DE', 19.00],
            'UK reduced'      => [100.00, 'UK', 5.00],
            'zero amount'     => [0.00,   'DE', 0.00],
            'negative amount' => [-50.00, 'US', 0.00],
        ];
    }
}
Name your data sets
Adding a string key to each data set (like 'US standard') makes failure output vastly more readable: 'TaxCalculatorTest::test_calculate_tax with data set "US standard"'. Without keys, you get 'with data set #3' — much harder to debug.
Production Insight
Data providers that return 10,000 rows will kill CI performance and memory.
Segment your tests: use @group=smoke for a small representative sample and run the full suite nightly.
Use @large annotation for tests with huge data providers — they'll run in a separate process.
Key Takeaway
Name your data sets for readable failures.
Keep providers deterministic — no file I/O.
Segment large providers into groups to preserve CI speed.

Code Coverage, CI Integration, and the Reality of 'Green' Tests

Code coverage reports show which lines you executed, not which paths you tested. A 90% coverage number doesn't mean your code is 90% bug-free — it means 90% of lines ran. The remaining 10% could hide the worst bugs.

Set coverage thresholds in phpunit.xml using <coverage><requireCoverage>true</requireCoverage></coverage>. But more important: enforce that new code must reach a minimum coverage. Use a tool like phpmd or infection (mutation testing) to go deeper.

CI integration is straightforward: run vendor/bin/phpunit in your pipeline. Upload coverage reports to services like Codecov or Coveralls. Critical: make the pipeline fail on coverage drop. Otherwise coverage drifts down sprint after sprint.

.github/workflows/tests.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: PHPUnit
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          coverage: xdebug
      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/phpunit --coverage-clover coverage.xml
      - uses: codecov/codecov-action@v4
        with:
          files: coverage.xml
          fail_ci_if_error: true
Coverage is a floor, not a ceiling
High coverage with weak assertions is worse than moderate coverage with meaningful assertions. Always review what the tests actually check.
Production Insight
Coverage dropping from 85% to 84% after a PR seems harmless — but over five sprints that's 75%.
Enforce a hard threshold with a CI check. Use minimum-php-version and coverage-min in phpunit.xml.
Rule: coverage should only go up. Accept no PR that decreases it without a documented exception.
Key Takeaway
Coverage is a measure of execution, not correctness.
Fail the CI pipeline on coverage drops.
Combine coverage with mutation testing for real confidence.
● Production incidentPOST-MORTEMseverity: high

The Mock That Hid a Real Bug for Three Sprints

Symptom
All unit tests green. But in staging, payment confirmations randomly timed out when the gateway was slow — something the mock never simulated.
Assumption
The team assumed that mocking the payment gateway with a fixed response was enough. They didn't model timeouts or retries.
Root cause
The mock always returned instantly. The real gateway could delay up to 30 seconds. The service code timed out because it never received the delayed response, but the mock never exercised that path.
Fix
Add a configurable delay to the mock. Write an integration test that uses a real (but sandbox) gateway for the timeout scenario. Keep unit tests fast, but cover real timing with contract tests.
Key lesson
  • Mocks that always succeed hide production failure modes.
  • Always model realistic timing and retry behaviours in your test doubles.
  • A green unit test suite is not proof your code works — it's proof your mocks are consistent with your assumptions.
Production debug guideSymptom -> Action guide4 entries
Symptom · 01
Test passes locally but fails in CI
Fix
Check environment differences: PHP version, extensions, file permissions, and test order dependency (use @depends or @group).
Symptom · 02
Mock expectations fail (too few invocations)
Fix
Add debug output to the mock using andReturnCallback to log calls. Verify your test actually invokes the mocked method.
Symptom · 03
Data provider produces huge output — test times out
Fix
Limit data provider to a subset locally. Use --filter to run one test case. Profile with Xdebug if needed.
Symptom · 04
Code coverage report shows 0% for a module
Fix
Ensure Xdebug or PCOV is enabled. Check that the test class use statements cover the production class. Run phpunit --coverage-html manually.
★ PHPUnit Quick Debug Cheat SheetUse these commands and fixes when your test suite misbehaves.
Memory exhaustion running all tests
Immediate action
Limit memory with `php -d memory_limit=512M vendor/bin/phpunit`
Commands
php -d memory_limit=512M vendor/bin/phpunit --group=fast
phpunit --verbose --debug tests/
Fix now
Disable coverage (add --no-coverage) or split test suite into groups.
Mock method never called+
Immediate action
Print the actual calls: `$mock->method('foo')->willReturnCallback(function($args) { var_dump(func_get_args()); return 'mock'; });`
Commands
phpunit --filter testMethod tests/Unit/MyTest.php
phpunit --testdox tests/
Fix now
Check your test's call path — the mock may not be reached due to early return in production code.
Data provider generates 5000 test cases+
Immediate action
Temporarily reduce provider size by adding `array_slice($data, 0, 10)`
Commands
phpunit --filter testMethod#0 tests/
phpunit --group=smoke
Fix now
Use @small annotation and cluster data into groups if full run is needed.
PHPUnit Test Double Types Compared
TypeUse CasePerformance ImpactMaintenance Cost
Stub (createStub)Replace dependency that returns data but you don't care about call countFast — zero call trackingLow — method signature changes break silently
Mock (createMock + expects)Verify contract: method called with specific arguments, countSlightly slower due to call trackingMedium — fragile if production code refactors call order
Partial Mock (createPartialMock)Override specific methods of a real object, keep othersSimilar to mock, but construction overheadHigh — ties test to implementation details
Spy (manual via callback)Record all calls for later analysis without upfront expectationsDepends on call volumeMedium — requires custom wiring

Key takeaways

1
PHPUnit verifies code isolation
mocks and stubs are the tools, not the goal.
2
Mock interfaces, not concrete classes. Couple mocks to contracts.
3
Name your data provider keys
debugging 'data set #4' is painful.
4
Code coverage does not measure test quality. Pair with mutation testing.
5
CI pipeline must fail on coverage drops, not just celebrate green numbers.

Common mistakes to avoid

4 patterns
×

Mocking concrete classes instead of interfaces

Symptom
Tests pass locally but fail after a dependency update. The mock object doesn't reflect the new constructor signature or method changes.
Fix
Extract an interface for every external dependency and mock that interface. Use interface-only mocking in all unit tests. If the library doesn't provide an interface, wrap it in an adapter class.
×

Using data providers that read from external files

Symptom
Tests intermittently fail when CI has slightly different file system state. Provider loads stale data because file wasn't refreshed.
Fix
Hardcode test data inside the provider method. If the data set is large, generate it with a static helper but never read from disk or network at test execution time.
×

Treating coverage percentage as a quality metric

Symptom
Team achieves 95% coverage but still ships critical bugs. Tests pass without meaningful assertions (e.g., 'assertTrue(true)').
Fix
Enforce assertion density: use PHPStan or a custom rule that each test method must have at least one assertion. Combine coverage with mutation testing to measure test effectiveness.
×

Skipping integration tests because unit tests pass

Symptom
Everything works with mocks but fails when real services are wired together. Database connection, API authentication, or ordering issues surface in staging.
Fix
Maintain a separate integration test suite that uses real (but isolated) dependencies — e.g., test databases, sandbox APIs. Run it on pull request merge, but not on every commit to keep feedback fast.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a mock and a stub in PHPUnit?
Q02SENIOR
Why should you mock interfaces rather than concrete classes?
Q03SENIOR
How do data providers affect test performance and how would you handle a...
Q04SENIOR
Describe a production incident caused by a poorly designed mock. What wa...
Q01 of 04JUNIOR

What is the difference between a mock and a stub in PHPUnit?

ANSWER
A stub replaces a dependency with a fake that returns canned answers — you don't verify how it's called. A mock replaces a dependency and asserts that specific methods were called with exact arguments and counts. PHPUnit's createMock() creates a mock by default; createStub() creates a stub. Use mocks for behaviour verification, stubs for data supply.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is PHPUnit and why should I use it?
02
Can I test code that uses global functions or static calls?
03
How do I test code that interacts with a database?
04
What is the best way to organise tests in a large application?
🔥

That's Advanced PHP. Mark it forged?

3 min read · try the examples if you haven't

Previous
REST API with Pure PHP
7 / 13 · Advanced PHP
Next
WebSockets in PHP