Testing and Logging

Test the Store and the File Storage

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

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 assert and require
  • Why some tests live in a separate _test package
  • 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:

  • assert reports the failure and continues the test, like t.Errorf.
  • require reports the failure and stops the test, like t.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 clauseCan use unexported namesTypical use
package taskYesTesting small internal functions
package task_testNoTesting 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:

  1. Arrange: create the store and the data the test needs.
  2. Act: call the code you test.
  3. 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())
}
  • TestFileSaveAndLoad is 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.Equal compares whole slices of structs, field by field.
  • TestFileLoadMissingFile checks the first-run rule from Lesson 3.3.
  • TestFileLoadBrokenJSON writes a broken file on purpose. assert.ErrorContains checks the error message.
  • TestFileSaveCreatesFolderAndLeavesNoTempFiles checks two promises of Save: it creates missing folders, and the defer cleanup removes the temporary file. os.ReadDir lists 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

  1. A failing load. Write a fake storage whose Load returns an error. Test that task.Open returns that error, and use assert.ErrorIs to check it is the same error value.
  2. 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 Complete leaves the task open when saving fails. Hint: give the fake a counter field and use a pointer receiver.
  3. 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.
  4. Table test for Complete. Rewrite TestStoreComplete as a table test with the cases "open task", "done task", and "missing task".
  5. Run in parallel. Add t.Parallel() as the first line of each file storage test, and run go test -v ./internal/storage. Why is this safe for these tests? Hint: each test has its own t.TempDir().

Checkpoint

You are ready for the next lesson if you can:

  • Explain the difference between assert and require
  • Explain why store_test.go uses package 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