Senior 6 min · March 06, 2026
PHP Unit Testing with PHPUnit

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 & Principal Engineer

20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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.
✦ Definition~90s read
What is PHP Unit Testing with PHPUnit?

PHPUnit is the de facto unit testing framework for PHP, used by projects like Laravel, Symfony, and Drupal to verify that individual units of code behave as expected. It solves the problem of manual regression testing by automating assertions about function outputs, object states, and exception handling.

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.

In the PHP ecosystem, alternatives like Pest offer a more fluent syntax, but PHPUnit remains the standard for its maturity, extensive assertion library, and deep integration with CI pipelines via tools like GitHub Actions or Jenkins. You should not use PHPUnit alone for integration or end-to-end testing—it excels at isolating small code paths, not verifying that your gateway calls, database transactions, or external API interactions complete within real-world time constraints.

A green test suite in PHPUnit gives you confidence that your logic is correct in isolation, but it can actively hide timing bugs when your code depends on external gateways—payment processors, rate-limited APIs, or database connections. PHPUnit’s mock objects and stubs replace real dependencies with deterministic doubles, so a test that passes in 50ms may fail in production when a gateway takes 2 seconds to respond.

The framework’s data providers and code coverage reports help you eliminate duplication and measure what you’ve tested, but they don’t simulate network latency, connection drops, or concurrent request queuing. This is why experienced teams pair PHPUnit with integration tests (using tools like Testbench or Docker containers) and chaos engineering practices to catch the timing bugs that green unit tests systematically miss.

When you structure your test suite with PHPUnit, you typically organize tests into classes mirroring your source code, use setUp() for shared fixtures, and rely on assertions like assertSame() or assertTrue() for clarity. Mocks via PHPUnit’s createMock() or the more expressive Mockery library let you control dependency behavior, but they also abstract away real-world timing—your mock returns instantly, while a real gateway might throttle or timeout.

Data providers reduce duplication by feeding multiple datasets into a single test method, but they don’t help with race conditions or slow responses. Code coverage tools like phpunit-coverage or Xdebug show you which lines execute, but 100% coverage of a controller that calls a gateway doesn’t guarantee the gateway won’t hang your app.

The reality is that green tests are a necessary baseline, not a safety net—you must supplement them with load testing (e.g., k6 or JMeter) and explicit timeout assertions in integration suites to surface the gateway timing bugs that PHPUnit alone will never reveal.

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.

Why PHPUnit Tests Can Miss Gateway Timing Bugs

PHPUnit is a unit testing framework for PHP that isolates individual units of code — typically methods or classes — and verifies their behavior against expected outputs. The core mechanic is assertion: you call a method with known inputs and assert that the return value, side effect, or exception matches a predetermined result. This works well for pure logic but breaks down when code depends on external systems like databases, APIs, or file I/O.

In practice, PHPUnit tests are fast because they run in-memory without real I/O. Developers mock or stub external dependencies to avoid network calls, which makes tests deterministic and quick — often under 100ms per test. However, this speed comes at a cost: mocks simulate perfect, zero-latency responses. Real gateways (payment processors, auth services, message queues) have variable latency, timeouts, and transient failures that mocks never reproduce.

Use PHPUnit for testing business logic, validation rules, and algorithmic correctness — things that are pure functions of their inputs. Do not rely on it to catch timing-related bugs like race conditions, deadlocks, or timeout cascades. Those require integration tests with real dependencies under realistic load. The moment your code touches a network boundary, PHPUnit's green bar gives false confidence about production behavior.

