SoFloPHPIndependent reference

How to Unit Test PHP Code Using PHPUnit With Isolation Boundaries

Write PHP unit tests with PHPUnit that use real isolation, one assertion per test, and mutation-surviving coverage beyond vanity CI badges.

The Isolation Boundary Is the Test

PHP unit testing with PHPUnit is a discipline that separates a verifiable claim from a fragile script. Most developers who write tests are not testing in isolation. They are testing a bootstrap sequence plus a database call plus a session read plus the class they meant to test. When that test fails at 2am, the failure message points to the database adapter, not the business rule. The fix: draw a hard line around the class under test and replace everything outside it with a controlled double.

Isolation is not a feature of the framework. It is a property of the design. A class that reaches out to the container, the file system, or a static facade has already lost the battle before the test file is created. The only reliable way to achieve isolation is constructor-based dependency injection. Every collaborator arrives as a typed parameter. Every side effect is a method call on a contract. When the test supplies a mock object instead of the real database connection, the test measures only the logic inside the class. Not the network latency. Not the migration state of a test database.

Mock Object Test Doubles Replace Real Collaborators

A PHPUnit mock object test double is the mechanism that makes isolation practical. Create one with $this->createMock(InterfaceName::class) and you get an object that implements the contract, records every method call, and returns whatever you tell it to return. The double does not connect to MySQL. It does not read from Redis. It does not send an HTTP request. It sits in memory, fast and predictable, and it exposes the exact interaction the test needs to verify.

Mock only the seam where the class under test touches something outside itself: a repository, a mailer, a queue client, a filesystem abstraction. Do not mock value objects or data transfer objects. When you mock a logger, you assert that the logger received a specific message at a specific level. When you mock a payment gateway, you assert that the gateway was asked to charge a specific amount. The assertion targets the side effect, not the return value. That is what turns a line-coverage test into a behaviour-coverage test.

Stubs Return Data, Mocks Enforce Behaviour

A test double has three jobs: it isolates, it records, and it enforces expectations. Use expects($this->once()) to guarantee a method was called exactly one time. Use with($this->equalTo($value)) to guarantee it was called with the right payload. A test that does not assert the interaction runs code without proving it did the one thing that matters. The difference between a stub and a mock is exactly this: a stub returns data, a mock asserts behaviour.

Test Coverage Percentage Is a Vanity Metric Without Assertion Density

PHP test coverage is a number, and the gap between the claimed figure on a CI badge and reality is wide enough to walk through. A project with 90% line coverage can still crash on the first real request because the tests assert return values without asserting side effects, database state, or emitted headers. The coverage driver runs the lines. The assertions are absent or trivially true. Mutation testing tools like Infection insert logical operators, flip conditionals, and remove early returns, then re-run the test suite. If the suite passes after a mutant is introduced, the line was covered but the behaviour was not tested.

The number to track is mutation score, not line percentage. A mutation score below 60% means more than a third of your logical paths are untested. A score above 85% on a codebase with dependency injection and small classes is achievable. The same score on a legacy codebase with static calls is a multi-month project. Start with the class that has the highest production error rate, not the class with the lowest coverage number. Fix the thing that breaks, then measure again.

Two Assertions Per Test Is the Floor

Coverage without assertion density is a vanity metric. Two assertions per test method is a floor. One assertion testing the happy path and a second assertion testing a specific failure mode is the minimum useful pair. A test method with five assertions is a code smell: the method is testing too many things or the class is doing too many things. Split it into separate test methods, each with a single logical assertion and a descriptive name that starts with the method under test and ends with the expected outcome.

Single Assertion Per Test Method Reveals What Broke

A PHP test method with a single assertion is not a religious mandate. It is a diagnostic tool. When a test named testCalculateTotalWithTaxAndDiscountAndFreeShipping fails, the failure message tells you the assertion line number and the expected value. It does not tell you which business rule broke. The developer opens the test, reads five assertions, and has to reason backwards from the failure to the intent. That costs five minutes per failure. On a suite with intermittent failures, the cost is real.

Name the test method after the condition and the expected outcome. testCalculateTotal_appliesTaxRate() and testCalculateTotal_zeroShippingAboveThreshold() are two methods, each with one assertion. When the tax rate claim fails, the commit that changed the tax calculation is the only candidate. Debugging time drops from minutes to seconds. The data provider pattern in PHPUnit feeds multiple input sets into one test method, keeping the single-assertion structure while testing boundary values: zero, negative, maximum, and the exact threshold.

