A Beginner’s Guide To Testing In Go
I’ve been writing tests in Go for a few weeks now. And I’ve had more than a few moments of uncomfortable confusion.
So here’s a few things I wish someone had told me upfront.
Examples are in Go, but the ideas aren’t really language dependent.
Tests Enforce Contracts
Now right off the bat, you don’t want to get this wrong.
When writing tests, I’ve found the key thing to keep in mind is we should treat what we’re testing like a black box.
Give it specific inputs, expect specific outputs. For example, let’s take a look at function I had in a recent project (simplified). The project was a simple URL shortener I built.
func Create(url string) (shortCode string, err error)
The idea is, the function should take a URL string, e.g., https://exampleurl.com/this/is/really/long. That’s the input.
And it should return a short code that represents the long URL, e.g., bdcagl. Or an error if things go wrong. Those are the outputs.
That right there, that’s the contract. We’re saying if you give me a URL (a valid one), I should give you back a short code. Unless something goes wrong. In which case, I should give you an error.
This is what we test. So our test might look like this:
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) // if there's an unexpected error, fail the test
}
// Check that shortCode was generated
if shortCode == "" {
t.Error(`expected short code, got ""`) // after Creation, short code should not be empty
}
})
Now the test above is what we call the happy path. It’s what we expect to happen when everything goes right. But what happens if something goes wrong?
Errors Are Part of the Contract Too
Of the outputs we expect, specific error cases are included.
What I mean is, if, for example, I pass in bad input, I should get back one error. But if something fails internally, I should get back a different error.
This is part of the contract. And it took me quite a while to realize.
Cause see what I’d often do is, in cases where I expected an error, I’d simply check for the presence of errors, e.g.,
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") // if there's no error where you expect one, fail the test
}
This is not enough!
I’ve since found that a better way to handle this, would be to check for specific errors, e.g,
if !errors.Is(err, ErrInvalidURL) { // Check for invalid url error
t.Fatalf("expected ErrInvalidURL, got %v", err)
}
The contract should specify what error you get back.
NOTE: Testing error cases is just as important as testing happy paths. DO NOT sleep on it.
And we’re not done yet. Cause important as it is to have this mental model of contracts, it’s even more essential to understand how to structure our tests.
But in order to understand that, I have to first clear up what a unit test is. Cause it is not what I thought it was.
What Even Is a Unit?
For this section, it’s useful to quickly outline a commonly recommended structure for backend systems:
(We organize our HTTP server into layers)
HTTP Request
|
v
Handlers → receive HTTP requests and send responses
|
v
Services → apply business rules and coordinate work
|
v
Repository → reads and writes data in the database
|
v
Database
Now back to unit tests.
You might have heard of unit tests before. And if not, you’ll be glad you read this.
Cause see for a long time, when I heard “unit test”, I thought that referred to testing a function.
So if I had three functions, I thought that was three separate units to test.
I was wrong.
In our code, a unit is actually a “cohesive chunk of behavior, with one responsibility, that can be reasoned about independently.”
Which sounds fancy, but it really just means, if I have a service like this:
type Shortener struct {
store Store // where you store the generated short code and its associated long URL
gen Generator // consider this a function that 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
}
Then that entire struct — the Shortener plus all its public methods (Create, Resolve , Stats) is ONE unit.
Not three units. One.
So when I write tests for Create, Resolve, and Stats, I’m just testing different behaviors of the same unit.
A way to say it that helped me understand better is:
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.”
This matters because once I understood this, test organization made way more sense.
So let’s get to it shall we? How to structure our tests.
Good Test Structure
See after writing a bunch of messy tests, I found a structure that works:
Setup → Subject → Assertion
Setup
Here you get everything ready for the test.
Say you want to test the Create function above. Well you need a generator, and a store. The Shortener’s Create function uses the generator to generate short codes, and the store to store the generated codes alongside the original URLs. This is the setup stage. You have to prepare what the element being tested needs, e.g.,:
// Setup
store := NewMockStore()
gen := NewMockGenerator()
service := NewShortener(store, gen)
Subject
The thing you’re actually testing. Call the method, trigger the behavior, e.g.,
// Subject
shortCode, err := service.Create("https://example.com")
Assertion
Verify the contract holds. Check outputs, check errors, check side effects. (Side effects are tricky though. We’ll talk about them in a bit)
Example:
// Assertion
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if shortCode != "abc123" {
t.Errorf("Expected abc123, got %s", shortCode)
}
And one critical thing to keep in mind (trust me, I made this mistake quite a few times): If setup fails, fail the test immediately. E.g.,:
func TestCreate(t *testing.T) {
store := NewMockStore()
if store == nil {
t.Fatal("Failed to create mock store") // t.Fatal() 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...
}
There’s a simple reason why, i.e., if setup fails and you keep going, you’ll likely get misleading errors. The test will fail at unexpected points, and it’ll be a nightmare to debug.
And here’s where the bit about what a unit is gets particularly interesting.
See while working on my shortener, I had cases like this:
func TestResolve(t *testing.T) {
service := NewShortener(mockStore, mockGen)
// SEE THIS? We need to use Create for setup, cause to resolve (i.e., get the URL a short code points to), we need to have created the short code
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)
}
}
It’s a case where we have to use the Create function to setup tests for the Resolve function. Because to resolve (i.e., get the URL a short code points to), we need to have created the short code.
Which seems reasonable enough. Until we, for example, go a level up and have a handler that needs another handler for setup. Here’s a snippet:
func TestHandleRedirect_OK(t *testing.T) {
// Setup
// We would call HandleShorten() here, which creates a short code given the URL to shorten
handler.HandleShorten(rr, req)
// Subject (the thing we're testing)
// Create a mock request specifically for testing
req := httptest.NewRequest(http.MethodGet, "/"+shortCode, nil) // NOTICE the short code?
rr := httptest.NewRecorder()
// Given a short code, HandleRedirect redirects to the URL the short code represents. It internally calls Resolve() to get this done.
handler.HandleRedirect(rr, req)
res := rr.Result()
res.Body.Close()
if res.StatusCode != http.StatusFound {
t.Fatalf("expected status 302, got %d", res.StatusCode)
}
}
Like the test for Resolve() earlier, we need the short code in order to test HandleRedirect. But in order to get the short code, we’d either have to call another handler, HandleShorten , to generate the short code for us…
Or we’d have to reach into the service and call service.Create().
So which approach is better?
Using Other Functions for Test Setup
The key principle I found to deal with these kinds of test setup issues is: A test should not depend on the behavior of another unit.
The key issue here, is unit boundaries. (See? Units came back)
See calling HandleShorten to set up HandleRedirect might feel symmetrical, but it’s actually the wrong move.
This is cause handlers are separate units.
If the HandleRedirect test failed, you wouldn’t be able to easily tell what broke. Was it HandleRedirect or HandleShorten? It messes with failure attribution.
HandleRedirect should not have to depend on HandleShorten to work.
The better approach, would be to reach down, not sideways.
func TestHandleRedirect_OK(t *testing.T) {
service := NewShortener(mockStore, mockGen)
handler := NewHandler(service)
// Setup: reach down to the service
shortCode, err := service.Create("https://example.com") // We have Create from the service setting up HandleRedirect
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)
}
}
This is better because:
- You’re testing one handler
- Setup uses lower-level implementation
- No handler depends on another handler’s behavior
- If the test fails, we know where to look
And if service.Create breaks, the handler test should fail. Because in the real app, the handler relies on the service. It’s honest dependency.
However, it’s pretty important to note that the earlier example of using Create to set up Resolve, is different.
That’s perfectly fine, because
- Create and Resolve belong to the same unit.
- If Create is broke, Resolve is broken too.
A single service is a single unit. Handlers don’t have that relationship.
A “rule of thumb” of sorts that helps me with this is (and this ties back to the backend layers idea):
A test can depend on lower layers, but it shouldn’t depend on same layer entities unless it’s from the same unit.
However do note that an exception to this is the Service layer. For the service layer, you don’t want to call lower layer functions (from repository/db layer) directly for setup in your service tests, cause you’d be bypassing business rules, which is the core of your application. It’s at a base level, what your application should do.
And now one last thing I mustn’t forget to mention: Side Effects.
Side Effects Are Tricky
I say they’re tricky cause, a side effect is something your function changes internally, that’s not part of its return value.
My service.Resolve function, for example, internally increments a hits counter for a URL every time someone requests that URL.
“hits”, however, is not part of its return values, so you have to observe or test hits separately, for example by creating a separate function for metrics (e.g., my service.Stats function), through database queries, etc.
Key Takeaways
- A unit is a cohesive module with a single responsibility, not a single function. (Think, “if something fails, can I tell what kind of problem it is?” - Clear failure attribution)
- Tests enforce contracts - Specific inputs → Specific outputs
- A reasonable test structure: Setup → Subject → Assertion
- Fail fast on setup failures (
t.Fatal()) to avoid misleading errors - Side effects need indirect observation
Popular Products
-
Classic Oversized Teddy Bear$23.78 -
Gem's Ballet Natural Garnet Gemstone ...$171.56$85.78 -
Butt Lifting Body Shaper Shorts$95.56$47.78 -
Slimming Waist Trainer & Thigh Trimmer$67.56$33.78 -
Realistic Fake Poop Prank Toys$99.56$49.78