Practical guide
How to write PHPUnit tests that actually matter
Test observable behavior and important risks, not the order of private method calls in an implementation.
In short
A good test is a small business specification
A unit test should be fast and isolated from slow boundaries, but it need not isolate every class from every other class. It verifies a result or agreed communication that is part of the behavior.
Use an integration test where the risk lies in real SQL, ORM mapping, serialization, or framework configuration. Both layers together are more useful than high coverage with meaningless assertions.
Prepare
Choose behavior worth protecting
Start with expensive failures and frequently changed rules, not a mechanical test for every getter.
- PHPUnit installed as a development dependency and one standard command for the full suite.
- The input, observable output, and important business invariant of each scenario.
- Separate unit and integration suites with predictable environments.
- Explicit dependencies that let tests control time, randomness, UUIDs, and external boundaries.
Steps 1 to 3
Write a test from its reason for existing
The name describes the rule, the body prepares minimum data, and the assertion verifies one coherent outcome.
1. Verify behavior with arrange–act–assert
- Name a test as a scenario and expectation, such as paid_order_cannot_be_cancelled.
- Arrange only data relevant to the rule, act through the object’s public API, and assert a result, state, or domain exception.
- Do not call private methods or copy the production algorithm. A refactor with unchanged behavior should stay green.
- Use a named data provider for boundaries, equivalence classes, and multiple inputs so the failing case is visible.
vendor/bin/phpunit --testdox tests/Unit Official PHPUnit guide to writing tests 2. Mock only real boundaries
- Use a stub to control indirect input such as a payment gateway response. Use a mock only when the communication itself is the required output.
- Do not mock value objects, entities, or every internal service. A web of exactly(1) expectations often locks tests to irrelevant implementation details.
- Replace time with a Clock interface, randomness with a generator, and an external API with a small owned port to keep tests deterministic.
- Add an integration or contract test for an important adapter because a mock cannot verify the real protocol and mapping.
$gateway = $this->createStub(PaymentGateway::class); Official PHPUnit test doubles documentation 3. Layer tests by risk
- Test pure rules and value objects as units. Test repositories against the same database and constraints as production.
- An HTTP test covers an important request-to-response path; a few end-to-end scenarios verify critical flows without duplicating every variant.
- Every test owns its data and leaves a known state. Do not depend on test order, a local time zone, or the network.
- Measure and repair slow tests. Parallelize only after removing shared files, ports, and global database identities.
vendor/bin/phpunit --testsuite=unit,integration Official PHPUnit guide to organizing tests Step 4
Verify that the test protects a defect
A green test can be worthless if it still passes after the rule is broken.
-
Temporarily break the condition
The right test fails with a readable name and difference. Then restore the production change.
vendor/bin/phpunit --testdox -
Run repeatedly in random order
The result does not change with order, time, local environment, or leftover data.
vendor/bin/phpunit --order-by=random -
Perform a safe refactor
Changing internal structure without changing public behavior does not require rewriting half the mock expectations.
Troubleshooting
Common problems
Tests fail only sometimes
Find uncontrolled time, randomness, order, shared database state, or network access. Expose and set that dependency deterministically.
vendor/bin/phpunit --order-by=random Every refactor breaks dozens of tests
Tests verify internal calls. Move assertions to public outcomes and mock only meaningful outbound boundaries.
Coverage is high but regressions keep appearing
Coverage does not show whether an assertion protects a rule. Add boundary and failure scenarios based on risk.
The integration suite is too slow
Profile it, reduce bootstrap and fixtures, use transaction isolation, and pick the cheapest layer that catches the risk.
Done
Tests describe rules, not implementation noise.
Unit and integration tests now protect the right risks, use test doubles sparingly, and give readable feedback on regression.