Build Fixtures Fresh in setUp Every Time

The setUp method builds the class under test and the mock objects. The test method exercises one behaviour and makes one assertion. The tearDown method cleans up any shared state, though with proper isolation there is rarely state to clean. A test fixture that is built once and reused across ten test methods saves setup time but couples the tests. When one test mutates the fixture state, the other nine tests break in ways that do not point to their own code. Build the fixture fresh in setUp for each test method. The runtime cost is negligible compared to the debugging cost of shared mutable state.

Isolation Techniques Ranked by Mutation Survival Rate
TechniqueIsolation LevelMutation Score ImpactSetup Cost
Constructor dependency injection with interfaceComplete; class touches only its parametersHigh; every dependency is mockableMedium; requires interface extraction
Static facade with Laravel Facade::shouldReceivePartial; facade root is swapped at runtimeMedium; tests pass with swapped root but miss real pathLow; one-line swap in test setup
In-memory SQLite for database testsLow; the database is real but the dialect differsLow; SQLite passes queries that MySQL 8 rejectsLow; change one config line
Real service container with test bindingsPartial; container resolves test doublesMedium; container wiring errors surfaceHigh; must maintain parallel binding config
No isolation; test hits real database and APINone; test is an integration testVery low; network failures mask logic failuresZero; but debugging cost is enormous

Dependency Injection Creates the Isolation Boundary

The isolation boundary is a contract between the class and its collaborators. The contract is written in the constructor signature. A class that accepts a PaymentGatewayInterface in its constructor is announcing that it needs a thing that can charge money. It does not care whether that thing is a Stripe adapter, a PayPal adapter, or a test double that records charge attempts in an array. The test supplies the double. The class under test cannot tell the difference. That is the definition of a unit test.

Static method calls destroy this boundary. A class that calls Logger::info() directly has hardwired itself to a global symbol. You cannot replace that symbol in a test without a framework that patches the class loader at runtime. Even then, the replacement is global state that leaks between tests. The same problem applies to the new keyword inside a method. When a class instantiates its own dependencies, the test cannot intercept those dependencies. The fix is to pass the dependency in, always.

South Florida Employers Demand Testable Design

South Florida PHP testing job requirement listings increasingly name dependency injection as a prerequisite, not a nice-to-have. Employers in the Miami to West Palm Beach corridor who run PHPUnit as part of their CI pipeline have learned that code written without DI is untestable code. Untestable code is the thing that causes the 4am production incident. The interview question is not "do you write tests" but "show me a class you designed for testability and walk me through the mock setup." The answer that passes is the one that starts with the constructor signature.

PHPUnit Mock Object Test Double Configuration

The PHPUnit TestCase base class provides three methods for creating test doubles: createMock, getMockBuilder, and createStub. createMock returns an object where every method returns null or zero by default. The test must configure the methods it cares about. Use this when the test needs to assert that a method was called with specific arguments. createStub returns an object where methods return configured values, and no call expectations are enforced. Use this when the class under test needs data but the interaction is not the thing being tested.

The getMockBuilder offers fine control: disable the original constructor, disable autoloading of the original class, set mock class name for debugging. Most tests do not need this. The two things that matter are the contract you mock and the expectations you set. A mock of a repository contract with expects($this->once())->method('findById')->willReturn($entity) is the standard pattern. Create the mock in setUp. Set the expectation in the test method that needs it. The mock is discarded after the test. No state survives between tests.

Wrap Third-Party Code Before You Mock It

Never mock a class you do not own without wrapping it. A third-party HTTP client mocked directly in your test couples the test to the client's method names and signatures. When the client releases a new major version, your tests break for reasons unrelated to your business logic. Wrap the client in a contract you control. Implement the adapter once. Mock your contract in tests. The adapter itself gets an integration test that hits a sandbox endpoint, not a unit test.

PHP Test Coverage Mutation Surviving and the Xdebug Coverage Mode

Mutation testing requires a code coverage driver. Xdebug coverage mode is the default in PHPUnit 10 and later. The driver instruments the code at the opcode level, recording which lines execute during the test run. The coverage report is an XML file that PHPUnit writes to a directory specified in phpunit.xml. The mutation testing tool reads the same coverage file, identifies lines that were covered, and mutates the abstract syntax tree of those lines. A covered line where every assertion passes after mutation is an escaped mutant. Escaped mutants are the bugs your test suite cannot catch.

Run Mutations in CI, Not Locally

