Testing and Logging
Test the Store and the File Storage
Goal
By the end of this lesson, the most important parts of Tasker have tests: adding and completing tasks, IDs after a restart, the undo after a failed save, and reading and writing the JSON file. You use the testify library for short, readable checks. You also use temporary folders, so tests never touch your real tasks.
What you will learn
- How to write checks with testify's
assertandrequire - Why some tests live in a separate
_testpackage - How to avoid import cycles in tests
- How to use fakes to test error paths that are hard to cause for real
- How to write test helpers with
t.Helper() - How to use
t.TempDir()for tests that write files
Why this matters
ParseID and SplitTags are small functions with clear inputs and outputs. The store is different: it has state, it depends on a storage, and it must handle failures correctly. These are the parts where bugs hurt most, because a bug here can lose a user's tasks. The interface from Lesson 4.2 now pays off: you can test the store with a memory storage or a fake broken storage, without touching any file.
Step 1: Add testify
go get github.com/stretchr/testify@latest
testify is the most popular testing library in Go. It does not replace go test. It only adds helper functions for checks. Compare:
// Standard library
if got != 42 {
t.Errorf("ParseID(\"42\") = %d, want 42", got)
}
// testify
assert.Equal(t, 42, got)
When a check fails, testify prints the expected and actual values for you. This saves time with structs and slices, where writing good messages by hand is tedious.
testify has two packages with the same functions:
assertreports the failure and continues the test, liket.Errorf.requirereports the failure and stops the test, liket.Fatalf.
Use require when the rest of the test depends on the check, for example when there must be no error before you use a result. Use assert for the final checks.
The standard library alone is enough for good tests, and many Go teams use only
testing. You learned both styles, so you can work in either kind of project.
Step 2: A separate test package
You want to test the store with storage.NewMemory(). But there is a problem. The test file is in package task, the storage package imports task, and now task (through its test) imports storage. Try it, and Go stops:
package github.com/yourname/tasker/internal/task
imports github.com/yourname/tasker/internal/storage from zz_test.go
imports github.com/yourname/tasker/internal/task from file.go: import cycle not allowed in test
The solution is an external test package. A test file in the folder internal/task can declare package task_test instead of package task. Go compiles it as a separate package that imports task like any other user. The cycle disappears, because task_test imports both task and storage, and nothing imports task_test.
External test packages have a second benefit: they can only use exported names. The test uses the store exactly like the real program does. If a test is hard to write from outside, the API of the package is probably hard to use too.
| Package clause | Can use unexported names | Typical use |
|---|---|---|
package task | Yes | Testing small internal functions |
package task_test | No | Testing the public behavior of a package |
Step 3: A test helper
Create internal/task/store_test.go:
package task_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yourname/tasker/internal/storage"
"github.com/yourname/tasker/internal/task"
)
// newTestStore returns an empty store that keeps tasks in memory.
func newTestStore(t *testing.T) *task.Store {
t.Helper()
store, err := task.Open(storage.NewMemory())
require.NoError(t, err)
return store
}
Many tests need an empty store, so a helper creates one. It receives t, so it can stop the test if something goes wrong.
t.Helper() marks the function as a helper. When a check inside it fails, Go reports the line in the test that called the helper, not the line inside the helper. That is the line you want to see.
Step 4: Test adding and completing
Add these tests:
func TestStoreAdd(t *testing.T) {
store := newTestStore(t)
first, err := store.Add("Buy milk", []string{"shop"})
require.NoError(t, err)
second, err := store.Add("Pay rent", nil)
require.NoError(t, err)
assert.Equal(t, 1, first.ID)
assert.Equal(t, 2, second.ID)
assert.Equal(t, "Buy milk", first.Title)
assert.Equal(t, []string{"shop"}, first.Tags)
assert.False(t, first.Done)
assert.False(t, first.CreatedAt.IsZero())
assert.Equal(t, 2, store.Len())
}
func TestStoreAddInvalidTitle(t *testing.T) {
store := newTestStore(t)
_, err := store.Add("", nil)
assert.ErrorIs(t, err, task.ErrEmptyTitle)
assert.Equal(t, 0, store.Len())
}
func TestStoreComplete(t *testing.T) {
store := newTestStore(t)
added, err := store.Add("Buy milk", nil)
require.NoError(t, err)
done, err := store.Complete(added.ID)
require.NoError(t, err)
assert.True(t, done.Done)
_, err = store.Complete(added.ID)
assert.ErrorIs(t, err, task.ErrAlreadyDone)
_, err = store.Complete(99)
assert.ErrorIs(t, err, task.ErrNotFound)
}
Each test follows the Arrange, Act, Assert pattern, separated by blank lines:
- Arrange: create the store and the data the test needs.
- Act: call the code you test.
- Assert: check the results.
This structure makes every test easy to scan.
The order of arguments in assert.Equal is expected first, actual second. If you swap them, the failure message is confusing.
assert.ErrorIs works like errors.Is and prints a clear message when it fails.
Note that TestStoreAdd does not check the exact CreatedAt value, because the current time is different in every run. It checks that the time was set. Tests must give the same result every time they run.
Step 5: Test behavior across a restart
func TestStoreKeepsTasksAfterReopen(t *testing.T) {
mem := storage.NewMemory()
store, err := task.Open(mem)
require.NoError(t, err)
_, err = store.Add("Buy milk", nil)
require.NoError(t, err)
_, err = store.Add("Pay rent", nil)
require.NoError(t, err)
reopened, err := task.Open(mem)
require.NoError(t, err)
assert.Equal(t, 2, reopened.Len())
next, err := reopened.Add("Third", nil)
require.NoError(t, err)
assert.Equal(t, 3, next.ID, "new IDs must continue after the highest loaded ID")
}
Opening a second store on the same memory storage simulates starting Tasker again. The test checks a rule that is easy to break: new IDs must never repeat old ones.
The last argument of assert.Equal is an optional message. Add one when the reason for a check is not obvious. When the test fails, the message explains which rule was broken.
Step 6: Fakes for error paths
How do you test what happens when the disk is full? You cannot easily fill a real disk in a test. But the store only knows the Storage interface, so you can write a fake: a small type that behaves exactly as the test needs.
// brokenStorage is a fake storage that loads nothing and always fails to save.
type brokenStorage struct{}
func (brokenStorage) Load() ([]task.Task, error) { return nil, nil }
func (brokenStorage) Save([]task.Task) error { return errors.New("disk is full") }
func TestStoreRollsBackWhenSaveFails(t *testing.T) {
store, err := task.Open(brokenStorage{})
require.NoError(t, err)
_, err = store.Add("Buy milk", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "disk is full")
assert.Equal(t, 0, store.Len(), "a failed save must not leave the task in the store")
}
func TestOpenRejectsInvalidTasks(t *testing.T) {
mem := storage.NewMemory()
require.NoError(t, mem.Save([]task.Task{{ID: 1, Title: ""}}))
_, err := task.Open(mem)
assert.ErrorIs(t, err, task.ErrEmptyTitle)
}
The methods of brokenStorage have no receiver name, (brokenStorage), because they do not use the receiver. A parameter without a name, like []task.Task in Save, is also allowed when you do not use it.
TestOpenRejectsInvalidTasks uses the memory storage to prepare "broken" saved data, a task with an empty title, the same way a hand-edited file could look. Then it checks that Open refuses it.
Step 7: Test the file storage with temporary folders
The File storage really writes files, so its tests need a folder. Never use your real ~/.tasker folder in tests. t.TempDir() creates a new, empty folder for the test and deletes it automatically when the test ends.
Create internal/storage/file_test.go:
package storage
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yourname/tasker/internal/task"
)
func TestFileSaveAndLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "tasks.json")
f := NewFile(path)
created := time.Date(2026, 10, 16, 9, 30, 0, 0, time.UTC)
tasks := []task.Task{
{ID: 1, Title: "Buy milk", Tags: []string{"shop"}, CreatedAt: created},
{ID: 2, Title: "Pay rent", Done: true, CreatedAt: created},
}
require.NoError(t, f.Save(tasks))
loaded, err := f.Load()
require.NoError(t, err)
assert.Equal(t, tasks, loaded)
}
func TestFileLoadMissingFile(t *testing.T) {
f := NewFile(filepath.Join(t.TempDir(), "missing.json"))
loaded, err := f.Load()
require.NoError(t, err)
assert.Empty(t, loaded)
}
func TestFileLoadBrokenJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "tasks.json")
require.NoError(t, os.WriteFile(path, []byte(`[{"id": 1,`), 0o644))
_, err := NewFile(path).Load()
assert.ErrorContains(t, err, "parse")
}
func TestFileSaveCreatesFolderAndLeavesNoTempFiles(t *testing.T) {
dir := filepath.Join(t.TempDir(), "new", "folder")
f := NewFile(filepath.Join(dir, "tasks.json"))
require.NoError(t, f.Save([]task.Task{{ID: 1, Title: "Test"}}))
entries, err := os.ReadDir(dir)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "tasks.json", entries[0].Name())
}
TestFileSaveAndLoadis a round-trip test: save data, load it, and check that it is the same. It uses a fixed time,time.Date(...), so the expected value is exact.assert.Equalcompares whole slices of structs, field by field.TestFileLoadMissingFilechecks the first-run rule from Lesson 3.3.TestFileLoadBrokenJSONwrites a broken file on purpose.assert.ErrorContainschecks the error message.TestFileSaveCreatesFolderAndLeavesNoTempFileschecks two promises ofSave: it creates missing folders, and thedefercleanup removes the temporary file.os.ReadDirlists the folder. There must be exactly one file.
These tests use package storage, an internal test package. There is no import cycle here, because storage already imports task.
Step 8: Run the tests
go mod tidy
go test ./...
? github.com/yourname/tasker/cmd/tasker [no test files]
? github.com/yourname/tasker/internal/cli [no test files]
ok github.com/yourname/tasker/internal/storage 0.010s
ok github.com/yourname/tasker/internal/task 0.006s
Check the coverage:
go test -cover ./internal/...
ok github.com/yourname/tasker/internal/storage 0.012s coverage: 62.5% of statements
ok github.com/yourname/tasker/internal/task 0.006s coverage: 71.4% of statements
The task package went from 21% to 71%. Open the HTML report from Lesson 5.1 to see which lines are still red.
Now see what a testify failure looks like. In TestStoreAdd, change assert.Equal(t, 2, second.ID) to assert.Equal(t, 3, second.ID):
--- FAIL: TestStoreAdd (0.00s)
store_test.go:31:
Error Trace: .../internal/task/store_test.go:31
Error: Not equal:
expected: 3
actual : 2
Test: TestStoreAdd
testify wrote the message for you, with both values. Change the line back.
Why it works: test through interfaces, with fakes at the edges
The store depends on the Storage interface, not on a file. So in tests, you can give it:
- a memory storage for fast, normal tests,
- a fake broken storage for error paths,
- and test the real file storage separately, in its own package, with a temporary folder.
Each test checks one part, and all parts together are covered. You will use the same approach in Part 2: handlers are tested with a fake repository, and the real Postgres repository is tested against a real database.
Exercises
- A failing load. Write a fake storage whose
Loadreturns an error. Test thattask.Openreturns that error, and useassert.ErrorIsto check it is the same error value. - Undo after a failed complete. Write a fake storage that saves successfully the first time and fails the second time. Use it to test that
Completeleaves the task open when saving fails. Hint: give the fake a counter field and use a pointer receiver. - Test the memory storage copy. Write a test that saves a slice to
storage.Memory, changes the slice, loads again, and checks that the stored tasks did not change. - Table test for
Complete. RewriteTestStoreCompleteas a table test with the cases "open task", "done task", and "missing task". - Run in parallel. Add
t.Parallel()as the first line of each file storage test, and rungo test -v ./internal/storage. Why is this safe for these tests? Hint: each test has its ownt.TempDir().
Checkpoint
You are ready for the next lesson if you can:
- Explain the difference between
assertandrequire - Explain why
store_test.gousespackage task_test - Write a test helper and explain what
t.Helper()changes - Write a fake that implements an interface to test an error path
- Use
t.TempDir()for tests that create files - Structure a test with Arrange, Act, Assert
