API-DD

Practical guide

The mock that makes a correct refactoring fail

A mock requires Find followed by Save to cancel a booking. A transactional implementation preserves the behavior, but the test fails because it protected the internal path.

By Lautaro Mei

A service cancels a booking by loading it, changing its status, and saving it:

go
type repository interface {
    Find(ctx context.Context, id BookingID) (Booking, error)
    Save(ctx context.Context, booking Booking) error
}

type Service struct {
    bookings repository
}

func (s *Service) Cancel(ctx context.Context, id BookingID) error {
    booking, err := s.bookings.Find(ctx, id)
    if err != nil {
        return err
    }
    if err := booking.Cancel(); err != nil {
        return err
    }
    return s.bookings.Save(ctx, booking)
}

The test uses a mock to describe that path:

go
func TestCancel(t *testing.T) {
    repo := newMockRepository(t)
    booking := ConfirmedBooking("booking-42")

    repo.ExpectFind("booking-42").Return(booking, nil)
    repo.ExpectSave(booking.WithStatus(Cancelled)).Return(nil)

    service := NewService(repo)

    if err := service.Cancel(context.Background(), "booking-42"); err != nil {
        t.Fatalf("cancel: %v", err)
    }
    repo.VerifyExpectations()
}

The test passes and looks precise. It has also turned two internal decisions into requirements: cancellation must call Find and then Save.

The consumer who requested a cancellation never made either promise.

An atomic implementation breaks the test, not the contract#

In production, the read-then-write path lets another process change the booking between both operations. The repository can offer an atomic transition:

go
type repository interface {
    Cancel(ctx context.Context, id BookingID) error
}

func (s *Service) Cancel(ctx context.Context, id BookingID) error {
    return s.bookings.Cancel(ctx, id)
}

The SQL adapter can apply the transition with a conditional statement:

text
UPDATE bookings
SET status = 'cancelled'
WHERE id = $1 AND status = 'confirmed';

From the outside, it can preserve exactly the same outcomes:

If the contract distinguishes the last two cases, the adapter can translate the affected-row count and inspect the reason inside the same transaction. That decision does not expose Find and Save to the service again.

The previous test does not even compile, however. There is no Find or Save to configure.

That does not show that the refactoring is wrong. It shows that the test confused the chosen mechanism with the promised behavior.

Test the transition the consumer can observe#

A test written from the outside prepares a state, sends the message, and queries the result:

go
func TestCancelChangesConfirmedBooking(t *testing.T) {
    app := newTestApp(t)
    app.bookings.givenConfirmed("booking-42")

    err := app.cancellations.Cancel(
        context.Background(),
        "booking-42",
    )

    if err != nil {
        t.Fatalf("cancel: %v", err)
    }
    if got := app.bookings.statusOf("booking-42"); got != Cancelled {
        t.Fatalf("status = %q, want %q", got, Cancelled)
    }
}

newTestApp can use an in-memory adapter or an ephemeral database. The observation point matters more than that detail: the body of the test speaks in terms of confirmed bookings, cancellation, and final status.

Replacing Find plus Save with Cancel may require adapting the fixture. The assertion does not change. It still rejects an implementation that returns nil without cancelling, and it accepts both an in-memory transition and an atomic UPDATE.

A test surviving through magic with no edits is not the objective. The objective is for it to fail when a promise changes, not merely when the path used to fulfill that promise changes.

A fake is not correct just because it is called a fake#

Replacing the mocking framework with a handwritten struct does not remove the coupling if the test keeps recording calls:

go
type fakeRepository struct {
    calls []string
}

func (f *fakeRepository) Find(...) (...) {
    f.calls = append(f.calls, "Find")
    // ...
}

func (f *fakeRepository) Save(...) error {
    f.calls = append(f.calls, "Save")
    // ...
}

An assertion on []string{"Find", "Save"} freezes the same path with less library code.

A fake is useful when it implements observable semantics. In this example, it stores bookings and applies the relevant rules: it cannot cancel a missing booking, and a successful cancellation changes the status. The test queries that state instead of the private history of methods used to produce it.

Some interactions are the promise#

Not every call expectation is an internal detail. If the contract says a successful cancellation publishes BookingCancelled, publication is an observable effect:

go
func TestCancelPublishesBookingCancelled(t *testing.T) {
    events := &recordingEvents{}
    app := newTestAppWithEvents(t, events)
    app.bookings.givenConfirmed("booking-42")

    err := app.cancellations.Cancel(context.Background(), "booking-42")

    if err != nil {
        t.Fatalf("cancel: %v", err)
    }
    if !events.Contains(BookingCancelled{ID: "booking-42"}) {
        t.Fatal("BookingCancelled was not published")
    }
}

The test does not need to require Publish to happen before or after the private persistence method unless that order is a real guarantee. It checks the promised fact and leaves the delivery mechanism free to change.

The difference depends on the boundary under test:

ExpectationCan the consumer observe it?What it protects
calls Find oncenointernal path
calls Save after Findnoimplementation order
the booking becomes cancelledyespublic outcome
publishes BookingCancelledyes, if it belongs to the contractpromised effect
does not publish after a rejected cancellationyes, if guaranteedabsence of an effect

The mistake usually happens before the mock#

An over-specified mock often means the test started from the existing collaborations:

  1. it opened the implementation;
  2. it found Find and Save;
  3. it created expectations for both calls;
  4. it called the public method at the end.

The safer order starts with a plausible incorrect alternative. For Cancel, an implementation could report success without changing the booking. The smallest test should reject that alternative. Querying the final state does; counting calls to Save rejects it only while Save remains the chosen mechanism.

Before verifying an interaction, ask: if a transaction, cache, or batch operation preserves the same behavior tomorrow without making this call, would the consumer have a reason to reject it?

If the answer is no, the expectation belongs to the implementation.

A test protects a refactoring when it fixes what must remain true, not the steps the current code uses to make it true.