A unit test calls a small piece of code and checks its behavior automatically. PHPUnit supplies a runner, assertions, fixtures, and failure reporting so you do not have to inspect every result manually. The useful question is what requirement the test proves, not how many lines it executes. This reference tests a shipping rule, covers invalid input, and explains dependency boundaries. You will learn to use assertSame() for strict values and keep database, mail, and network work out of an ordinary unit test. PHPUnit major versions have PHP requirements, so choose a supported version for the project's actual runtime.
Choose behavior worth proving
Start with a concrete requirement: orders of at least fifty currency units receive free shipping; smaller valid orders pay five units. Test values below, at, and above the threshold. An input outside the allowed domain should have a documented outcome. These cases prove the rule rather than repeating the implementation line by line. A test that calculates the expected value with exactly the same formula as the production code can duplicate the same bug. State expected results independently and name tests after the behavior they check. Unit tests suit deterministic business rules and small transformations. Integration tests verify boundaries such as SQL queries, while end-to-end tests exercise complete user flows. Keep those purposes clear.
Install a compatible development tool
PHPUnit is commonly a Composer require-dev dependency, run through the project's vendor binary. Select a maintained major version whose documented PHP and extension requirements match the CLI runtime used by the test runner. A production no-dev install usually does not include the test tool. Commit the application lockfile so CI and developers run the reviewed version. Configure autoloading for application and test namespaces, and keep tests separate from public files. Version-specific attributes, assertions, and runner options should match the installed manual. Do not copy old annotation syntax or removed APIs without checking compatibility. Installation commands can execute dependency code, so they belong to the project's reviewed dependency workflow.
Use strict assertions for meaningful values
assertSame() checks both value and type, which catches a string '0' accidentally returned instead of integer zero. assertEquals() has a different comparison purpose; choose deliberately rather than using it everywhere. Boolean, count, string, and collection assertions can express requirements clearly. One test may have several related assertions when they describe one behavior, but unrelated outcomes belong in separate tests so a failure identifies the broken rule. Check exceptions with expectException() before invoking the failing call. If you expect a message or code, assert it only when it is part of a stable public contract. Avoid fragile tests that depend on incidental wording, random IDs, or clock times without a controlled source.
Design code with replaceable boundaries
A function that calculates shipping should not open a database or send mail just to return a number. Pass required data or dependencies explicitly. For a service needing the current time, inject a clock abstraction or pass a fixed time in a narrow design. For mail, use a fake transport that records the intended message without delivering it. A mock verifies interactions when those interactions are the behavior under test, but excessive mocks can lock tests to internal call sequences. Prefer checking observable outcomes where possible. A unit test should run repeatedly without production credentials, network access, or mutable external state. This makes failures easier to interpret and prevents test runs from changing real user data.
Keep fixtures simple and isolated
Arrange the input, act by invoking the code, then assert the result. A small explicit fixture is easier to understand than a shared setup that creates dozens of unrelated objects. Reset mutable state between tests; test order should not determine success. Use data providers when several inputs share one rule, following the syntax supported by the installed PHPUnit version. Database integration tests need a disposable schema, a known fixture, and cleanup appropriate to the engine; they are not ordinary isolated unit tests. Never point the test suite at production merely because configuration is convenient. Separate environment settings and verify the target before destructive fixture preparation.
Read failures as evidence and maintain tests
A failed assertion shows expected and actual behavior. Determine whether the code regressed, the requirement changed, or the test depended on an incidental detail. Do not update expected values automatically until the requirement is understood. Coverage identifies code that executed, not proof every branch or business case is correct. A hundred percent metric can coexist with weak assertions. Run the focused tests affected by a change, then the required project suite under CI policy. Keep tests fast enough that developers use them regularly. Review flaky timing and random-state failures rather than repeatedly rerunning until green. A trustworthy suite is one whose failures usually indicate meaningful behavior problems.
Practice task and worked solution
Write tests for a quantity validator that accepts zero through one hundred and rejects negative values, oversized values, and malformed text. The solution tests boundary values independently and uses strict assertions for returned integers or tagged results. If the validator raises exceptions, declare expectations before the call. Next, test a notification service with a fake transport and verify the intended recipient and message without sending mail. Explain which database behavior would require a separate integration test. Keep fixtures explicit and add a regression case for any meaningful bug found during implementation rather than writing tests that merely restate every assignment.
Continue learning
Review functions, exceptions, and Composer.
The PHPUnit installation manual specifies runtime requirements, and the writing-tests guide documents assertions and test structure.
Unit Testing with PHPUnit Examples
Each example shows the code, the output it produces and what each part does.
Test an integer shipping threshold
Put shippingFee() in an application file loaded by the test bootstrap, and the test class in a separate test file. These snippets use familiar PHPUnit TestCase APIs.
With a compatible PHPUnit installation, autoload or bootstrap, and the function available, the three tests pass. The runner banner, timing, and formatting depend on the installed PHPUnit version.
<?php
function shippingFee(int $subtotal): int {
if ($subtotal < 0) throw new InvalidArgumentException('Negative subtotal');
return $subtotal >= 50 ? 0 : 5;
}
// Separate test file:
final class ShippingFeeTest extends \PHPUnit\Framework\TestCase {
public function testThresholdGetsFreeShipping(): void {
self::assertSame(0, shippingFee(50));
}
public function testBelowThresholdCostsFive(): void {
self::assertSame(5, shippingFee(49));
}
public function testNegativeSubtotalIsRejected(): void {
$this->expectException(InvalidArgumentException::class);
shippingFee(-1);
}
}Expected test result: 3 passing tests covering threshold, below-threshold fee, and negative-input exception.Unit Testing with PHPUnit FAQ
Does coverage prove correctness?
No. Coverage shows which lines ran during the tests, not whether the tests checked the right results. Meaningful cases and assertions matter: a test that calls a function without asserting anything still counts as coverage.
When use assertSame?
When both value and type are part of the contract. assertSame(10, $total) fails if $total is the string "10" or the float 10.0, while assertEquals accepts them. Prefer assertSame for scalars and identity checks, and assertEquals when comparing objects by their values.
Should unit tests send real email?
No. Use a controlled fake boundary: pass a fake mailer that records messages, then assert on what would have been sent. Real email makes tests slow and flaky and can reach real people by accident.
Why does a test pass only after another?
Shared mutable state or ordering dependence needs fixing. One test is probably relying on data, a static property or a file created by another. Give each test its own setup in setUp(), reset shared state, and run tests in random order to expose these dependencies.
Unit Testing with PHPUnit Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
A shipping rule grants free shipping at 50. Which boundary case matters?
Unit Testing with PHPUnit Quiz
2 questions to check what you learned.
What does assertSame check?
- Both type and value
- Only output length
- Database grants
- The test filename alone
What belongs in an ordinary isolated unit test?
- Production deletes
- Controlled inputs and dependencies without real external side effects
- Real SMTP delivery
- A live payment charge