Your Go tests probably don't need a mocking library

· 16 min

There are frameworks that generate those kind of fakes, and one of them is called GoMock… they’re fine, but I find that on balance, the handwritten fakes tend to be easier to reason about and clearer to sort of see what’s going on. But I’m not an enterprise Go programmer. Maybe people do need that, so I don’t know, but that’s my advice.

– Andrew Gerrand, Testing Techniques (46:44)

No shade against mocking libraries like gomock or mockery. I use them all the time, both at work and outside. But one thing I’ve noticed is that generating mocks often leads to poorly designed tests and increases onboarding time for a codebase.

Tap compare testing for service migration

· 17 min

Throughout the years, I’ve been part of a few medium- to large-scale system migrations. As in, rewriting old logic in a new language or stack. The goal is usually better scalability, resilience, and maintainability, or more flexibility to adapt to changing requirements. Now, whether rewriting your system is the right move is its own debate.

A common question that shows up during a migration is, “How do we make sure the new system behaves exactly like the old one, minus the icky parts?” Another one is, “How do we build the new system while the old one keeps changing without disrupting the business?”

Re-exec testing Go subprocesses

· 6 min

When testing Go code that spawns subprocesses, you usually have three options.

Run the real command. It invokes the actual binary that creates the subprocess and asserts against the output. However, that makes tests slow and tied to the environment. You have to make sure the same binary exists and behaves the same everywhere, which is harder than it sounds.

Fake it. Mock the subprocess to keep tests fast and isolated. The problem is that the fake version doesn’t behave like a real process. It won’t fail, write to stderr, or exit with a non-zero code. That makes it hard to trust the result, and over time the mock can drift away from what the real command actually does.

Revisiting interface segregation in Go

· 6 min

Object-oriented (OO) patterns get a lot of flak in the Go community, and often for good reason.

Still, I’ve found that principles like SOLID, despite their OO origin, can be useful guides when thinking about design in Go.

Recently, while chatting with a few colleagues new to Go, I noticed that some of them had spontaneously rediscovered the Interface Segregation Principle (the “I” in SOLID) without even realizing it. The benefits were obvious, but without a shared vocabulary, it was harder to talk about and generalize the idea.

Organizing Go tests

· 7 min

When it comes to test organization, Go’s standard testing library only gives you a few options. I think that’s a great thing because there are fewer details to remember and fewer things to onboard people to. However, during code reviews, I often see people contravene a few common conventions around test organization, especially those who are new to the language.

If we distill the most common questions that come up when organizing tests, they are:

Subtest grouping in Go

· 10 min

Go has support for subtests starting from version 1.7. With t.Run, you can nest tests, assign names to cases, and let the runner execute work in parallel by calling t.Parallel from subtests if needed.

For small suites, a flat set of t.Run calls is usually enough. That’s where I tend to begin. As the suite grows, your setup and teardown requirements may demand subtest grouping. There are multiple ways to handle that.

Test state, not interactions

· 8 min

With the advent of LLMs, the temptation to churn out a flood of unit tests for a false veneer of productivity and protection is stronger than ever.

My colleague Matthias Doepmann recently fired a shot at AI-generated tests that don’t validate the behavior of the System Under Test (SUT) but instead create needless ceremony around internal implementations. At best, these tests give a shallow illusion of confidence in the system’s correctness while breaking at the smallest change. At worst, they remain green even when the SUT’s behavior changes.

Early return and goroutine leak

· 7 min

At work, a common mistake I notice when reviewing candidates’ home assignments is how they wire goroutines to channels and then return early.

The pattern usually looks like this:

  • start a few goroutines
  • each goroutine sends a result to its own unbuffered channel
  • in the main goroutine, read from those channels one by one
  • if any read contains an error, return early

The trap is the early return. With an unbuffered channel, a send blocks until a receiver is ready. If you return before reading from the remaining channels, the goroutines writing to them block forever. That’s a goroutine leak.

Lifecycle management in Go tests

· 8 min

Unlike pytest or JUnit, Go’s standard testing framework doesn’t give you as many knobs for tuning the lifecycle of your tests.

By lifecycle I mean the usual setup and teardown hooks or fixtures that are common in other languages. I think this is a good thing because you don’t need to pick up many different framework-specific workflows for something so fundamental.

