Skip to content
academia.sh

Lesson 04 / 16

Faking and Interface Separation

The same ReduceStock body is run with both a recording and a spy Notifier implementation: in a normal reduction both give the same verdict, and the spy object's call count reads 1. The limiting measurement shows that in a call reducing more than the stock, the real implementation rejects the negative remainder while the spy object silently records the same call.

Published

In this lesson

  • Reads from the source that the Notifier interface binds ReduceStock to a contract, not a concrete implementation
  • Runs the same test body with a real and a fake implementation and reads the spy object's call count
  • Limits, with the case of reducing more than the stock, that an assumption holding in the real implementation is never tested in the fake one
Contents

The previous lesson measured that an HTTP handler can be tested without ever making a real connection, but the handler itself still looked at a fixed store built by hand. This lesson moves that store to another dependency behind the handler — a notification sender — and asks the same question: if this dependency is something slow, unreliable, or side-effecting in the real world (writing to an external system, for instance), how do you test the same logic without touching that dependency at all?

The previous lesson had, in a sense, already solved this question: httptest.NewRecorder was a pre-written fake that implemented the standard library’s own http.ResponseWriter interface. This lesson applies the same pattern to a dependency that has no ready-made fake in the standard library: this package itself writes Notifier and its two implementations.

The fake dependency family — mock object, stub, imitation object, spy object, dummy object — was built with its taxonomy in the Unit Testing and Test-Driven Development course and is not reintroduced here; neither is the taxonomy retold nor is every member implemented separately in this lesson. This lesson’s added question is narrow: what makes faking possible, and where does that thing appear in the source?

In production codebases it is often not possible to test everything a function calls in its real form: a notification sender might write to a real external system, a payment provider might move real money, a storage layer might connect to a real server. A fake standing in for these dependencies frees the test from these costs — but this freedom has its own price, and this lesson’s limiting measurement counts exactly that price. As long as the price stays invisible it carries no risk; this lesson’s goal is to make the price visible, not to make you abandon faking.

What Makes Faking Possible Is Interface Separation

One more function is added to the inventory package: ReduceStock reduces an item’s quantity and sends a notification when the remaining amount drops below a certain threshold — the threshold is fixed at 2 in this lesson. ReduceStock does not call the notification sender directly — it calls it through a Notifier interface.

// inventory.go — ReduceStock is bound not to a concrete notification sender but to an interface
package main

import (
	"errors"
	"fmt"
)

type Item struct {
	Name     string
	Quantity int
	Price    int
}

type Notifier interface {
	Notify(name string, remainingQuantity int) error
}

func ReduceStock(store map[string]Item, name string, amount int, n Notifier) error {
	it := store[name]
	it.Quantity -= amount
	store[name] = it
	if it.Quantity < 2 {
		return n.Notify(name, it.Quantity)
	}
	return nil
}

// recordingNotifier is the real implementation: it rejects a negative remainder.
type recordingNotifier struct {
	records []string
}

func (r *recordingNotifier) Notify(name string, remainingQuantity int) error {
	if remainingQuantity < 0 {
		return errors.New("a negative remainder cannot be reported")
	}
	r.records = append(r.records, fmt.Sprintf("%s:%d", name, remainingQuantity))
	return nil
}

// spyNotifier is the fake implementation: it records the call without validating it.
type spyNotifier struct {
	calls int
}

func (s *spyNotifier) Notify(name string, remainingQuantity int) error {
	s.calls++
	return nil
}

TM10. ReduceStock’s last parameter is Notifier — an interface consisting of nothing but a method signature, not a concrete type like recordingNotifier or spyNotifier. This is a decision that can be read directly from the source: the code calling ReduceStock can pass in any type that carries the Notify method, and ReduceStock’s body never knows which concrete type was passed. This is exactly what makes faking possible: if ReduceStock took *recordingNotifier directly, there would be no way at all to pass in something else in its place.

The inventory package gained exactly one new dependency throughout this lesson, and this dependency was defined inside the package itself — Notifier is not a type imported from a real external package, it is inventory’s own interface, defining only the single method ReduceStock needs. This is an example of the consumer of an interface (here, ReduceStock) defining a small interface to fit its own needs: an interface is not the broad contract a library exposes to the outside world, but the narrow contract that covers only as much as the caller actually uses.