The practical workflow: run PHPUnit with Xdebug coverage enabled, generate the coverage report, run Infection with the same report, and read the mutation score. The report lists every escaped mutant with the file, line number, and the mutation applied. A typical first run on a project with 80% line coverage produces a mutation score around 30%. Closing the gap requires adding assertions that verify the specific behaviour the mutation broke: the early return that was removed, the conditional that was flipped, the boundary value that was shifted by one.

The Xdebug coverage mode has a performance cost. On a large codebase, a full coverage run can take several minutes. Run it in CI, not on every local test execution. For local development, run the subset of tests that exercise the class you are working on. Run the full mutation suite before pushing. The CI pipeline should block merges that drop the mutation score below the threshold defined in infection.json5. A threshold of 80% is aggressive for a new project; 60% with a requirement that the score never decreases is realistic for a legacy project under active remediation.

South Florida PHP Testing Job Requirement Demands Isolation Skills

A South Florida PHP testing job requirement in 2026 is not a checkbox on a listing. It is a filtering mechanism. Employers from Fort Lauderdale agencies to Miami health-tech firms to West Palm Beach enterprise shops list PHPUnit as a requirement, but the interview separates the developers who can write a test from the developers who can design for testability. The question that trips candidates is not about assertion syntax. It is "given this class with a static database call, how would you refactor it so you can test the business logic without a database?"

The answer that gets the offer describes extracting a contract, injecting it through the constructor, and replacing the static call with a method call on the injected object. The answer that loses the offer describes installing a package that patches static methods at runtime. The first answer demonstrates understanding of the isolation boundary. The second answer demonstrates knowledge of a workaround that will break in production when the patching library conflicts with the opcache or a PHP minor version upgrade.

PHPUnit Is the Common Vocabulary Across Frameworks

The regional market values PHPUnit over alternative testing frameworks because the XML configuration format and the TestCase base class are the common vocabulary across every framework. A Laravel shop and a Symfony shop both use PHPUnit under the hood. A Drupal 10 project uses PHPUnit for kernel and functional tests. A developer who can read a phpunit.xml file, configure a test suite, set up a data provider, and mock a contract can move between these projects without relearning the testing tool. That portability is what makes the skill durable in the South Florida PHP job market.

Common Questions

What is the difference between a mock and a stub in PHPUnit?

A stub returns configured data and does not enforce call expectations. A mock enforces that specific methods were called with specific arguments a specific number of times. Use a stub when the class under test needs data but the interaction is not the behaviour you are testing. Use a mock when the side effect is the behaviour you are testing.

How do I test a class that depends on a static method?

You cannot test it in isolation without a framework that swaps the static method at runtime, and that swap is global state that leaks between tests. Refactor the class to accept the dependency through its constructor as a contract, then mock the contract in the test. The static method becomes an implementation detail of the concrete adapter, tested separately.

Why does my 90% coverage test suite still let bugs through?

Line coverage measures which lines executed, not which behaviours were verified. If your tests call methods but assert only the return value, the side effects (database writes, emails sent, logs written) are untested. Run mutation testing with Infection to identify lines that execute but have no meaningful assertion guarding them.

Should I use an in-memory SQLite database for unit tests?

No. SQLite is a different database engine with different SQL dialect rules, date handling, and strict mode behaviour. A test that passes against SQLite can fail against MySQL 8 in production because of silent data truncation or date format mismatches. Use SQLite for functional tests where you control the schema, but unit tests that touch a database are not unit tests. They are integration tests. Mock the repository contract instead.

How many assertions should one test method have?

One logical assertion per test method. A logical assertion may require multiple PHPUnit assertion calls (for example, asserting that an array has the correct keys and values), but the test should verify a single behaviour. When a test with five assertions fails, the failure message does not tell you which business rule broke. Split the test into separate methods with descriptive names.

What is the setUp method for in PHPUnit?

setUp builds the test fixture before each test method runs. Use it to instantiate the class under test and create the mock objects that every test method needs. Build the fixture fresh in setUp for every test. Sharing fixture state between tests couples them: when one test mutates the state, the other tests break in ways that do not point to their own code.

How do South Florida PHP employers test for isolation skills?

They ask for a refactoring exercise, not a syntax quiz. The candidate is given a class with a hardcoded dependency and asked to make it testable. The passing answer extracts a contract, injects it through the constructor, and writes a test with a mock object. The failing answer reaches for a static mocking library. The exercise takes 20 minutes and reveals more about architectural understanding than a resume line about PHPUnit.