JavaRush /Courses /Go SELF /Dependency test doubles for API tests

Dependency test doubles for API tests

Go SELF
Level 64, Lesson 2
Available

1. Why replace dependencies in handler tests

When you’re just starting to write a server, it’s very tempting to make a handler “self-contained”: it parses JSON, writes to a map, and produces an error. It’s quick, but it doesn’t last long. As soon as you have at least one non-trivial scenario (validation, lookup, different errors), the handler turns into a “Swiss army knife” — and it becomes hard to test, because you’re testing everything at once: HTTP, business logic, and storage.

A sane architecture does the opposite: the handler should be thin. It accepts an HTTP request, pulls out the data, calls a dependency (service/storage), and then translates the result into an HTTP response (status, headers, JSON).

And that’s where the question appears: how do you test the HTTP contract specifically, without pulling in the “entire universe” of dependencies?

The answer is test doubles: stub, fake, mock. They replace the real service/storage and give the test full control over what the handler receives as input and what it returns.

Minimal dependency interface

To replace a dependency, a handler’s dependency must not be a “concrete type”, but a “contract”. In Go, that contract is most often expressed as an interface.

It’s important not to go to the extreme and create an interface with 25 methods “just in case”, otherwise you’ll suffer when writing fakes. Make the interface exactly for the needs of a specific handler (or a small set of handlers).

Let’s assume we have a learning task (to-do) API, and we want to test the endpoint for getting a task by id.

Here are the minimal domain types and the service interface:


package taskapi

import "context"

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type TaskService interface {
	GetByID(ctx context.Context, id int) (Task, error)
}

The handler keeps TaskService inside a struct — that’s our “hook” for replacing it in tests:

package taskapi

import "net/http"

type Handler struct {
	svc TaskService
}

func NewHandler(svc TaskService) *Handler {
	return &Handler{svc: svc}
}

Now, to test the HTTP layer, we don’t need a real database. It’s enough to provide an implementation of TaskService that behaves the way the test needs.

2. Stub, fake, and mock: differences and how to choose

The words “mock” and “fake” are often used as synonyms, but it’s more useful to distinguish them by meaning. That helps you choose a tool instead of arguing in a meeting about “mocks are banned in our project”.

Below is a compact table worth keeping in mind:

Double Main idea What we verify When to use
Stub “Return a predefined answer” Handler behavior for different dependency outcomes When you need to switch branches: ok/not_found/validation/internal
Fake “A simplified working implementation” Longer scenarios close to reality, but without external resources When you want to test a chain of calls without a DB
Mock “Remember how you were called” Interaction: arguments, call count, order When it’s important to prove the handler passed the right parameters

Good news: most of the time, a stub is enough. A mock is the “noisiest” tool: it’s useful, but it easily turns a test into bookkeeping (“how many times who called whom”). A fake is a great compromise when you want realistic behavior, but without infrastructure.

How to choose without fanaticism

If your goal is to verify that the handler maps service errors into the HTTP contract correctly, a stub is almost always the best choice. It’s short, straightforward, and makes the test “about the contract”.

If your goal is to test a long data-driven scenario, but without infrastructure (no DB and migrations), a fake wins: it provides “almost real” behavior, but you control it in memory.

If your goal is to verify interaction (for example, the correct id, or that the method was called exactly once), a mock makes sense. But you should use it sparingly, because otherwise you’ll start testing “how the code is written” rather than “what it does”. And that’s the path to brittle tests: change the implementation (not the behavior) — tests fail.

3. Stub: branches and mapping errors into the HTTP contract

A stub is your lever to “switch reality”. We don’t model real storage; we simply say: “the service will return this value and this error”. In HTTP-layer tests, a stub is especially good because the HTTP layer mostly translates different outcomes into status codes and an error envelope.

Domain errors as the basis for mapping

Let the service return ErrNotFound when the task doesn’t exist:

package taskapi

import "errors"

var ErrNotFound = errors.New("not found")

A stub service implementation can look like this:

package taskapi

import "context"

type stubTaskService struct {
	task Task
	err  error
}