The Notifier interface carries a single method, and this narrowness is deliberate: the narrower an interface is, the less work it takes to write a type that implements it. Both recordingNotifier and spyNotifier satisfy the interface by defining only this one method (Notify) — in Go, a type does not have to separately declare that it satisfies an interface; if its method set covers the set the interface asks for, the type satisfies that interface on its own. This is why spyNotifier can be passed to ReduceStock while having no connection at all to the rest of the inventory package, only by carrying the Notify method.

The interface itself guarantees that the real and the fake implementation carry the same contract: both can be called with the same signature (Notify(name string, remainingQuantity int) error), and ReduceStock’s body runs the same line (n.Notify(name, it.Quantity)) without distinguishing which one it is working with. But this contract only guarantees the signature — not the behavior behind it. Whether the two implementations give the same answer to the same input cannot be read from the interface itself at all; the answer to that question emerges in the measurement below.

Same Body, Two Implementations

ReduceStock’s body has exactly one line concerning the Notifier type: n.Notify(name, it.Quantity). Which concrete type this line works with depends on which value the caller passed to ReduceStock — the function itself never makes this decision. The measurement below leaves this decision to the caller, calling the same function twice with two different third arguments.

TM11. The same ReduceStock call is run first with the real implementation (recordingNotifier), then with the fake implementation (spyNotifier); two are reduced from a three-quantity item, the remainder (1) is below the threshold, so the notification fires.

// measurement.go — the same body runs with a real and a fake implementation
package main

import "fmt"

func main() {
	fmt.Println("-- run: same body with the real and the fake --")
	store1 := map[string]Item{"pen": {"pen", 3, 250}}
	real := &recordingNotifier{}
	errReal := ReduceStock(store1, "pen", 2, real)
	fmt.Println("real: error present:", errReal != nil, "record count:", len(real.records))

	store2 := map[string]Item{"pen": {"pen", 3, 250}}
	fake := &spyNotifier{}
	errFake := ReduceStock(store2, "pen", 2, fake)
	fmt.Println("fake: error present:", errFake != nil, "spy call count:", fake.calls)
}
-- run: same body with the real and the fake --
real: error present: false record count: 1
fake: error present: false spy call count: 1

The measurement builds two separate store variables (store1, store2), with the same content but as separate maps — since ReduceStock mutates the store itself, if a single store were used twice, the second call would start from the state the first call left changed, and the two implementations’ verdicts would no longer rest on the same input. This isolation rests on the same reasoning as tools.Setup’s building a completely empty module on every run in the earlier lessons: one measurement must not carry the trace of a previous one.

The body of ReduceStock is byte-for-byte identical across both runs; the only thing that changes is the value passed as the last argument. In the real implementation, the record list’s length rises to 1 — this tracks a real side effect. In the fake implementation, the same call raises the calls counter to 1 — all the spy object measures is this: how many times it was called, not what it did. Both measurements arrive at the same result (error present: false), and to this extent it can be said that the fake stands in for the real one.

spyNotifier is specifically a spy object here — the member of the fake dependency family that records calls and allows them to be queried afterward. Notify does nothing when called, it only increments a counter; this is enough to test whether ReduceStock fired a notification, because the measurement’s question is “was a notification sent,” not “was the notification’s content correct.” The same scenario could be extended with a spy that also keeps the call’s arguments instead of just the count — spyNotifier is kept in its narrowest form here, just a counter, because that is all this measurement asks.

The name recordingNotifier is itself a choice too: this type is not just a dummy that records calls, it is an implementation that really makes a decision — rejecting a negative remaining quantity is not a fake rule, it is logic that would go into production. This lesson calls it “real” because it builds a small model of the actual implementation ReduceStock will work with in production — it, too, writes to no external system in this lesson, but the validation rule it carries is of the kind a real external system might carry.

The Limiting Measurement: The Fake Implementation Tests Less

ReduceStock itself does not reject a call that would push the quantity negative — this is a design decision: how reducing more than the stock is handled was left to Notifier’s responsibility, not ReduceStock’s. This is a branch invisible in the previous measurement; when two were reduced from a three-quantity item, the remainder always stayed positive.

TM12. The same measurement is now run reducing five from a three-quantity item — the remaining quantity drops negative (-2) and Notify is called with this negative value.

// boundary.go — an assumption holding in the real implementation is never tested in the fake one
package main

import "fmt"

