PHP Testing
A suite buys one thing: the ability to change code and learn about the breakage in seconds rather than from a user. It buys that exactly as far as the tests would actually catch a defect. That is the fault line of the whole topic — coverage tools answer "which lines executed", not "which fault would have been noticed", and a suite containing not a single assert will honestly report full coverage. Everything else here is a way of closing the gap between those two questions.
The traps go up front, because nearly every one of them looks like success. A mock encodes your assumption about someone else's dependency — and stays green once that assumption stops being true. RefreshDatabase rolls a transaction back, so a commit issued by the code under test escapes the rollback, and a second connection — a queue worker, a browser driver — simply cannot see the uncommitted rows. In-memory SQLite is not a fast MySQL, it is a different engine with a different dialect: a green run on it says nothing about the queries production will actually issue. And a failing snapshot gets "regenerated" — quietly approving the regression. The layer-by-layer breakdown is below.
Topic map
- What a test is — the Arrange–Act–Assert structure, the act-once rule, and why you assert on behaviour rather than implementation.
- PHPUnit — lifecycle and data providers — a fresh test-class instance per method,
setUp/tearDowninstead of the constructor, and the provider that names the failing input. - Pest — a syntax layered on PHPUnit — a closure DSL executed by the very same engine, and why no big-bang migration is needed.
- Mocking dependencies — stub, mock and spy, the seam for substitution, an injected clock, and the price of over-mocking.
- Unit and feature tests in Laravel — what boots the framework and what does not, fakes at the dispatch boundary, and the cost of snapshots.
- The test database — how
RefreshDatabaserolls a transaction back, where the rollback silently fails, and whySQLiteis a different engine. - Mutation testing — how
Infectiondiffers from coverage and what a surviving mutant means. - Static analysis — what
PHPStanfinds without executing anything, and why neitherstrict_typesnor tests replace it. - Coding standards — PSR-1, PSR-12 and PSR-4,
phpcs/phpcbf, and the three levels of control.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
| Reading full coverage as proof a regression would be caught | Coverage measures execution, not detection — a suite with no assertions reports 100% |
| Calling several methods in the Act phase | A red test has more than one possible cause — the failure stops localizing the defect |
| Asserting on the fixture you arranged instead of the observed outcome | The test is always green: it checks itself |
Building the fixture in the test class's constructor instead of setUp() | The constructor sits outside the lifecycle — no paired teardown, and its failure is not reported as an ordinary test failure |
Looping over the input cases with foreach inside one test | The first failing case hides every later one, and the report never names the input |
Believing Pest is a separate engine | You expect different semantics and a big-bang migration where only the authoring syntax changes |
| Asserting on interactions everywhere a stub would have done | The test is nailed to the implementation and goes red on every harmless refactor |
Freezing a global clock instead of injecting a Clock | State leaks across tests and breaks parallel runs |
Testing a class that news its dependency inside | There is no seam — nowhere to plug the double, and the class is untestable in isolation |
| Treating a mock as evidence about someone else's API | The mock encodes your assumption about the provider; it will not notice the contract changing |
Thinking RefreshDatabase truncates the tables | It wraps the test in a transaction and rolls it back — a commit from the code escapes the rollback |
| Expecting a queue worker or a browser driver to see the test's data | A second connection cannot see uncommitted rows — the test hangs or fails for no visible reason |
Running the suite on in-memory SQLite with MySQL in production | A different engine and dialect — a green run says nothing about the queries production will run |
| Regenerating a failing snapshot by reflex | The regression is quietly approved: a snapshot asserts on everything, including what you never meant to change |
| Confusing a static analyser with a style linter | phpcs watches style, PHPStan watches types and reachability; strict_types does not replace it |
| Naming the PSR-7 transport spec as the style standard | Style is set by PSR-1 and PSR-12; PSR-7 describes HTTP-message interfaces |
Interview relevance
Testing is not probed by asking "do you write tests", but by asking "what does your suite prove, and what does it fail to catch". A candidate who can name the boundaries of their own suite — "coverage shows execution, so I run Infection over the changed files", "a mock of the provider proves nothing about the provider, so the contract is verified on their side" — is immediately apart from the one who answers with a coverage percentage.
Typical checks:
- The three AAA phases and why there must be exactly one act.
- The PHPUnit lifecycle — a fresh instance per test,
setUpversus the constructor. - What a data provider does and how it reports the failure of a specific input.
- What
Pestactually is relative to PHPUnit, and what it changes. - The difference between stub, mock and spy, and the price of interaction checks.
- How to make code that calls
time()deterministic, and why not through a global. - The line between a Laravel unit test and a feature test, and what each one proves.
- What
RefreshDatabasereally does and where it silently breaks. - Why
SQLitein tests withMySQLin production is not an optimization but a loss of evidence. - What mutation testing measures on top of coverage.
- Which defects a static analyser finds without ever running the code.
Common wrong answer: "we have 90% coverage, so we catch regressions." That is usually where the conversation starts: coverage records that a line ran, not that anyone would have noticed it breaking — a suite with every assert commented out reports the same 90%. The second classic failure is "RefreshDatabase cleans the database before each test": it wraps the test in a transaction and rolls it back, which is exactly why it is fast, and exactly why it is powerless against code that commits on its own.