func (s stubTaskService) GetByID(ctx context.Context, id int) (Task, error) {
	return s.task, s.err
}

Now the test can “turn on” the not found branch:

package taskapi_test

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"example.com/taskapi"
)

func TestGetTask_NotFound(t *testing.T) {
	h := taskapi.NewHandler(stubTaskService{err: taskapi.ErrNotFound})

	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/10", nil)

	h.ServeHTTP(rr, req)

	if rr.Code != http.StatusNotFound {
		t.Fatalf("status=%d, want %d", rr.Code, http.StatusNotFound)
	}
}

There’s an important methodological point here: an HTTP-contract test doesn’t have to know how exactly the service looks up a task. It only cares that not found maps to 404. If you also verify the error envelope (and you should), you’ll add JSON decoding and comparisons of code/message.

Error mapping and errors.Is/As

A handler usually translates service errors into an HTTP response. And if you designed errors carefully, this can be done consistently and predictably.

In Go, it’s important to remember the idea “errors are values”: errors can be not only printed, but also analyzed and compared. And if errors are wrapped via %w, then errors.Is and errors.As let you understand “what actually happened” without tying yourself to the text.

A practical pattern for the HTTP layer looks like this:

  • the service returns the domain error ErrNotFound → the handler responds 404 + {code:"not_found" ...}
  • the service returns a domain validation error (for example, ValidationError) → the handler responds 400 + {code:"validation", fields:{...}}
  • the service returns any other error → the handler responds 500 + {code:"internal", message:"internal error"} and does not “leak” err.Error()

To test this, a stub is ideal: it lets you “feed” the handler any error.

For example, a validation error as a type:

package taskapi

type ValidationError struct {
	Fields map[string]string
}

func (e ValidationError) Error() string {
	return "validation failed"
}

Then in the test you pass ValidationError{Fields: map[string]string{"id":"must be integer"}} and verify that the error envelope contains fields. (Technically, this kind of validation more often belongs to parseID at the HTTP layer, but as a mechanics example it’s great: you learn to test mapping.)

At the same time, errors.Is/As become especially useful if the service returns a wrapped error, for example fmt.Errorf("storage: %w", ErrNotFound). Then the handler doesn’t have to compare err == ErrNotFound; it can do errors.Is(err, ErrNotFound) and stay resilient to added context.

4. Fake: an in-memory implementation without a database

A fake is useful when returning a single predefined answer isn’t enough. For example, you want to test the scenario “create a task → then request it by id”, but you still don’t want to spin up a real DB. And that’s reasonable: unit tests should be fast and simple.

Let’s make a minimal in-memory fake storage. It will store tasks in a map[int]Task.

package taskapi

import "context"

type fakeTaskService struct {
	data map[int]Task
}

func newFakeTaskService() *fakeTaskService {
	return &fakeTaskService{data: make(map[int]Task)}
}

func (f *fakeTaskService) GetByID(ctx context.Context, id int) (Task, error) {
	task, ok := f.data[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	return task, nil
}

You can “configure” such a fake directly in the test by just putting data into the map. It’s not the ideal abstraction, but it’s cheap and clear: add it — it exists; don’t add it — it doesn’t.

func TestGetTask_OK(t *testing.T) {
	fake := newFakeTaskService()
	fake.data[7] = taskapi.Task{ID: 7, Title: "learn httptest", Done: false}

	h := taskapi.NewHandler(fake)

	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/7", nil)

	h.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("status=%d, want %d", rr.Code, http.StatusOK)
	}
}

Yes, this already looks a bit like an integration scenario, but it’s still without a network, without a DB, without migrations, without “why Postgres didn’t start on CI”. Such a fake is especially good if you have multiple handlers and you want to test their behavior on “live” data, but without infrastructure.

5. Mock: verify how the handler calls the dependency

A mock is needed less often, but it’s indispensable when the test’s goal is to prove the correctness of interaction. For example, you want to verify that the handler actually parsed {id} and passed it to the service, rather than accidentally always passing 0.

Let’s make a mock that remembers which id it was called with:

package taskapi

import "context"

type mockTaskService struct {
	gotID int
	calls int
}