Mocking Hides Reality
A mock that returns instantly will never expose the 200ms latency spike that causes your payment gateway timeout cascade in production.
Production Insight
Teams using 100% mock coverage for a Stripe integration missed that their retry logic had no backoff — when Stripe had a 3-second latency spike, all 50 concurrent requests retried simultaneously, causing a self-inflicted DDoS.
Symptom: intermittent 503s under moderate load, no errors in unit tests, only visible in production metrics as a latency cliff.
Rule of thumb: if a test mocks a network call, you need a separate integration test that hits a real or containerized version of that service under realistic latency.
Key Takeaway
Unit tests verify logic, not timing — a green suite does not mean your system is safe from race conditions or timeouts.
Mocked dependencies are zero-latency fantasies; always validate gateway behavior with integration tests under load.
When a production incident involves timing, your first suspect should be the gap between mock assumptions and real network behavior.
PHPUnit Test Gaps for Gateway Timing Bugs THECODEFORGE.IO PHPUnit Test Gaps for Gateway Timing Bugs How green PHPUnit tests can hide timing-sensitive gateway bugs PHPUnit Test Suite Green tests pass but miss timing issues Mocked Dependencies Mocks control timing, hiding real delays Integration Test Real gateway calls reveal timing bugs Timing Bug Detected Race conditions or timeout failures surface Contract Test Verify gateway behavior under real timing ⚠ Green PHPUnit tests with mocks can mask gateway timing bugs Always add integration tests with real gateways to catch timing issues THECODEFORGE.IO
thecodeforge.io
PHPUnit Test Gaps for Gateway Timing Bugs
Php Unit Testing Phpunit

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.

Installing PHPUnit the Right Way — and Why Composer Is Non-Negotiable

You don't 'download' PHPUnit. You manage it with Composer. Why? Because PHPUnit evolves fast. Your CI pipeline, teammates, and deployment scripts all need the exact same version. A global install on your laptop is a ticking time bomb — I've seen a production rollback caused by a developer running phpunit from /usr/bin while the project required 9.5 and they had 10.0. Never again.

Add it as a dev dependency with composer require --dev phpunit/phpunit. This locks the version in composer.json and composer.lock. Your CI can then run composer install and guarantee consistency. If you need a specific minor, pin it: composer require --dev phpunit/phpunit:^10.5.

Pro tip: alias ./vendor/bin/phpunit to phpunit in your shell profile. Or add a Composer script: "scripts": {"test": "phpunit"}. Now composer test does the right thing every time. No surprises.

setup.shBASH
1
2
3
4
5
6
// io.thecodeforge
composer require --dev phpunit/phpunit:^10.5

# Verify
./vendor/bin/phpunit --version
# Output: PHPUnit 10.5.20 by Sebastian Bergmann and contributors.
Output
PHPUnit 10.5.20 by Sebastian Bergmann and contributors.
Production Trap:
Never run sudo apt install phpunit. The system package is months behind and will silently break your CI when PHP 8.x features are used.
Key Takeaway
Always install PHPUnit per project with Composer dev dependencies. Lock the version. No global binaries.

Integration Testing — Where Mocks Lie and Contracts Break

Unit tests verify single units. Integration tests verify they talk to each other — and to the outside world. But here's the hard truth: mocking everything under the sun makes your integration tests as useful as a chocolate teapot. I've seen a team celebrate green tests while the payment API returned a new error code that their mock never modelled.

Focus integration tests on real interactions with boundaries: databases, HTTP APIs, file systems. Use PHPUnit's createMock() only for slow or non-deterministic dependencies. For everything else, spin up a test database or use a lightweight HTTP stub like httpbin.org.

Example: test a PaymentProcessor that calls a real PaymentGateway interface. Mock the gateway only if it hits a third-party API. Otherwise, use an in-memory implementation. The goal is to catch contract mismatches — not to paint the test suite green.

PaymentProcessingIntegrationTest.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
// io.thecodeforge

use PHPUnit\Framework\TestCase;

class PaymentProcessingIntegrationTest extends TestCase
{
    public function testPaymentProcessesWithRealGatewayStub(): void
    {
        $gateway = $this->createStub(PaymentGateway::class);
        $gateway->method('charge')->willReturn(true);

        $processor = new PaymentProcessor($gateway);

        $result = $processor->process(100.00, 'USD');

        $this->assertTrue($result);
    }
}
Output
PHPUnit 10.5.20 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.012, Memory: 6.00 MB
OK (1 test, 1 assertion)
Key Insight:
Use createStub() for integration tests when you need a thin fake for a boundary. Use createMock() only when you must verify interactions (method calls with specific arguments).
Key Takeaway
Integration tests should mock sparingly. Prefer real implementations for boundaries. Mocks mask contract drift.
● 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?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Advanced PHP. Mark it forged?

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

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