A Beginner’s Guide to Testing in Go

Published: (February 11, 2026 at 02:33 PM EST)
9 min read
Source: Dev.to

Source: Dev.to

Tests Enforce Contracts

Now right off the bat, you don’t want to get this wrong.
When writing tests, the key thing to keep in mind is that we should treat what we’re testing like a black box: give it specific inputs, expect specific outputs.

Example

func Create(url string) (shortCode string, err error)

The idea is that the function should take a URL string, e.g. https://exampleurl.com/this/is/really/long, and return a short code that represents the long URL, e.g. bdcagl. Or an error if something goes wrong. Those are the contract:

  • Input – a (valid) URL.
  • Output – a short code or an error.

Happy‑path test

t.Run("Valid URL", func(t *testing.T) {
    url := "https://example.com"

    shortCode, err := Create(url)
    if err != nil {
        t.Fatalf("expected nil err, got %v", err) // unexpected error → fail
    }

    // Check that shortCode was generated
    if shortCode == "" {
        t.Error(`expected short code, got ""`) // short code should not be empty
    }
})

The test above is what we call the happy path – it verifies the expected behavior when everything goes right.


Errors Are Part of the Contract Too

The contract also specifies which errors you should get for particular failure cases.

Bad‑input test (incorrect)

url := "https:invalid-url//example./com" // invalid input should fail and return an error

shortCode, err := Create(url)
if err == nil {
    t.Fatalf("expected error, got nil") // not enough – just checking presence
}

Better: check for a specific error

if !errors.Is(err, ErrInvalidURL) {
    t.Fatalf("expected ErrInvalidURL, got %v", err)
}

NOTE: Testing error cases is just as important as testing happy paths. DO NOT skip them.


What Even Is a Unit?

A quick look at a typical backend layering:

HTTP Request
   |
Handlers      → receive HTTP requests and send responses
   |
Services      → apply business rules and coordinate work
   |
Repository    → reads/writes data in the database
   |
Database

Unit definition

A unit is a cohesive chunk of behavior with one responsibility that can be reasoned about independently. It is not simply “a function”.

Example

type Shortener struct {
    store Store      // where you store the generated short code and its associated long URL
    gen   Generator // generates the short code
}

func (s *Shortener) Create(url string) (string, error) {
    // generates short code and saves it
}

func (s *Shortener) Resolve(shortCode string) (string, error) {
    // looks up original URL
}

func (s *Shortener) Stats(shortCode string) (int, error) {
    // returns hit count for a short code
}

The entire struct (Shortener) plus all its public methods (Create, Resolve, Stats) is ONE unit, not three.
When you write tests for Create, Resolve, and Stats, you are testing different behaviors of the same unit.

A unit is the smallest part of your code where you can say:
“If this breaks, I know exactly what kind of problem it is. Failures don’t blur together.”

Understanding this makes test organization much clearer.


Good Test Structure

After writing a bunch of messy tests, I settled on a simple, repeatable pattern:

Setup → Subject → Assertion

1. Setup

Prepare everything the test needs.
For the Create method above you need a Generator and a Store (the dependencies of Shortener).

func newTestShortener(t *testing.T) *Shortener {
    t.Helper()

    // mock or stub implementations
    store := NewInMemoryStore()
    gen   := NewFakeGenerator() // deterministic output for tests

    return &Shortener{
        store: store,
        gen:   gen,
    }
}

2. Subject

Call the function/method you are testing.

s := newTestShortener(t)

url := "https://example.com"
shortCode, err := s.Create(url)

3. Assertion

Verify the results (output, side‑effects, errors, etc.).

if err != nil {
    t.Fatalf("unexpected error: %v", err)
}
if shortCode == "" {
    t.Error("expected a short code, got empty string")
}

// optional: verify that the store now contains the mapping
if got, _ := s.store.Get(shortCode); got != url {
    t.Errorf("store contains %q, want %q", got, url)
}

Putting the three steps together yields a clean, readable test that is easy to extend and maintain.


TL;DR

  • Treat the code under test as a black box – define its contract (inputs → outputs, including specific errors).
  • Test both happy paths and error cases, checking for the exact errors you expect.
  • A unit is a cohesive piece of behavior, not just a single function.
  • Structure each test as: Setup → Subject → Assertion.

With this mental model and structure, your Go tests will become far more reliable and easier to reason about.

Setup

The setup stage prepares everything the element being tested needs, e.g.:

// Setup
store   := NewMockStore()
gen     := NewMockGenerator()
service := NewShortener(store, gen)

Subject

The subject is the thing you’re actually testing. Call the method or trigger the behavior, e.g.:

// Subject
shortCode, err := service.Create("https://example.com")

Assertion

Assertion verifies that the contract holds. Check outputs, errors, and side‑effects (the latter can be tricky, but we’ll discuss them later).

// Assertion
if err != nil {
    t.Fatalf("Expected no error, got %v", err)
}

if shortCode != "abc123" {
    t.Errorf("Expected abc123, got %s", shortCode)
}

Important: If setup fails, fail the test immediately. Example:

