1. How Go finds tests
When you are just learning to program, it can feel like tests are a luxury for corporations with a dedicated QA department and a Kubernetes coffee machine. In practice, a unit test is your future “you” who will open the code a week later and say: “I don’t remember anything, but the tests do.” A unit test checks a small unit of logic in a deterministic way: “input → action → result.”
The main idea of a unit test is simple: we don’t run the whole program “with our eyes” and click buttons—we automatically verify that a specific function behaves the way we intended. In Go this is especially convenient: the language encourages small functions and explicit contracts, which means they are easy to test.
Imagine we have a mini app "todo" (a task list). We write a function ValidateTitle that forbids empty titles. Manually, you can check it 2–3 times and forget about it. But a test guarantees that the rule won’t “break” after the next refactor (or after you “slightly simplified the code” while in a good mood).
If Go is a strict teacher, then the rule about tests sounds like this: “If you want me to see your test—name the file correctly.”
Files *_test.go
Tests in Go go into files whose names end with _test.go. Then the test runner understands: “aha, this is test code, include it.”
This is an important point: code in _test.go usually does not participate in the normal application build. That is, your main won’t suddenly depend on testing, and the production binary won’t bloat with test helpers.
The simplest folder structure might look like this:
todo/
title.go
title_test.go
task.go
task_test.go
The idea that “tests live next to the code” is one of the reasons Go projects are pleasant to read: you open a file with logic, and right next to it you see a file with checks.
By the way, the Go documentation emphasizes that examples (Examples) are also part of the test suite and are also located in _test.go files. We are not covering examples as a separate format here, but the fact about _test.go helps you remember the rule.
Functions TestXxx
After Go finds a _test.go file, it starts looking in it for test functions. A test function has the form:
func TestXxx(t *testing.T) { ... }
Several small details matter here, and each of them is a classic “why won’t my test run???” for beginners.
First, the name must start with Test. Second, after Test usually comes the name of your function/scenario, and the first letter after Test must be uppercase (this is part of the discovery rules in go test). Third, the parameter must be exactly t *testing.T.
A minimal test skeleton looks like this:
package todo
import "testing"
func TestSomething(t *testing.T) {
// your test goes here
}
If something is “off” in this code (for example, func testSomething(...) with a lowercase letter), Go won’t complain. It will simply not run the test—silently. And you will look at "PASS" and think you’re a genius. It’s a trap.
3. Test context: *testing.T
When you write func TestXxx(t *testing.T), it’s as if you get a “test control panel.” Through t, you tell the testing framework: “everything is OK,” “there is an error here,” “stop,” “here are the details.”
The easiest way to think about t is this: it’s not “data,” it’s the “test execution context.” It can print errors, mark a test as failed, and control what happens next.
For now, two families of methods are enough for us:
- t.Errorf(...) / t.Error(...)
- t.Fatalf(...) / t.Fatal(...)
And they differ not by the “string format,” but by the test behavior.
t.Errorf vs t.Fatalf
When something goes wrong in a test, you have two main reaction styles.
If you use t.Errorf (or t.Error), you are saying: “record the error, but keep running the test.” This is useful when, after an error, it still makes sense to perform a few more independent checks.
If you use t.Fatalf (or t.Fatal), you are saying: “record the error and immediately stop this test.” This is useful when further checks are pointless or even dangerous (for example, if you expected err == nil, but got an error, and then you start comparing a result that is already invalid).
A small table to make it stick:
| Method | What it does | When to choose |
|---|---|---|
|
Error + continue | You can do several checks in a row |
|
Error + stop | The rest of the test is meaningless |
The suffix f means “formatting,” like in fmt.Printf. That is, t.Errorf("x=%d", x) will substitute the number into the string.
In the examples from the Go documentation, tests actively use t.Errorf and t.Fatalf depending on the situation. We will do the same.
4. AAA: Arrange – Act – Assert
AAA is a simple template for organizing a test that keeps the code readable even a month later. It stands for:
- Arrange: prepare input data and environment.
- Act: call the function under test.
- Assert: verify the result (got vs want).
Beginners often want to write a test “in one line”: call—compare—move on. Sometimes that is fine. But AAA enforces discipline: you can immediately see where setup is, where the action is, and where the check is. Which means that when a test fails, it’s easier to understand what exactly broke.
You can think of AAA as an assembly line:
flowchart LR
A[Arrange: setup] --> B[Act: function call]
B --> C[Assert: got/want comparison]
If the test is short, AAA can literally be three comments. But even those comments often save you from a “soup test,” where everything is mixed together.
5. Example: title validation in "todo"
Now let’s make a small piece of logic for our "todo". Think of it as one of the building blocks of the app: we want to forbid empty task titles.
Production code: ValidateTitle
First, let’s write the code we will test. Let it live in title.go.
package todo
import (
"errors"
"strings"
)
var ErrEmptyTitle = errors.New("empty title")
func ValidateTitle(title string) error {
if strings.TrimSpace(title) == "" {
return ErrEmptyTitle
}
return nil
}
Everything is straightforward here: if the string is empty or consists of spaces, we return an error. Otherwise there are no errors.
Test: an empty title returns an error
Now we create a file title_test.go next to it (in the same folder). Note: the file name must end with _test.go, otherwise Go will “miss” the test.
package todo
import "testing"
func TestValidateTitle_Empty(t *testing.T) {
// Arrange
in := " "
// Act
err := ValidateTitle(in)
// Assert
if err == nil {
t.Fatalf("ValidateTitle(%q): expected error, got nil", in)
}
}
Notice a couple of things.
We use %q so the string is printed with quotes. This is useful because spaces are hard to see. The message "ValidateTitle(\" \"): ..." is much clearer than just “expected error.”
And we use t.Fatalf, because if there is no error, there is nothing else to check: that was the main assertion.
Test: a normal title does not return an error
Now let’s check the positive scenario. This is the mirror check.
package todo
import "testing"
func TestValidateTitle_OK(t *testing.T) {
// Arrange
in := "read book"
// Act
err := ValidateTitle(in)
// Assert
if err != nil {
t.Fatalf("ValidateTitle(%q): unexpected error: %v", in, err)
}
}
Here again we use Fatal, because “an error happened where it must not” means the whole scenario failed.
6. Got/Want and good error messages
Validating errors is a common scenario, but tests very often verify regular values too. Let’s add a small function that “normalizes” priority: for example, turning negative numbers into zero. It’s simple logic, but it’s exactly the kind of thing that later breaks “in small ways” when you rewrite code.
Production code: NormalizePriority
File priority.go:
package todo
func NormalizePriority(p int) int {
if p < 0 {
return 0
}
return p
}
Test: got/want on a simple example
Test priority_test.go:
package todo
import "testing"
func TestNormalizePriority_Negative(t *testing.T) {
// Arrange
in := -5
// Act
got := NormalizePriority(in)
// Assert
want := 0
if got != want {
t.Errorf("NormalizePriority(%d) = %d, want %d", in, got, want)
}
}
Here you can already use t.Errorf: we have one check, and the test is short. But even if there were more checks, Errorf would let you collect multiple errors in a single run.
The most important pattern to remember visually: got and want. In the Go community, it’s almost a “dialect”: if you write tests, you will very quickly start thinking “what did I get” and “what did I expect.”
How to write messages that help
A message in t.Errorf/t.Fatalf is not a formality—it’s your future navigator for the problem. A good message almost always answers three questions: what input the function had, what you got (got), and what you expected (want).
Compare two versions.
Bad:
t.Fatalf("failed")
Good:
t.Fatalf("NormalizePriority(%d) = %d, want %d", in, got, want)
In the second version, when the test fails in CI or in the IDE, you won’t have to do archaeological digs. You will immediately see the specific case and the values.
One more small habit that saves hours: for strings, often use %q to see quotes and escape sequences. For errors, use %v; that’s enough at a basic level.
7. Common mistakes
Mistake #1: the file is not named *_test.go, and the test “doesn’t exist.”
This is the most insidious situation: you wrote a test, it compiles in the IDE (because the IDE is smart), but go test simply doesn’t see it. As a result, you are sure the code is covered by tests, but in reality the test has never run even once. The remedy is simple: always check the _test.go suffix.
Mistake #2: the function is not named TestXxx or has an invalid signature.
A common variant is func testValidateTitle(t *testing.T) (lowercase letter) or func TestValidateTitle() (no parameter). Go won’t “guess” what you meant. It will just skip such a function. If a test “doesn’t run,” first look at the name and the signature.
Mistake #3: using t.Error* where the rest of the test is pointless.
For example, you expected err == nil, but got an error and still continued checking the result got. Then you end up with two errors in the report instead of one, and the second one is noise. In places where you can’t proceed without a condition, use t.Fatal*: it makes the report cleaner and easier to understand.
Mistake #4: checking got before you have dealt with err.
If a function returns (T, error), first decide whether you expect an error. If an error came unexpectedly, the test should stop (usually via t.Fatalf). If you expect an error, do not touch got at all: it may be the zero value and will only confuse things.
Mistake #5: error messages without inputs and without got/want.
Tests often fail not on your laptop, but in another environment: a classmate’s, in CI, a teacher’s, or even yours a week later. If the message does not include the input, got, and want, you will have to reproduce the problem manually.
GO TO FULL VERSION