Your First Useful Mock
Part 2 of 9 in the Getting Started with Mocks series. Previously: What Mocks Are Actually For.
Most testing tutorials start with a mocking framework. Install it, learn its annotations, and let the library generate the stand-in.
That is backwards. A useful mock starts with something your test cannot control, and the first job is not learning a library. It is making one important situation happen on purpose.
This series is for developers who can write a basic test but get stuck the moment the code calls an API. No framework. No taxonomy. One test you cannot write, one small change, and the mock that makes it possible. Every example is shown in Java, Node.js, Go, and Python, and the full code is in the companion repository.
- A mock is useful when it gives the test control over something it could not otherwise control.
- You cannot mock without a seam. Cutting the seam is the actual work; the mock is a one-liner after that.
- Every mocked test proves something and skips something. Write both down.
Meet the package notifier
The example for the whole series is a package notifier. It asks a carrier
where a package is. If the carrier says delayed, the customer gets a
message. Otherwise nothing happens.
Here is the first version. The carrier call is right there in the function.
Java
public final class PackageNotifier {
private static final String CARRIER_URL = "https://api.example-carrier.test";
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final ObjectMapper JSON = new ObjectMapper();
public static Optional<String> notify(String trackingNumber) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(CARRIER_URL + "/shipments/" + trackingNumber))
.GET()
.build();
HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode shipment = JSON.readTree(response.body());
if ("delayed".equals(shipment.path("status").asText())) {
return Optional.of("Package " + trackingNumber + " is delayed");
}
return Optional.empty();
}
}Node.js
const carrierUrl = 'https://api.example-carrier.test'
export async function notify(trackingNumber) {
const response = await fetch(`${carrierUrl}/shipments/${trackingNumber}`)
const shipment = await response.json()
if (shipment.status === 'delayed') {
return `Package ${trackingNumber} is delayed`
}
return null
}Go
const carrierURL = "https://api.example-carrier.test"
func Notify(trackingNumber string) (string, error) {
resp, err := http.Get(carrierURL + "/shipments/" + trackingNumber)
if err != nil {
return "", err
}
defer resp.Body.Close()
var shipment struct {
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&shipment); err != nil {
return "", err
}
if shipment.Status == "delayed" {
return fmt.Sprintf("Package %s is delayed", trackingNumber), nil
}
return "", nil
}Python
CARRIER_URL = "https://api.example-carrier.test"
def notify(tracking_number: str) -> str | None:
with urlopen(f"{CARRIER_URL}/shipments/{tracking_number}") as response:
shipment = json.load(response)
if shipment["status"] == "delayed":
return f"Package {tracking_number} is delayed"
return NoneSame shape in all four: fetch, parse, decide. It works. Now test the delayed case.
You cannot make a package be late
The test we want is obvious. Call the notifier with TRACK-123, expect
Package TRACK-123 is delayed.
Try to write it. Where does the delayed package come from?
The carrier owns that answer. The test cannot ask for it. You could find a tracking number that is delayed today, hard-code it, and watch the test break when the truck arrives. You could stand up a sandbox account and hope the sandbox has a delay button. Most do not.
Slow and flaky are the usual complaints about tests that hit real services. They are real, but they are not the problem here. The problem is that the test cannot request the situation it exists to check. It is observing the weather.
The companion repo keeps this version in a directory called before/ in each
language, with the test we want written out and skipped. Skipped is honest.
Passing would not be.
Put a seam in it
A seam is a place where you can change what code does without editing that code. The notifier above has none. The carrier is baked in.
The smallest seam is a parameter. Instead of knowing how to reach the carrier, the notifier takes a function that answers the question it actually cares about: where is this package?
Java
@FunctionalInterface
public interface ShipmentStatus {
String lookup(String trackingNumber) throws IOException, InterruptedException;
}
public final class PackageNotifier {
private final ShipmentStatus status;
public PackageNotifier(ShipmentStatus status) {
this.status = status;
}
public Optional<String> notify(String trackingNumber) throws IOException, InterruptedException {
if ("delayed".equals(status.lookup(trackingNumber))) {
return Optional.of("Package " + trackingNumber + " is delayed");
}
return Optional.empty();
}
}Node.js
export class Notifier {
#getShipmentStatus
constructor(getShipmentStatus) {
this.#getShipmentStatus = getShipmentStatus
}
async notify(trackingNumber) {
const status = await this.#getShipmentStatus(trackingNumber)
if (status === 'delayed') {
return `Package ${trackingNumber} is delayed`
}
return null
}
}Go
type ShipmentStatus func(trackingNumber string) (string, error)
type Notifier struct {
status ShipmentStatus
}
func New(status ShipmentStatus) *Notifier {
return &Notifier{status: status}
}
func (n *Notifier) Notify(trackingNumber string) (string, error) {
status, err := n.status(trackingNumber)
if err != nil {
return "", err
}
if status == "delayed" {
return fmt.Sprintf("Package %s is delayed", trackingNumber), nil
}
return "", nil
}Python
ShipmentStatus = Callable[[str], str]
class Notifier:
def __init__(self, get_shipment_status: ShipmentStatus) -> None:
self._get_shipment_status = get_shipment_status
def notify(self, tracking_number: str) -> str | None:
if self._get_shipment_status(tracking_number) == "delayed":
return f"Package {tracking_number} is delayed"
return NoneThe decision logic did not change. The HTTP code moved out. That move is the whole refactor, and it is the part beginners skip because it does not look like testing.
Write the test before the refactor and it is red in every language: Java and Go will not compile it, and Node and Python cannot import a class that does not exist yet. A test that will not build is just as failing as a test with a wrong assertion.
Here is the shape you just built. The notifier no longer knows how to reach a carrier. It knows there is a seam, and whoever constructs it decides what sits on the other side.
flowchart LR
N[Package notifier] --> S{{ShipmentStatus seam}}
S -->|in production| H[HTTP carrier client]
H --> C[(Carrier API)]
S -->|in a test| B["Stub: returns delayed"]
Make “delayed” happen on purpose
Now the test can answer the carrier question itself.
Java
@Test
void createsNotificationWhenPackageIsDelayed() throws Exception {
ShipmentStatus delayed = trackingNumber -> "delayed";
PackageNotifier notifier = new PackageNotifier(delayed);
assertEquals(
"Package TRACK-123 is delayed",
notifier.notify("TRACK-123").orElseThrow());
}Node.js
test('creates a notification when the package is delayed', async () => {
const delayed = async () => 'delayed'
const notifier = new Notifier(delayed)
assert.equal(await notifier.notify('TRACK-123'), 'Package TRACK-123 is delayed')
})Go
func TestNotifyDelayedPackage(t *testing.T) {
delayed := func(string) (string, error) { return "delayed", nil }
n := New(delayed)
message, err := n.Notify("TRACK-123")
if err != nil {
t.Fatal(err)
}
if message != "Package TRACK-123 is delayed" {
t.Fatalf("unexpected message: %q", message)
}
}Python
def test_creates_notification_when_package_is_delayed(self) -> None:
delayed = lambda tracking_number: "delayed"
notifier = Notifier(delayed)
self.assertEqual("Package TRACK-123 is delayed", notifier.notify("TRACK-123"))That lambda is the mock. One line. No library, no code generation, no
interface with twelve methods. It returns delayed because the test said so,
and the package is late on demand.
This post uses “mock” in the loose, everyday sense. Post 6 will split it into stubs, spies, fakes, and mocks, and the site’s Mock vs Stub post draws the same line if you want it now. For now the only idea that matters is control: the test owns the answer, so the test can check the decision.
Change the answer
The second test is the same shape with the opposite answer.
Java
@Test
void createsNoNotificationWhenPackageIsDelivered() throws Exception {
ShipmentStatus delivered = trackingNumber -> "delivered";
PackageNotifier notifier = new PackageNotifier(delivered);
assertTrue(notifier.notify("TRACK-123").isEmpty());
}Node.js
test('creates no notification when the package is delivered', async () => {
const delivered = async () => 'delivered'
const notifier = new Notifier(delivered)
assert.equal(await notifier.notify('TRACK-123'), null)
})Go
func TestNotifyDeliveredPackage(t *testing.T) {
delivered := func(string) (string, error) { return "delivered", nil }
n := New(delivered)
message, err := n.Notify("TRACK-123")
if err != nil {
t.Fatal(err)
}
if message != "" {
t.Fatalf("expected no message, got %q", message)
}
}Python
def test_creates_no_notification_when_package_is_delivered(self) -> None:
delivered = lambda tracking_number: "delivered"
notifier = Notifier(delivered)
self.assertIsNone(notifier.notify("TRACK-123"))Each language says “nothing to send” its own way: an empty Optional, null,
an empty string, or None. The behavior is identical. Two tests, no carrier
account, no API key, no network, and they finish before you can read this
sentence.
Wire the real thing back in
The HTTP code did not disappear. It moved into something that fits the seam.
Java
public final class HttpShipmentStatus implements ShipmentStatus {
private final String baseUrl;
private final HttpClient http = HttpClient.newHttpClient();
public HttpShipmentStatus(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public String lookup(String trackingNumber) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/shipments/" + trackingNumber))
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
// ... same status check and JSON parse as before ...
return shipment.path("status").asText();
}
}Node.js
export function httpShipmentStatus(baseUrl) {
return async (trackingNumber) => {
const response = await fetch(`${baseUrl}/shipments/${trackingNumber}`)
if (!response.ok) {
throw new Error(`carrier returned ${response.status}`)
}
const shipment = await response.json()
return shipment.status
}
}Go
func HTTPShipmentStatus(baseURL string) ShipmentStatus {
return func(trackingNumber string) (string, error) {
resp, err := http.Get(baseURL + "/shipments/" + trackingNumber)
// ... same status check and JSON decode as before ...
return shipment.Status, nil
}
}Python
def http_shipment_status(base_url: str) -> ShipmentStatus:
def lookup(tracking_number: str) -> str:
with urlopen(f"{base_url}/shipments/{tracking_number}") as response:
if response.status != 200:
raise RuntimeError(f"carrier returned {response.status}")
shipment = json.load(response)
return shipment["status"]
return lookupProduction builds the notifier with the HTTP version. The tests build it with the lambda. Same slot, different occupant. That is all dependency injection is, and you just did it without a framework.
What this test proves
The notifier produces a delayed-package message when the carrier reports
delayed. It produces nothing when the carrier reports delivered.
Those are claims about our code, not about the carrier. They are the claims the notifier exists to make good on, and they now hold every time, on every machine, with the network cable out.
What this test does not prove
It does not prove the real carrier is reachable. It does not check the URL
path, the request headers, or whether the JSON field is really called status.
It cannot tell you that production points at the right host.
The HTTP implementation never runs under test in this post. That is a gap, and it is a deliberate one. A mock buys control by giving up reality. The mistake is not the trade. The mistake is forgetting you made it, which is how a green suite ends up covering a client that has not worked in a month.
Post 7 tests that layer with a fake HTTP server in each language. Post 8 is about what happens when even that fake drifts from the truth. This test should stay small and keep its one job.
Try it
Add a third status, unknown, and decide what the notifier should do. Write
the test first, in your language, and do not reach for a mocking library. For
where this leads once a project outgrows hand-written stubs, the
API mocking tools round-up maps the territory this
series reaches in post 9. The full code, with the before/ version and the production wiring for all four
languages, is in the
companion repository.
Next: the same seam, now returning bad news. Make Failure Boring with Mocks.
Series navigation: Previous: What Mocks Are Actually For · All nine posts · Next: Make Failure Boring with Mocks