func TestCreate(t *testing.T) {
    store := NewMockStore()
    if store == nil {
        t.Fatal("Failed to create mock store") // stops the test here
    }

    service := NewShortener(store, mockGenerator)
    if service == nil {
        t.Fatal("Failed to create service") // stops test here
    }

    // Now do the actual test...
}

If you keep going after a failed setup you’ll get misleading errors, making debugging a nightmare.


Using Other Functions for Test Setup

When is it OK?

If two functions belong to the same unit, it’s fine to use one to set up the other. For example, Create and Resolve are both methods of the Shortener service, so using Create to set up a test for Resolve is reasonable:

func TestResolve(t *testing.T) {
    service := NewShortener(mockStore, mockGen)

    // Use Create for setup because Resolve needs a short code first
    shortCode, err := service.Create("https://example.com")
    if err != nil {
        t.Fatalf("Setup failed: %v", err) // fail fast if setup fails
    }

    // Now test Resolve
    url, err := service.Resolve(shortCode)
    if err != nil {
        t.Errorf("Resolve failed: %v", err)
    }

    if url != "https://example.com" {
        t.Errorf("Expected https://example.com, got %s", url)
    }
}

When NOT to do it

If the functions belong to different units, you should not use one to set up the other. Handlers are separate units, so a test for HandleRedirect must not depend on HandleShorten.

Bad approach (depends on another handler)

func TestHandleRedirect_OK(t *testing.T) {
    // Setup – calling another handler!
    handler.HandleShorten(rr, req) // creates a short code

    // Subject – test the redirect handler
    req := httptest.NewRequest(http.MethodGet, "/"+shortCode, nil)
    rr  := httptest.NewRecorder()
    handler.HandleRedirect(rr, req)

    res := rr.Result()
    res.Body.Close()

    if res.StatusCode != http.StatusFound {
        t.Fatalf("expected status 302, got %d", res.StatusCode)
    }
}

Problems:

  • The test now depends on two units (HandleShorten and HandleRedirect).
  • Failure attribution becomes ambiguous – did the redirect handler break, or the shorten handler?

Better approach (setup via lower‑level unit)

func TestHandleRedirect_OK(t *testing.T) {
    service := NewShortener(mockStore, mockGen)
    handler := NewHandler(service)

    // Setup: use the service directly, not another handler
    shortCode, err := service.Create("https://example.com")
    if err != nil {
        t.Fatalf("Setup failed: %v", err)
    }

    // Subject
    req := httptest.NewRequest(http.MethodGet, "/"+shortCode, nil)
    rr  := httptest.NewRecorder()
    handler.HandleRedirect(rr, req)

    res := rr.Result()
    res.Body.Close()

    if res.StatusCode != http.StatusFound {
        t.Fatalf("expected status 302, got %d", res.StatusCode)
    }
}

Why this is better

  • One unit under test: only HandleRedirect is exercised.
  • Setup uses a lower‑level implementation: the service is the true dependency of the handler.
  • Clear failure attribution: if the test fails, you know whether the handler logic or the service logic is at fault.
  • Honest dependency: the handler really does depend on the service, so using the service in the test reflects real‑world usage.

TL;DR

  • A test should not depend on the behavior of another unit.
  • Use functions from the same unit for setup when appropriate.
  • When testing across unit boundaries, set up state via the lower‑level component, not by calling another higher‑level component.

By following these principles you’ll get faster, more reliable, and easier‑to‑debug unit tests.

Unit Testing Guidelines

A unit is a single unit. Handlers don’t have that relationship.

Rule of Thumb

A helpful rule of thumb (tied back to the backend‑layers idea) is:

  • A test can depend on lower layers, but it shouldn’t depend on same‑layer entities unless they belong to the same unit.

Exception – Service layer
For the service layer you should not call lower‑layer functions (e.g., repository/DB) directly in your service tests, because that would bypass the business rules—the core of your application. The service layer’s responsibility is to enforce those rules.


Side Effects Are Tricky

A side effect is something your function changes internally that isn’t part of its return value.

// Example
service.Resolve(url) // increments a `hits` counter internally

The hits counter isn’t returned, so you must observe it separately, e.g.:

  • Create a dedicated function for metrics (service.Stats)
  • Query the database directly
  • Use other indirect observation techniques

Key Takeaways

  • A unit is a cohesive module with a single responsibility, not a single function.
    “If something fails, can I tell what kind of problem it is?” – clear failure attribution.

  • Tests enforce contracts – specific inputs → specific outputs.

  • Reasonable test structure: Setup → Subject → Assertion.

  • Fail fast on setup failures (t.Fatal()) to avoid misleading errors.

  • Side effects need indirect observation.

0 views
Back to Blog

Related posts

Read more »

Savior: Low-Level Design

Grinding Go: Low‑Level Design I went back to the drawing board for interview preparation and to sharpen my problem‑solving skills. Software development is in a...

Go templates

What are Go templates? Go templates are a way to create dynamic content in Go by mixing data with plain text or HTML files. They allow you to replace placehold...