func (m *mockTaskService) GetByID(ctx context.Context, id int) (Task, error) {
	m.gotID = id
	m.calls++
	return Task{ID: id, Title: "x"}, nil
}

Test:

func TestGetTask_PassesIDToService(t *testing.T) {
	mock := &mockTaskService{}
	h := taskapi.NewHandler(mock)

	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/42", nil)

	h.ServeHTTP(rr, req)

	if mock.calls != 1 {
		t.Fatalf("calls=%d, want 1", mock.calls)
	}
	if mock.gotID != 42 {
		t.Fatalf("gotID=%d, want 42", mock.gotID)
	}
}

Notice the nuance: this test isn’t about the response body, not about JSON, not about headers. It’s about the handler correctly “passing the data through” inward. As soon as you start verifying in one test both gotID and the JSON response and middleware, the test becomes “about everything” and breaks from any tiny change. That’s why mock tests are best kept surgical.

6. Helpers for tests: so the code doesn’t turn into mush

When you start actively using doubles, there’s a risk that tests turn into a set of repetitive “if not equal — fail”. The checks themselves are fine, but readability drops quickly.

That’s why it’s useful to have small helper functions with t.Helper() in tests: when a test fails, Go will show the line where the helper was called, not its internals.

Let’s start with JSON decoding:

func mustDecodeJSON(t *testing.T, rr *httptest.ResponseRecorder, dst any) {
	t.Helper()

	if err := json.NewDecoder(rr.Body).Decode(dst); err != nil {
		t.Fatalf("decode json: %v", err)
	}
}

Now the test that verifies the error envelope becomes shorter and more readable, because it keeps the meaning instead of “technical noise”.

If you test via httptest.NewServer, a helper for closing resp.Body is useful (yes, defer resp.Body.Close() is usually enough, but a helper enforces discipline and makes close errors visible):

func mustClose(t *testing.T, c io.Closer) {
	t.Helper()

	if err := c.Close(); err != nil {
		t.Fatalf("close: %v", err)
	}
}

And one more important technique: don’t try to rewrite complex mock setup in every test. It’s better to keep doubles dumb and keep configuration next to the test. When you see at the start of a test stubTaskService{err: ErrNotFound}, your brain immediately understands the scenario. When you see a “mock builder with five methods and config”, your brain quietly goes on vacation.

7. Typical mistakes when using fakes and mocks

Mistake #1: huge interfaces “for the whole application”.
When an interface includes dozens of methods, any fake/mock becomes expensive: you have to implement everything even if the test needs only one method. That leads either to copy-paste or to empty stubs that people forget to update. Keep the interface minimal — for a specific handler or a narrow use case.

Mistake #2: mock tests verify internals instead of the contract.
If a test fails because the service was called “twice instead of once”, but the external behavior (the HTTP response) is still correct, that’s often a sign the test is coupled to implementation. Use a mock where interaction is truly part of the requirements, not just “interesting to observe”.

Mistake #3: the fake starts repeating the real implementation and turns into a “second system”.
A fake should be simple. Once you start adding transactions, complex indexes, “search optimization”, you’re quietly writing a mini-DB. At that point it’s easier either to move to real infrastructure in integration tests, or to simplify the test scenario.

Mistake #4: tests compare JSON as a string and then blame Go for “unstable field order”.
Field order can change, formatting can change, whitespace can change. Verify JSON structurally: decode into a struct/map and compare field values.

Mistake #5: “internal error” accidentally starts leaking outward with details.
A very common bug: someone decided to “improve diagnostics” and returned err.Error() to the client in a 500 response. Stub-based tests that expect a stable message are exactly what you need to catch such a change immediately. In Go, wrapping errors is useful, but it’s important to remember that wrapping makes details available for analysis — and that sometimes becomes part of the API.

1
Task
Go SELF, level 64, lesson 2
Locked
Greeting stub
Greeting stub
1
Task
Go SELF, level 64, lesson 2
Locked
Error stubs
Error stubs
1
Task
Go SELF, level 64, lesson 2
Locked
Fake tasks
Fake tasks
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION