The PHP Testing Pyramid: Unit, Integration, and End-to-End Strategy
How to balance unit, integration, and end-to-end PHP tests using PHPUnit, with a focus on the mutation-surviving coverage that South Florida employers expect.
The PHP Testing Pyramid: Unit, Integration, and End-to-End Strategy
A CI pipeline badge showing 92% line coverage fails its first production deploy because the suite never touched the database transaction that silently rolled back. That is the gap between claimed coverage and the metric South Florida mid-to-senior job descriptions actually prize: mutation-surviving coverage. The testing pyramid allocates effort across three layers, unit, integration, and end-to-end, with the bulk of assertions living in fast, isolated unit tests. It is not a ratio to copy. It is a budget you spend on confidence. Every South Florida team running PHPUnit already spends it, whether they name it or not.
PHPUnit test isolation means a unit test that passes with no database, no network, and no filesystem. When a test hits the database, it is an integration test. Calling it a unit test does not change the 40-second suite runtime that kills a developer’s flow. The pyramid does not forbid integration tests. It demands you know which layer you are in and pay the right cost for it.
PHP Test Isolation: The Unit Layer That South Florida Teams Actually Audit
A unit test for a service that calculates a commission on a real estate listing touches no database. The test double stands in for the repository. The assertion count per test method stays at one or two. That constraint is not pedantry. When a single test method asserts the return value, the side effect, and the log entry, the failure message tells you three things broke and which one is the lie. A test method with one assertion fails and names the exact behaviour that changed.
South Florida leads enforce PHPUnit test isolation in code review by checking the setUp method and the XML configuration. A setUp that opens a PDO connection is an integration test. A phpunit.xml that points to a real MySQL host is an integration suite. Move the unit layer to an in-memory SQLite database and the suite drops from 90 seconds to 8 seconds on a cold container. That 8-second feedback loop keeps a developer from switching to their phone while the suite runs.
Mock Objects and the Arrange-Act-Assert Discipline
The mock object system in PHPUnit creates test doubles that verify the contract, not the implementation. Arrange the double to expect a single call with specific parameters. Act by invoking the method under test. Assert the return value. When a test needs a data provider, the provider feeds input-output pairs, and each pair runs as its own test method. One failure in a data provider does not hide the seven passing cases behind it.
Group unit tests by domain class rather than by file. A TestSuite built this way stays readable six months later. The test double for a Slim controller action that receives a PSR-7 request and returns a response is a stub implementing the RequestInterface. You do not need a running server. You need the interface and the contract it promises. That is the difference between a test that survives a framework upgrade and one that couples to the HTTP layer and breaks on the next minor release.
PHP Integration Test Database: When the Fixture Is the Contract
An integration test that exercises the order-total calculation against a real database validates the SQL the ORM generated. The ORM is a convenience layer over PDO. When it produces a query that runs in 2 milliseconds locally and several hundred milliseconds on the production MySQL 8 instance, the integration test is the only layer that catches the missing index before the deploy. Database design validation does not disappear because the repository class returns an entity. It moves to the integration layer.
The fixture strategy determines whether the suite is repeatable. Load a known state before each test class and tear it down after. You get a clean slate. Sharing a fixture across test classes couples the tests. When test C fails because test A left a row in the orders table, you waste 20 minutes bisecting the suite instead of fixing the code. The in-memory SQLite database works for integration tests that exercise query-builder logic. It masks strict-mode truncation and date-format mismatches that only appear on the production MySQL instance. Run the integration suite against a test container that mirrors the production engine, or accept that your coverage is a polite fiction.
Regression Tests and the Smoke Test Gate
A regression test replays a logged production failure against the integration database. It proves the fix works and stays working. The smoke test that hits the API endpoint, touches the database, and checks the response code is not a unit test. Put it in the integration layer. Run it last in the CI pipeline. A failing smoke test after a green unit suite means the wiring between layers broke, not the logic inside them.
PHP End-to-End Test Coverage and the Claimed-Versus-Real Gap
PHP end-to-end test coverage measures the paths exercised through the full request-response cycle, from the HTTP entry point to the database and back. A suite that logs in, creates a resource, and asserts the response body contains the expected JSON key covers the happy path. It does not cover the path where the payment gateway times out after 30 seconds, the session lock blocks the concurrent AJAX request, or the opcache stampede spikes CPU after a deploy. Those failures happen in production. They happen because the end-to-end suite asserted the visible behaviour without asserting the boundary conditions.
The claimed-versus-real gap in CI badges is a South Florida hiring signal. A candidate who reports high line coverage but cannot explain the mutation score of the suite has never run Infection against their code. Mutation-surviving coverage measures by injecting a change, a flipped operator, a removed method call, a negated condition, and checking whether a test fails. Line coverage counts a line that was executed. Mutation coverage counts a line whose behaviour was verified. When a job description asks for experience with test quality, it is asking for the mutation score. South Florida mid-to-senior interviewers know the difference. They ask for it by name.
Budgeting the End-to-End Layer
The end-to-end layer is the most expensive to write and the slowest to run. Budget it at 10% of the total test count. A Laravel application with a large unit suite and a solid integration layer should carry roughly 50 end-to-end tests, each one exercising a critical user journey. A Slim API that serves a mobile app needs end-to-end tests for the authentication flow and the primary data endpoints. Everything else lives in the integration layer where the fixture is controllable and the runtime is under 5 seconds per test class.
Mutation-Surviving Coverage PHP: The Metric South Florida Employers Prize
A code coverage percentage that drops sharply after a mutation run tells you the suite is a tour guide, not a security guard. It walks every line but checks nothing. Adopt mutation-surviving coverage by adding Infection to the CI pipeline and setting a threshold. Aim for 60% on a legacy codebase and 80% on new development. The threshold is a gate. A pull request that drops the mutation score below it fails the build. The developer sees which mutants escaped in the terminal output before they push again.
The mutation score is not a replacement for code coverage. It is a quality check on the assertions the coverage percentage counts. A method that returns a calculated value from three inputs needs tests that vary each input independently. Use a data provider that supplies ten input sets per method. Keep the assertion count per test method at exactly one. That raises the mutation score faster than ten separate test methods that each assert multiple things. The machine notices when an assertion is missing. It does not notice when an assertion is wrong. Human review still owns correctness.
The PHPUnit Test Isolation and Assertion Count Per Test Method Rule
A test method that asserts five things is five tests hiding in one method. When the third assertion fails, the fourth and fifth never run. The developer fixes the first failure, pushes, and discovers the second failure on the next CI run. That loop costs 12 minutes per cycle. The rule is one assertion per test method. Relax to two when the second assertion is a direct consequence of the first, such as asserting a return value and then asserting a side effect on a mock object called as part of the same operation.
Enforce PHPUnit test isolation by running tests in a process isolation configuration when the suite is flaky. A test that passes alone and fails in the suite is coupled to global state, a static property, or a shared fixture. Process isolation runs each test in its own PHP process. The overhead is worth it the first time it surfaces a test that depended on a previous test’s database row. Fix the test, remove the isolation, and move on. The flag exists to find the coupling, not to live in the CI configuration permanently.
ORMs, PDO, and the Database Fixture That Lies
An ORM is a convenience layer over PDO that generates SQL from object graphs. It does not validate the schema, the index strategy, or the query plan. A migration that adds a column without a default value passes locally against SQLite, which ignores the strictness. It fails in production against MySQL 8 with strict mode on. The integration test running against the in-memory SQLite database never sees the failure. The production deploy does. The rollback time is 90 seconds if the CI pipeline built the previous release correctly.
The database fixture that mirrors production schema but runs on SQLite is a development convenience, not a test strategy. Use it for the unit layer only when the code under test does not generate SQL. The integration layer that validates the ORM’s behaviour must run against the same engine and version the deployment target uses. Spin up a MySQL 8 test container in the CI pipeline. It adds 15 seconds to the suite runtime and saves a production outage that costs the team an afternoon. That trade is never close.
Common Questions
What is the correct balance of unit, integration, and end-to-end tests for a PHP application?
Aim for roughly 70% unit tests, 20% integration tests, and 10% end-to-end tests by count. The unit layer covers business logic with fast, isolated tests. The integration layer validates database interactions, API contracts, and component wiring against real infrastructure. The end-to-end layer exercises critical user journeys through the full stack. Adjust the ratio based on the application’s risk surface: an API that serves data to a mobile app shifts more weight to integration tests; a calculation-heavy service shifts more to unit tests.
Why does my CI badge show 90% coverage but production still breaks?
Line coverage counts executed lines, not verified behaviour. A test that calls a method without asserting its return value or side effects contributes to the coverage percentage while proving nothing. The gap is between claimed coverage and mutation-surviving coverage. Run Infection against your suite. A mutation score below 50% means half the code that appears covered is not actually tested. South Florida hiring managers at the mid-to-senior level ask about this gap directly.
Should I use an in-memory SQLite database for integration tests?
No, not for tests that validate database behaviour. SQLite masks strict-mode truncation, date-format mismatches, and query-plan differences that surface on MySQL 8 in production. Use SQLite for unit tests that isolate the repository interface behind a test double. Run integration tests against a test container that mirrors the production database engine and version. The 15-second CI overhead is cheaper than a rollback.
How many assertions should a single test method contain?
One assertion per test method is the target. Relax to two when the second assertion checks a direct consequence of the first, such as a mock-object verification that a method was called with the expected parameters. More than two assertions in a method obscures which behaviour failed and leaves later assertions unrun when the first one throws. A data provider that feeds multiple input sets into a single test method keeps each run to its own assertion count.
What is a mutation score and what threshold should I set?
A mutation score is the percentage of injected code changes, flipped operators, removed calls, negated conditions, that a test suite catches. Set the CI threshold at 60% for a legacy codebase and 80% for new development. The threshold is a gate: a pull request that drops the score below it fails the build. Do not chase 100%. The last 10% of mutants often represent equivalent mutations that no test can catch, and the time spent on them is better spent on integration coverage.
Does using an ORM remove the need for integration tests?
No. An ORM generates SQL from object graphs but does not validate the schema, indexes, or query plan. An integration test that exercises the repository against a real database catches the missing index, the strict-mode violation, and the N+1 query that the ORM obscured. The ORM is a convenience layer over PDO. The layer underneath still needs testing, and the integration suite is the only layer that touches it.
What is the difference between a smoke test and a regression test?
A smoke test is a broad check that the application boots and the critical endpoints respond. It runs at the integration or end-to-end layer and fails fast when a dependency is down. A regression test is a specific test written to reproduce a logged production failure. It proves the fix works and stays working across future changes. Both belong in the CI pipeline, but the regression test lives at the layer closest to the failure, usually integration or unit.