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
ReduceStockbody 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.