savage-days / phpunit-case-coverage
Opt-in behavioral case coverage for PHPUnit doubles.
Requires
- php: ^8.2
- phpunit/phpunit: ^9.6 || ^10.5 || ^11.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
PHPUnit Case Coverage is an opt-in extension that makes meaningful, developer-declared outcomes of a dependency visible in consumer tests.
PHPUnit remains responsible for correctness: assertions and mock expectations still prove the behaviour of the system under test. Case Coverage answers a complementary question: which declared dependency outcomes has this consumer scope exercised?
It is deliberately test-layer only. Production classes do not receive attributes, interfaces, or other metadata from this package.
Requirements
- PHP 8.2+
- PHPUnit 9.6, 10.5, or 11
Installation
Install from a published repository:
composer require --dev case-coverage/phpunit-case-coverage
Register the extension in the consuming project's phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="vendor/autoload.php"> <extensions> <bootstrap class="CaseCoverage\PHPUnit\Extension"/> </extensions> <testsuites> <testsuite name="unit"> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> </phpunit>
For PHPUnit 9.6, use its legacy listener configuration instead; the event extension API used by PHPUnit 10/11 does not exist in PHPUnit 9:
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="vendor/autoload.php"> <listeners> <listener class="CaseCoverage\PHPUnit\LegacyListener"/> </listeners> </phpunit>
Define cases
A CaseSet is a test-side catalogue for one target class or interface. Every public static method returning CaseDefinition declares exactly one outcome for exactly one target method.
use CaseCoverage\Attribute\Group; use CaseCoverage\CaseDefinition; use CaseCoverage\CaseInstance; use CaseCoverage\CaseSet; final class PaymentClientCases extends CaseSet { protected static function target(): string { return PaymentClient::class; } public static function approved(): CaseDefinition { return self::on('charge')->returns( new PaymentResponse(PaymentStatus::Approved), ); } public static function declined(): CaseDefinition { return self::on('charge')->returns( new PaymentResponse(PaymentStatus::Declined), ); } #[Group('temporaryFailures')] public static function timeout(): CaseDefinition { return self::on('charge')->throws(new TimeoutException()); } #[Group('temporaryFailures')] public static function connectionError(): CaseDefinition { return self::on('charge')->throws(new ConnectionException()); } public static function committed(): CaseDefinition { return self::on('commit')->completes(); } /** @return array<string, array{0: CaseInstance}> */ public static function temporaryFailures(): array { return self::forGroup(__FUNCTION__); } }
Supported outcomes are deliberately small:
self::on('method')->returns($value); // a normal return value self::on('method')->throws($exception); // a Throwable self::on('voidMethod')->completes(); // normal completion of a void method
completes() is valid only for a target method declared with void. Case definitions are validated when the catalogue is used: the target type and target method must exist.
Apply one case
Use case() in an ordinary PHPUnit test. A case becomes covered only when apply() runs; merely creating a case does not affect the report.
public function testApprovedPayment(): void { $client = $this->createStub(PaymentClient::class); PaymentClientCases::case('approved')->apply($client); $service = new PaymentService($client); self::assertTrue($service->pay(1000)->isSuccessful()); }
apply() checks that the PHPUnit double implements or extends the CaseSet target, configures the declared return/throw outcome, and records coverage. It does not configure call counts, arguments, or expectations; keep those as normal PHPUnit mock setup:
$case->apply($client); $client->expects(self::once())->method('charge')->with(1000);
Run a group without a provider method in the test class
Groups collect semantically equivalent cases. Declare membership with #[Group], then expose one public static provider method on the CaseSet. PHPUnit's DataProviderExternal keeps the test method focused on the test itself:
use PHPUnit\Framework\Attributes\DataProviderExternal; #[DataProviderExternal(PaymentClientCases::class, 'temporaryFailures')] public function testTemporaryFailuresAreReportedAsRetryable(CaseInstance $case): void { $client = $this->createStub(PaymentClient::class); $case->apply($client); $service = new PaymentService($client); self::assertTrue($service->pay(1000)->isRetryable()); }
PHPUnit invokes PaymentClientCases::temporaryFailures(), which delegates to forGroup('temporaryFailures'). The resulting datasets are named after their cases, for example timeout and connectionError.
When a new case is added to temporaryFailures, this test automatically gains a new PHPUnit dataset. DataProviderExternal requires a real public static method, so that small provider method is necessary; it belongs beside the group, not in every test class.
For a one-case data provider, use CaseSet::forCase('approved'). Most one-case tests are simpler with CaseSet::case('approved')->apply($double).
PHPUnit 9.6 does not support DataProviderExternal attributes. It supports the same external static provider with its legacy annotation:
/** @dataProvider \App\Tests\Cases\PaymentClientCases::temporaryFailures */ public function testTemporaryFailuresAreReportedAsRetryable(CaseInstance $case): void { // ... }
Coverage semantics
Coverage is aggregated by PHPUnit test class. It begins only when apply() is called.
Once any case for a target method is applied, all declared cases of that same target method become the denominator for that test class. Cases for other methods in the same CaseSet are not shown unless one of their cases is applied.
For example, if a UserCases catalogue declares four outcomes for User::getIdentity() and a UserTest applies only exception and id, the report is:
UserTest
User::getIdentity
email uncovered
exception covered
id covered
login uncovered
2 / 4 (50%)
This is behavioural coverage of the declared space, not proof that the catalogue contains every possible real-world outcome.
Run the report
Normal PHPUnit execution is unchanged and produces no Case Coverage output:
vendor/bin/phpunit
Use the package wrapper and --case-coverage to request the report:
vendor/bin/case-phpunit --case-coverage
All other arguments are forwarded to PHPUnit:
vendor/bin/case-phpunit --case-coverage --filter PaymentServiceTest vendor/bin/case-phpunit --case-coverage --testsuite unit
The wrapper is necessary because PHPUnit parses command-line options before extensions load; an integration cannot directly add a new native vendor/bin/phpunit option. It enables the PHPUnit 9 listener and PHPUnit 10/11 extension alike.
What the package does not do
- It does not infer cases from test names or ordinary mock configuration.
- It does not modify generated PHPUnit doubles or replace PHPUnit mock APIs.
- It does not generate combinations across different dependency methods.
- It does not add production-code metadata or validate external provider behaviour.
- It does not enforce coverage thresholds in CI.
The package stays intentionally small: cases declare what a dependency produces; PHPUnit tests declare how the consumer uses it.