func main() {
	fmt.Println("-- limiting measurement: reducing more than the stock --")
	store3 := map[string]Item{"pen": {"pen", 3, 250}}
	real2 := &recordingNotifier{}
	errReal2 := ReduceStock(store3, "pen", 5, real2)
	fmt.Println("real: error present:", errReal2 != nil)

	store4 := map[string]Item{"pen": {"pen", 3, 250}}
	fake2 := &spyNotifier{}
	errFake2 := ReduceStock(store4, "pen", 5, fake2)
	fmt.Println("fake: error present:", errFake2 != nil, "spy call count:", fake2.calls)
}
-- limiting measurement: reducing more than the stock --
real: error present: true
fake: error present: false spy call count: 1

The call count comes out the same in both implementations — fake2.calls is still 1, because Notify is still called exactly once. What changes is the outcome of the call, not the call itself; the spy object correctly counts whether the call happened, but never looks at whether the value the call carried is valid. This time the two implementations reach different verdicts. recordingNotifier encodes an assumption a real external system might carry in the real world: a negative remaining quantity cannot be reported, because such a state is meaningless in the external system’s eyes. When this assumption is violated, the real implementation returns an error, and ReduceStock itself carries this error along unchanged to its caller. spyNotifier carries no assumption at all — it only records that it was called, and always reports success; called with the same negative value, it silently raises calls by 1 and returns nil.

This is a limit specific to this lesson within the topic’s measurement axis: a test suite using only spyNotifier never tests whether ReduceStock correctly handles the case of reducing more than the stock — because the spy object itself does not distinguish this case, it accepts every call in the same way. An assumption that holds in the real implementation goes entirely untested in the fake implementation; a faking test therefore tests less than the real test, not more. This is not a flaw but the nature of a fake: a fake does not carry all the rules of the real system behind it, only as much as the person who wrote it encoded.

This has a concrete production-side counterpart: even if a test suite written only with a spy object looks green, ReduceStock will hit an error in this scenario once the real external system engages — and the rule producing that error might be written in the external system’s own documentation, yet never carried into the fake implementation at all. This is another form of the limit measured in Test Package Basics: there, a passing test did not say it was correct, only that what it tested held; here, a passing faking test says only that the fake’s own rules held — not the real system’s rules. Both limits trace to the same root: a test passing carries no claim about anything outside the scope of what it tested. The number of methods the fake implements is the same as the real implementation here (both implement only Notify), but the number of behaviors is not: the real implementation carries two behaviors (accepting, rejecting), the fake carries only one (accepting). The interface itself never shows this difference, because an interface only enforces the signature, not the number of behaviors.

This limit does have an exit, though it is outside this lesson’s scope: writing a separate contract test that periodically tests the fake implementation against the real implementation’s assumptions could separately verify that the two sides’ behavior does not drift apart. There is no such contract test in this lesson — the goal is to show the fake’s limit, not to offer a solution that closes it.

spyNotifier is the right choice for this measurement, because the question asked is “how many times was it called” — the other members of the fake dependency family exist to answer different questions. A stub would return a fixed value and record nothing; a dummy object would only fill a parameter slot, and it would make no difference if Notify were never called at all. The behavior of ReduceStock measured here — whether the notification is triggered and how many times it is triggered — is exactly the kind of information a spy object measures; that is why this lesson used only that one of the five members.

Summary

  • ReduceStock’s last parameter is not a concrete type but an interface (Notifier); this separation can be read directly from the source, and it is exactly what makes faking possible — the information that makes faking possible is stated by the source.
  • The same ReduceStock body was run with a real (recordingNotifier) and a fake (spyNotifier) implementation; in a normal reduction both gave the same verdict.
  • What the spy object measures is only the call count — not what it did, but how many times it was called.
  • The limiting measurement: in a call reducing more than the stock, the real implementation rejected the negative remainder, the fake implementation silently accepted the same call — the faking test never tested this assumption at all.
  • The fake dependency taxonomy was built in the Unit Testing and Test-Driven Development course; it is not retold here, only measured through Go’s own interface machinery.

Next Step

This lesson measured that an interface makes a dependency swappable between a real and a fake implementation, but that the fake implementation does not carry all the real one’s assumptions. Notifier stayed a narrow, single-method interface; recordingNotifier carried its own validation rule, spyNotifier kept only a call counter — and the two implementations gave the same verdict in a normal reduction and different verdicts in an excessive one. The four lessons so far each passed or failed one at a time, gave or did not give an error one at a time — but none of them asked which number among these verdicts changes from run to run. The next lesson asks exactly this question: when two writings of the same work are compared, which number stays the same across five separate runs, and which does not — and the topic’s binding numbers are also set up there.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close