PHP Testing
Testing PHP code — coding standards (PSR), the PHPUnit/Pest toolchain, and mocking external dependencies through injected seams.
16 questions
JuniorTheoryVery commonWhat standards and tools exist for testing PHP, and how do you mock dependencies?
What standards and tools exist for testing PHP, and how do you mock dependencies?
Coding standards are the PSR family — PSR-1 and PSR-12 — checked with tools like PHP_CodeSniffer. The main test runners are PHPUnit and Pest. To isolate code from external dependencies you mock them: PHPUnit's createMock(), the Mockery library, or test doubles injected through a constructor (DI) seam. You assert against the double instead of hitting a real network or database.
Common mistakes
- ✗Naming a transport spec like PSR-7 as the code-style standard instead of PSR-1/PSR-12
- ✗Testing a class that news-up its own dependencies, leaving no seam to inject a mock
- ✗Mocking value objects or pure functions that have no external side effect to stub
Follow-up questions
- →Why is constructor injection easier to mock than a dependency created with
newinside a method? - →What is the difference between a stub that returns canned data and a mock that asserts calls?
JuniorTheoryVery commonIn Laravel, what is the difference between a unit test and a feature test?
In Laravel, what is the difference between a unit test and a feature test?
A unit test exercises one class in isolation, with its collaborators replaced by doubles, and by default does not boot the framework. A feature test boots the application, so it can hit a route with $this->get('/orders'), touch the database and assert on the HTTP response. Feature tests prove the wiring; unit tests pinpoint the fault.
Common mistakes
- ✗Putting a route-level test in
tests/Unitand wondering why the container is not booted - ✗Mocking so much inside a feature test that it no longer proves the wiring works
- ✗Assuming a feature test needs a browser driver to exercise a route
Follow-up questions
- →Which base
TestCasedoes a Laravel unit test extend, and what does that change? - →When is a feature test the wrong tool and a unit test the right one?
JuniorTheoryCommonWhat are the three phases of the Arrange-Act-Assert test structure?
What are the three phases of the Arrange-Act-Assert test structure?
Arrange builds the object under test and its inputs. Act invokes exactly one behaviour on it, usually a single method call. Assert compares the observed outcome with the expectation. Keeping the phases separate and acting once leaves the test a single reason to fail, so a red test tells you precisely which call broke.
Common mistakes
- ✗Calling several methods in the Act phase, so a failure has more than one possible cause
- ✗Asserting on the fixture that was arranged rather than on the observed outcome
- ✗Treating it as a rule PHPUnit enforces rather than a structuring convention
Follow-up questions
- →How does the Given-When-Then wording of behaviour-driven tests map onto these phases?
- →Why is one logical assertion per test usually better than several unrelated ones?
JuniorTheoryCommonHow does the test framework Pest differ from PHPUnit, and what runs the tests?
How does the test framework Pest differ from PHPUnit, and what runs the tests?
Pest is a DSL layered on PHPUnit, not a separate engine: you write it('...', fn() => ...) closures instead of methods on a TestCase, and PHPUnit still executes them. Its assertions, mocks and configuration keep working, and classic TestCase classes sit in the same suite. The authoring style changes, not the runner.
Common mistakes
- ✗Thinking Pest replaces the PHPUnit engine rather than wrapping it
- ✗Assuming a project must migrate every test at once to adopt Pest
- ✗Expecting different test semantics rather than a different authoring syntax
Follow-up questions
- →How do you drop into a classic PHPUnit
TestCasefrom inside a Pest test file? - →What does Pest's expectation API give you that PHPUnit's
assert*methods do not?
JuniorTheoryCommonWhat does a static analyser like PHPStan check without ever running the code?
What does a static analyser like PHPStan check without ever running the code?
It parses the source and reasons about types and reachability without executing anything: calls to undefined methods, wrong argument and return types, conditions that can never be true, unreachable code, missing null checks. It also reads docblock generics the engine ignores. Its strictness is a level you raise gradually on legacy code.
Common mistakes
- ✗Believing a static analyser executes the code or needs a booted application
- ✗Confusing it with a formatter or style linter such as PHP_CodeSniffer
- ✗Turning on the strictest level in a legacy codebase instead of raising it gradually
Follow-up questions
- →What does a baseline file let you do when adding PHPStan to a legacy codebase?
- →How do generics written in a docblock change what the analyser is able to prove?
MiddleTheoryCommonWhat does a PHPUnit data provider do, and how does it report a failing case?
What does a PHPUnit data provider do, and how does it report a failing case?
A data provider is a method returning an iterable of argument sets. PHPUnit runs the test once per set, passing each one as the test method's parameters, and reports every set as a separate test with its own name — so a failure names the exact input. A foreach inside a single test would stop at the first bad case and hide all the rest.
Common mistakes
- ✗Looping over the cases inside one test, so the first failure hides every later case
- ✗Expecting the provider to run after the test rather than before it
- ✗Returning rows whose shape does not match the test method's parameter list
Follow-up questions
- →How do named keys in the returned array change the failure output?
- →Why can a data provider not use a fixture created in
setUp()?
MiddleTheoryCommonWhat is the difference between a stub, a mock and a spy as test doubles?
What is the difference between a stub, a mock and a spy as test doubles?
A stub returns canned data and verifies nothing — it only lets the code under test run. A mock sets call expectations up front, such as expects($this->once()), and fails when they are not met. A spy records calls so you assert on them after the act. Interaction checks couple the test to how the code calls its collaborators.
Common mistakes
- ✗Reaching for a mock with strict call expectations where a data-returning stub would do
- ✗Asserting on interactions everywhere, so the tests break on every harmless refactor
- ✗Thinking a stub verifies something — it only supplies data
Follow-up questions
- →When is a hand-written fake better than a generated mock?
- →Why does asserting on interactions make a test brittle under refactoring?
MiddleDesignCommonYour Laravel suite runs against an in-memory SQLite database so CI (continuous integration) stays fast, and it is green on every pull request. Production runs MySQL. A release then fails on a migration that SQLite happily accepted, and a query that returned rows in the tests returns none in production. Decide what the test database should be, and justify the trade-off between suite speed and the confidence the suite actually gives you.
Your Laravel suite runs against an in-memory SQLite database so CI (continuous integration) stays fast, and it is green on every pull request. Production runs MySQL. A release then fails on a migration that SQLite happily accepted, and a query that returned rows in the tests returns none in production. Decide what the test database should be, and justify the trade-off between suite speed and the confidence the suite actually gives you.
SQLite is a different engine, not a fast MySQL: different dialect, looser typing, different constraint and date behaviour. Green on SQLite proves nothing about the queries production will run — that is the failure you hit. Test against the same engine as production, and keep it fast with transaction rollback in CI.
Common mistakes
- ✗Treating SQLite as a fast drop-in for MySQL rather than a different engine
- ✗Reading a green suite as proof the production queries work
- ✗Papering over the divergence with driver flags instead of running the production engine
Follow-up questions
- →Which classes of bug does SQLite hide that a real MySQL surfaces immediately?
- →How do you keep a MySQL-backed suite fast enough to run on every pull request?
JuniorTheoryOccasionalWhy does PHPUnit put fixture creation in setUp() instead of the constructor?
Why does PHPUnit put fixture creation in setUp() instead of the constructor?
PHPUnit builds a fresh test-class instance for every test method, runs setUp() before it and tearDown() after. The constructor runs outside that lifecycle: it has a fixed signature you may not break, it has no paired teardown, and a failure in it is not reported as an ordinary test failure. setUp() is the supported seam.
Common mistakes
- ✗Assuming one test-class instance is shared across all of its test methods
- ✗Overriding the constructor and breaking the signature PHPUnit expects
- ✗Building a fixture with no paired
tearDown(), leaking state into the next test
Follow-up questions
- →How does
setUp()differ from the staticsetUpBeforeClass()hook? - →When does
tearDown()not run, and what should you not rely on it for?
MiddleCodeOccasionalHow do you make a class that calls time() deterministic in a test?
How do you make a class that calls time() deterministic in a test?
Inject a clock instead of calling the global. Declare a Clock interface with now(): int, take it in the constructor and call $this->clock->now(). Production wires a SystemClock that returns time(); the test passes a frozen clock with a fixed timestamp. No global is touched, so the tests stay isolated and can still run in parallel.
Common mistakes
- ✗Freezing or overriding a global clock, leaking state across tests and breaking parallel runs
- ✗Stubbing the class under test itself, so the behaviour being tested never executes
- ✗Reading the clock deep inside the method, leaving no seam where a test can substitute it
Follow-up questions
- →How does the clock interface standardised by PSR-20 make this seam reusable across libraries?
- →Why does a frozen global clock break a parallel test run?
MiddleTheoryOccasionalWhat does the Laravel RefreshDatabase trait do, and when does it silently fail?
What does the Laravel RefreshDatabase trait do, and when does it silently fail?
It wraps each test in a database transaction and rolls it back at the end, so the schema is migrated once and every test starts from the same state without truncating tables. That is why it is fast. It breaks when the code under test commits, or when a second connection — a queue worker, a browser — cannot see the uncommitted rows.
Common mistakes
- ✗Assuming it truncates or re-migrates per test rather than rolling back a transaction
- ✗Using it with code that commits its own transaction, which escapes the rollback
- ✗Expecting a second connection or an external process to see the uncommitted test rows
Follow-up questions
- →How does the
DatabaseTruncationtrait differ, and when would you switch to it? - →Why do browser tests need a different database strategy from
RefreshDatabase?
MiddleTheoryOccasionalIn CI (continuous integration), how does a static analyser complement runtime types and tests?
In CI (continuous integration), how does a static analyser complement runtime types and tests?
They cover different windows. A runtime type declaration only fires on a line a request actually reaches; a test only proves the paths it exercises. A static analyser checks every path in the source before anything runs, and understands docblock generics the engine cannot express. CI runs all three.
Common mistakes
- ✗Thinking
strict_typesmakes a static analyser redundant - ✗Expecting the analyser to prove behaviour — it checks types and reachability, not outcomes
- ✗Assuming the analyser executes the code or needs a booted application
Follow-up questions
- →What kind of defect can a test catch that a static analyser never will?
- →Why does raising the PHPStan level surface errors in code you did not touch?
MiddleTheoryRareWhat does the mutation-testing tool Infection measure that line coverage cannot?
What does the mutation-testing tool Infection measure that line coverage cannot?
Line coverage only records which lines executed. Infection mutates the source — flips a > into >=, negates a condition, drops a return — reruns the suite and asks whether any test now fails. A surviving mutant is a defect your tests would not have caught. A suite with no assertions still reports full coverage.
Common mistakes
- ✗Reading full line coverage as proof the tests would catch a regression
- ✗Taking a surviving mutant as a pass instead of an undetected defect
- ✗Running mutation testing over the whole codebase each commit rather than the changed files
Follow-up questions
- →Why is a mutation run so much slower than an ordinary coverage run?
- →What is an equivalent mutant, and why can it never be killed?
SeniorDesignRareYour PHP service consumes a REST API owned by another team, and your suite replaces that HTTP client with a mock. The suite stayed green after the provider renamed a response field, and production broke on the next deploy. The provider will not run your test suite, and you will not call their staging environment on every commit. Propose a testing approach that keeps your own suite fast and double-based yet still turns red when the provider's contract changes, and say who owns which part of it.
Your PHP service consumes a REST API owned by another team, and your suite replaces that HTTP client with a mock. The suite stayed green after the provider renamed a response field, and production broke on the next deploy. The provider will not run your test suite, and you will not call their staging environment on every commit. Propose a testing approach that keeps your own suite fast and double-based yet still turns red when the provider's contract changes, and say who owns which part of it.
A mock encodes your assumption about the provider, and nothing checks that the assumption still holds. Add consumer-driven contract testing: your suite records the requests it makes and the responses it expects as a pact, and the provider replays that pact against its real implementation in its own pipeline. Your suite stays fast; their build goes red when they break you.
Common mistakes
- ✗Trusting a mock as evidence about a system you do not own
- ✗Replacing the doubles with live provider calls, making the suite slow and flaky
- ✗Leaving the contract check out of the provider's pipeline, so nothing ever warns them
Follow-up questions
- →What does a pact broker add over committing the pact file into the provider's repository?
- →How do you version a pact so the provider can keep supporting an older consumer?
SeniorTheoryRareWhat does snapshot testing a view rendered by the template engine Blade buy and cost?
What does snapshot testing a view rendered by the template engine Blade buy and cost?
It renders the view, compares the output with a committed snapshot file and fails on any difference. That is cheap to write and catches unintended markup changes across a large view layer. The cost is that it asserts on everything: a legitimate whitespace or class-name edit fails too, and the reflex is to regenerate the snapshot — quietly approving a regression.
Common mistakes
- ✗Regenerating a failing snapshot by reflex, thereby approving a real regression
- ✗Snapshotting a whole page, so every cosmetic edit produces a failure
- ✗Reading a passing snapshot as proof the view is correct rather than merely unchanged
Follow-up questions
- →How do you keep dynamic values such as timestamps or ids out of a snapshot?
- →When is asserting on a few specific selectors better than a full-page snapshot?
SeniorDesignRareA Laravel job dispatched from a controller charges a card, writes a receipt row and emails the customer. It has no tests at all: the team runs it by hand against the payment sandbox before each release. Design the test strategy — say what should be asserted at the dispatch site, what should be asserted about the job itself, and how you keep the real card charge and the real email out of the suite while still proving the job's retry and failure behaviour.
A Laravel job dispatched from a controller charges a card, writes a receipt row and emails the customer. It has no tests at all: the team runs it by hand against the payment sandbox before each release. Design the test strategy — say what should be asserted at the dispatch site, what should be asserted about the job itself, and how you keep the real card charge and the real email out of the suite while still proving the job's retry and failure behaviour.
Split it in two. At the dispatch site, fake the queue and assert the job was pushed with the right payload — the controller's contract ends there. Then test the job's handle() as a unit, with the gateway and the mailer injected as doubles, so no real charge or email fires. Cover retries and the failure hook by making a double throw.
Common mistakes
- ✗Letting the real gateway or mailer run because the job was never isolated from them
- ✗Asserting on the job's side effects in the controller test instead of asserting it was dispatched
- ✗Leaving retry and failure behaviour untested because no double is ever made to throw
Follow-up questions
- →How do you assert a job was dispatched onto a specific queue and connection?
- →Why does making an injected double throw prove the retry path better than a flaky sandbox?