Testing and Logging
Your First Tests
Goal
By the end of this lesson, Tasker has its first automated tests. You test ParseID, ValidateTitle, and SplitTags with table-driven tests, the most common testing style in Go. You run them with one command, read a failing test, and measure how much of the code your tests cover.
What you will learn
- How Go finds and runs tests:
_test.gofiles andTestXxxfunctions - How to report failures with
t.Errorfandt.Fatalf - How to write table-driven tests with subtests (
t.Run) - How to run one test, all tests, or tests with details (
-v) - How to read a failing test
- How to measure test coverage
Why this matters
Until now, you checked Tasker by running it and reading the output. That works for one change, but every change can break something you tested last week. Automated tests check everything again in less than a second, every time you run them. In Part 2, they also run automatically on every change you push to GitHub.
Go has testing built in. You do not need a framework or a special runner: the testing package and the go test command are part of the standard toolchain.
Step 1: How Go finds tests
Go has three simple rules:
- Test code lives in files that end with
_test.go. Go never includes these files in your normal program. - A test is a function whose name starts with
Test, followed by a capital letter or a number, for exampleTestParseID. - A test function takes one parameter,
t *testing.T, and returns nothing.
Test files usually sit next to the code they test, in the same folder and the same package. actions.go gets actions_test.go. Because the test is in the same package, it can call unexported functions too.
Step 2: Write your first test
Create internal/task/actions_test.go:
package task
import "testing"
func TestParseIDValid(t *testing.T) {
got, err := ParseID("42")
if err != nil {
t.Fatalf("ParseID(\"42\") returned an error: %v", err)
}
if got != 42 {
t.Errorf("ParseID(\"42\") = %d, want 42", got)
}
}
Run it from the project root:
go test ./...
Output:
? github.com/yourname/tasker/cmd/tasker [no test files]
? github.com/yourname/tasker/internal/cli [no test files]
? github.com/yourname/tasker/internal/storage [no test files]
ok github.com/yourname/tasker/internal/task 0.003s
ok means every test in the package passed. The other packages have no tests yet.
A Go test is ordinary Go code. There are no special assert keywords. You check a condition with if, and you report a problem with a method on t:
t.Errorfreports a failure and continues the test. Use it when the following checks still make sense.t.Fatalfreports a failure and stops the test immediately. Use it when continuing makes no sense. Here, ifParseIDreturned an error, the value ofgotis meaningless.
Go has a convention for failure messages: Function(input) = got, want expected. A reader sees the call, the actual result, and the expected result in one line, without opening the test file.
Step 3: Table-driven tests
ParseID has many interesting inputs: normal numbers, zero, negative numbers, text, and empty text. You could write one test function for each, but most of the code would repeat. Go programmers write a table instead: a list of cases, and one loop that checks them all.
Add this to actions_test.go:
func TestParseID(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "simple number", input: "7", want: 7},
{name: "large number", input: "1000", want: 1000},
{name: "zero", input: "0", wantErr: true},
{name: "negative", input: "-3", wantErr: true},
{name: "text", input: "abc", wantErr: true},
{name: "empty", input: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseID(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("ParseID(%q) = %d, want an error", tt.input, got)
}
return
}
if err != nil {
t.Fatalf("ParseID(%q) returned an error: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("ParseID(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
How it works:
[]struct{ ... }{ ... }is a slice of an anonymous struct, a struct type without a name. You define it right where you use it, because no other code needs it.- Each row is one test case with a name, an input, and the expected result. Fields you leave out get zero values, so
wantErrisfalseunless you set it. t.Run(tt.name, func(t *testing.T) { ... })creates a subtest for each row. Each subtest passes or fails on its own, and its name appears in the output. AFatalfinside a subtest stops only that subtest, and the loop continues with the next row.
To test a new input, you add one line to the table. This makes it cheap to test the edge cases, which is where most bugs hide.
Step 4: Test errors with errors.Is
Add "errors" and "strings" to the imports, and add a second table test:
func TestValidateTitle(t *testing.T) {
tests := []struct {
name string
title string
want error
}{
{name: "normal title", title: "Buy milk", want: nil},
{name: "empty", title: "", want: ErrEmptyTitle},
{name: "exactly the maximum", title: strings.Repeat("a", MaxTitleLength), want: nil},
{name: "one character too long", title: strings.Repeat("a", MaxTitleLength+1), want: ErrTitleTooLong},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTitle(tt.title)
if !errors.Is(err, tt.want) {
t.Errorf("ValidateTitle(%q) = %v, want %v", tt.title, err, tt.want)
}
})
}
}
The sentinel errors from Lesson 2.4 make this test precise. It checks not only that there is an error, but that it is the right error.
Look at the two length cases: exactly 50 characters, and 51. Testing both sides of a limit is a classic technique. Off-by-one mistakes, such as writing >= instead of >, are very common, and only a test at the exact boundary catches them. strings.Repeat("a", 50) builds a string of 50 a characters.
errors.Is(nil, nil) is true, so the rows with want: nil pass when there is no error.
Step 5: Compare slices
Create internal/task/list_test.go:
package task
import (
"slices"
"testing"
)
func TestSplitTags(t *testing.T) {
tests := []struct {
name string
words []string
wantTitle string
wantTags []string
}{
{
name: "no tags",
words: []string{"Buy", "milk"},
wantTitle: "Buy milk",
wantTags: nil,
},
{
name: "tags at the end",
words: []string{"Buy", "milk", "#shop", "#home"},
wantTitle: "Buy milk",
wantTags: []string{"shop", "home"},
},
{
name: "tags are lowercase",
words: []string{"Call", "#WORK"},
wantTitle: "Call",
wantTags: []string{"work"},
},
{
name: "a single # is part of the title",
words: []string{"Fix", "#"},
wantTitle: "Fix #",
wantTags: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
title, tags := SplitTags(tt.words)
if title != tt.wantTitle {
t.Errorf("title = %q, want %q", title, tt.wantTitle)
}
if !slices.Equal(tags, tt.wantTags) {
t.Errorf("tags = %v, want %v", tags, tt.wantTags)
}
})
}
}
You cannot compare slices with == in Go. The compiler rejects it. slices.Equal compares two slices item by item. It treats a nil slice and an empty slice as equal.
The last row documents a decision you made in Lesson 2.3: a # alone is not a tag. Tests are also documentation. They show exactly how the code behaves in unusual cases.
Step 6: Run tests in different ways
See every test and subtest with -v (verbose):
go test -v ./internal/task
=== RUN TestParseID
=== RUN TestParseID/simple_number
=== RUN TestParseID/large_number
=== RUN TestParseID/zero
...
--- PASS: TestParseID (0.00s)
--- PASS: TestParseID/simple_number (0.00s)
--- PASS: TestParseID/large_number (0.00s)
--- PASS: TestParseID/zero (0.00s)
...
Spaces in subtest names become underscores.
Run only some tests with -run, which takes a regular expression:
go test -run TestValidateTitle ./internal/task
go test -run 'TestParseID/negative' -v ./internal/task
The pattern matches the start of names, so TestParseID also matches TestParseIDValid. To match one name exactly, write -run '^TestParseID$'.
Run tests again without the cache with -count=1. Go remembers passed results and skips tests when nothing changed. Then the output shows (cached). -count=1 forces a fresh run.
Step 7: Read a failing test
Break ParseID on purpose. In actions.go, change if id < 1 { to if id < 0 {. Then run:
go test ./internal/task
--- FAIL: TestParseID (0.00s)
--- FAIL: TestParseID/zero (0.00s)
actions_test.go:39: ParseID("0") = 0, want an error
FAIL
FAIL github.com/yourname/tasker/internal/task 0.003s
FAIL
The output tells you everything you need:
- Which subtest failed:
TestParseID/zero - Where:
actions_test.go:39 - What happened:
ParseID("0")returned0, but an error was expected.
All other cases still pass, so you know the problem is only at the boundary. Change the code back and run the tests again. They pass.
Good habit: when you write a new test, break the code on purpose once and watch the test fail. A test that never fails might not test anything.
Step 8: Measure coverage
go test -cover ./internal/task
ok github.com/yourname/tasker/internal/task 0.004s coverage: 21.4% of statements
Coverage is the percentage of code statements that ran during the tests. 21% is low, because the store has no tests yet. You test it in the next lesson.
To see exactly which lines ran, create a coverage report and open it in your browser:
go test -coverprofile=cover.out ./internal/task
go tool cover -html=cover.out
Green lines ran during tests. Red lines did not. Add cover.out to .gitignore.
High coverage does not prove the code is correct, but red lines show you which code has no tests at all. Use coverage to find gaps, not as a score to maximize.
Why it works: tests are just Go
A Go test is a normal function with normal if statements. There is no hidden magic and no new language to learn. Table-driven tests separate the data (the cases) from the logic (the checks), so adding a case is one line, and the test reads like a specification:
| input | expected |
|---|---|
"7" | 7 |
"0" | error |
"abc" | error |
You will write tests like this for the rest of the course, including for your API handlers in Part 2.
Exercises
- Test
Filter. WriteTestFilteras a table test. Create four tasks in the test (two with the taghome, one done), then test these cases: no filter, tag only, open only, and tag plus open. Compare the IDs of the results. - Test
CountByTag. Write a test forCountByTag. Hint: you can compare two maps withmaps.Equalfrom themapspackage. - Test
String. Write a table test forTask.String()with an open task, a done task, and a task with two tags. - Find a gap. Run the coverage report in your browser. Find one red line in
actions.goorlist.goand add a test case that makes it green. - Break and fix. In
SplitTags, removestrings.ToLower. Which test fails, and what does its message say? Change the code back.
Checkpoint
You are ready for the next lesson if you can:
- Name the rules Go uses to find test files and test functions
- Explain when to use
t.Errorfand when to uset.Fatalf - Write a table-driven test with subtests
- Run one test with
-runand all tests withgo test ./... - Read a failing test and find the case that failed
- Measure coverage and open the HTML report