Go gives you enough hooks to handle this with less ceremony. But it can still be tricky to figure out the right conventions for setup and teardown that don’t look odd to other Gophers, especially if you haven’t written Go for a while. This text explores some common ways to do lifecycle management in your Go tests.

Flags for discoverable test config in Go

· 7 min

As your test suite grows, you need ways to toggle certain kinds of tests on or off. Maybe you want to enable snapshot tests, skip long-running integration tests, or switch between real services and mocks. In every case, you’re really saying, “Run this test only if X is true.”

So where does X come from?

I like to rely on Go’s standard tooling so that integration and snapshot tests can live right beside ordinary unit tests. Because I usually run these heavier tests in testcontainers, I don’t always want them running while I’m iterating on a feature or chasing a bug. So I need to enable them in an optional manner.

You probably don't need a DI framework

· 8 min

When working with Go in an industrial programming context, I feel like dependency injection (DI) often gets a bad rep because of DI frameworks. But DI as a technique is quite useful. It just tends to get explained with too many OO jargons and triggers PTSD among those who came to Go to escape GoF theology.

Dependency Injection is a 25-dollar term for a 5-cent concept.

– James Shore

DI basically means passing values into a constructor instead of creating them inside it. That’s really it. Observe:

Capturing console output in Go tests

· 5 min

Ideally, every function that writes to the stdout probably should ask for a io.Writer and write to it instead. However, it’s common to encounter functions like this:

func frobnicate() {
    fmt.Println("do something")
}

This would be easier to test if frobnicate would ask for a writer to write to. For instance:

func frobnicate(w io.Writer) {
    fmt.Fprintln(w, "do something")
}

You could pass os.Stdout to frobnicate explicitly to write to the console:

Deferred teardown closure in Go testing

· 6 min

While watching Mitchell Hashimoto’s Advanced Testing with Go talk, I came across this neat technique for deferring teardown to the caller. Let’s say you have a helper function in a test that needs to perform some cleanup afterward.

You can’t run the teardown inside the helper itself because the test still needs the setup. For example, in the following case, the helper runs its teardown immediately:

func TestFoo(t *testing.T) {
    helper(t)

    // Test logic here: resources may already be cleaned up!
}

func helper(t *testing.T) {
    t.Helper()

    // Setup code here.

    // Teardown code here.
    defer func() {
        // Clean up something.
    }()
}

When helper is called, it defers its teardown - which executes at the end of the helper function, not the test. But the test logic still depends on whatever the helper set up. So this approach doesn’t work.

Function types and single-method interfaces in Go

· 6 min

People love single-method interfaces (SMIs) in Go. They’re simple to implement and easy to reason about. The standard library is packed with SMIs like io.Reader, io.Writer, io.Closer, io.Seeker, and more.

One cool thing about SMIs is that you don’t always need to create a full-blown struct with a method to satisfy the interface. You can define a function type, attach the interface method to it, and use it right away. This approach works well when there’s no state to maintain, so the extra struct becomes unnecessary. However, I find the syntax for this a bit abstruse. So, I’m jotting down a few examples here to reference later.

Injecting Pytest fixtures without cluttering test signatures

· 2 min

Sometimes, when writing tests in Pytest, I find myself using fixtures that the test function/method doesn’t directly reference. Instead, Pytest runs the fixture, and the test function implicitly leverages its side effects. For example:

import os
from collections.abc import Iterator
from unittest.mock import Mock, patch
import pytest


# Define an implicit environment mock fixture that patches os.environ
@pytest.fixture
def mock_env() -> Iterator[None]:
    with patch.dict("os.environ", {"IMPLICIT_KEY": "IMPLICIT_VALUE"}):
        yield


# Define an explicit service mock fixture
@pytest.fixture
def mock_svc() -> Mock:
    service = Mock()
    service.process.return_value = "Explicit Mocked Response"
    return service


# IDEs tend to dim out unused parameters like mock_env
def test_stuff(mock_svc: Mock, mock_env: Mock) -> None:
    # Use the explicit mock
    response = mock_svc.process()
    assert response == "Explicit Mocked Response"
    mock_svc.process.assert_called_once()

    # Assert the environment variable patched by mock_env
    assert os.environ["IMPLICIT_KEY"] == "IMPLICIT_VALUE"

In the test_stuff function above, we directly use the mock_svc fixture but not mock_env. Instead, we expect Pytest to run mock_env, which modifies the environment variables. This works, but IDEs often mark mock_env as an unused parameter and dims it out.