Index

An Interface Does Not Make a Fake Substitutable

An HTTP product catalogue and an in-memory fake both implement this interface:

interface ProductCatalogue
{
    /** @throws ProductNotFound */
    public function priceCents(string $sku): int;
}

Both return 10000 for CHAIR-BLACK. Both make the checkout test green. Both pass PHP’s signature checks.

Now seed one divergence. When the product is absent, the HTTP adapter throws ProductNotFound while the fake returns zero. The interface remains satisfied, and the familiar checkout test still passes because it never asks for a missing SKU.

Method compatibility is not behavioral compatibility. When callers rely on the same output and failure semantics from several implementations, run one consumer-shaped contract suite against all of them. The suite makes the shared promise executable; independent implementation tests do not.

The method signature leaves the missing product undecided

PHP interfaces require implementations to provide compatible methods. That gives static analysis and the runtime something concrete to enforce: the public method exists, accepts a string, and declares an integer return type.

It cannot settle what MISSING-SKU means. Several implementations fit the same signature:

return 0;
throw ProductNotFound::forSku($sku);
throw new RuntimeException('Provider unavailable');

Those results are not interchangeable to a caller. Zero can enter price arithmetic. ProductNotFound can produce a normal unavailable-product path. A provider failure may require retry or operator attention. The @throws tag communicates intent to people and analyzers, but PHP does not use it to reject the zero-returning implementation.

This is the failure-semantics part of Liskov substitution. The useful question is not whether two classes share an interface. It is whether a caller can replace one with the other without changing the caller-visible behavior it depends on.

Write the consumer promise once

The reproduction gives the catalogue two shared examples:

abstract class ProductCatalogueContract extends TestCase
{
    abstract protected function catalogue(): ProductCatalogue;

    public function test_known_product_returns_its_exact_price_in_cents(): void
    {
        self::assertSame(
            10_000,
            $this->catalogue()->priceCents('CHAIR-BLACK'),
        );
    }

    public function test_unknown_product_has_the_shared_failure_semantics(): void
    {
        $this->expectException(ProductNotFound::class);

        $this->catalogue()->priceCents('MISSING-SKU');
    }
}

Concrete test cases provide only the implementation:

final class InMemoryProductCatalogueContractTest
    extends ProductCatalogueContract
{
    protected function catalogue(): ProductCatalogue
    {
        return new InMemoryProductCatalogue([
            'CHAIR-BLACK' => 10_000,
        ]);
    }
}

The HTTP version constructs HttpProductCatalogue instead. It does not replace the expected exception, skip an example, or branch inside the contract. The same methods run against both implementations.

Inheritance is incidental here. A trait, helper, or generated test matrix can provide the same reuse. The important property is that one definition owns the consumer expectations. PHPUnit’s test organization guide describes tests and suites as composable; the fixture uses that composition to make drift visible in either direction.

In clean mode, both two-example suites pass. That is modest evidence, but it is specific: both implementations return the named price and throw the named exception for the same inputs.

A fake can agree with the happy path and violate the contract

The first divergence changes only the fake’s missing-SKU branch:

if (getenv('FAKE_DEFECT') === '1') {
    return 0;
}

Its known product is untouched. Two consumer controls still calculate a subtotal of 20000, one through the fake and one through the HTTP adapter. The HTTP contract also stays green.

The fake contract fails:

Tests\InMemoryProductCatalogueContractTest::
test_unknown_product_has_the_shared_failure_semantics
Failed asserting that exception of type
"App\Catalogue\ProductNotFound" is thrown.

The retained command finished with exit 1 in 273 milliseconds. The HTTP contract passed in 298 milliseconds, and the known-SKU controls passed in 286 milliseconds. These durations include starting a disposable container and local provider. They identify the runs, not an HTTP-versus-memory performance result.

The green controls matter. They show why interface conformance and the existing happy path were insufficient. Neither exercised the semantic partition which drifted.

The same contract can reject production-adapter drift

A contract suite for fakes only would still leave an asymmetry: the fake must match a production adapter whose own behavior is assumed correct. The shared suite removes that privilege.

The production adapter talks through Laravel’s HTTP client to a real local provider process. The provider returns 10000 for the known product and 404 for an unknown one. Laravel documents that its HTTP client does not throw for 4xx and 5xx responses by default, so the adapter owns the translation:

if ($response->status() === 404) {
    throw ProductNotFound::forSku($sku);
}

$response->throw();

The specific 404 becomes the port’s absence meaning. Other client and server errors follow Laravel’s explicit throw() path rather than being collapsed into “not found.”

Now make the adapter return zero for that 404. The mirrored result appears:

Seeded divergenceHTTP contractFake contractKnown-SKU controls
fake returns zeropassfailpass
HTTP adapter returns zerofailpasspass

The HTTP contract fails with the same missing-exception assertion in 328 milliseconds. The fake contract passes in 283 milliseconds, and both consumer controls pass in 291 milliseconds.

The contract does not care whether the divergence came from array lookup or HTTP status translation. It sees the behavior the caller sees. That is what makes it shared.

Shared behavior is smaller than the adapter test inventory

The contract should not become a dumping ground for every test involving the interface. The catalogue example separates its evidence like this:

Behavior or riskEvidence owner
known SKU returns exact integer centsshared contract
unknown SKU throws ProductNotFoundshared contract
provider 404 maps to absenceHTTP adapter through the shared contract
malformed provider JSON is rejectedHTTP-adapter-specific test
retry, timeout, and authentication policyintegration or resilience test
fake seed data is deterministicfake setup

A fake cannot receive malformed provider JSON. Making it run an assertion about that payload would leak HTTP details into the port contract. Conversely, an HTTP-only 404 test would not stop the fake from returning zero. Put an example in the shared suite only when every substitute must preserve it for the caller.

The local provider also does not turn this fixture into proof about DNS, TLS, remote availability, credentials, or deployment configuration. It exists to put real status parsing and Laravel’s response behavior inside the test boundary. Wider operational risks need wider evidence.

A finite suite measures named behavior, not total substitutability

Two passing examples do not prove that the implementations agree for every SKU, price, failure, or state transition. They prove the examples they execute. A useful contract grows from caller-visible partitions:

  • the lowest and highest valid price;
  • invalid or differently normalized SKU values;
  • unavailable, discontinued, and missing products if callers distinguish them;
  • stable exception categories and any context callers inspect; and
  • state changes or ordering guarantees when the port permits writes.

Add a partition because a caller depends on it or a plausible divergence can violate it. Do not copy every implementation test upward merely to make the contract suite look substantial.

Static analysis remains better evidence for method signatures and types. Copy-pasted tests can be sufficient when implementations intentionally serve different callers. A deployed smoke test remains better evidence for live connectivity. The shared suite earns its place in the gap between them: runtime behavior which every substitute claims to provide.

If concrete test cases need different expected values, exception categories, or conditional skips, the shared interface may be hiding different contracts. Splitting the port can be more honest than teaching a contract test to excuse each implementation.

Make the substitute prove the same promise

The mock-fortress experiment kept one catalogue double because remote HTTP was outside the quote risk. That boundary was deliberate, but the double did not become trustworthy by implementing the port.

For each production adapter and fake, list the outputs, failures, and side effects callers rely on. Put the genuinely shared examples in one suite. Then seed a realistic divergence in each implementation and confirm that only its contract run fails while relevant happy paths stay green.

An interface makes substitution possible. The shared contract makes a finite, named part of it measurable. When a new implementation cannot pass that same promise without exceptions in the test, decide whether the implementation is wrong or the port was never truly shared.