Updated Jul 6, 2026

Tests That Catch Real Bugs

Right now, til works because you watched it work. That guarantee expires the next time you touch the code - add an edit command in three months, nudge searchNotes while you're in there, and nothing will tell you that search stopped being case-insensitive. Tests are how the guarantee outlives your attention span. This phase writes real ones: not ceremony, not 100% coverage - a handful of tests aimed at the behavior that would hurt to lose.

And here's the payoff I promised twice: because the logic functions take plain data and return plain data - searchNotes(notes, query), not "read the file, search it, print results" - testing them requires no setup at all. Build a slice, call the function, check the result. The design choice was the hard part, and it's already done.

How Go testing works

No framework to install. The testing package and go test command ship with the toolchain, and the rules fit in three lines:

  • Tests live in files ending in _test.go, which go build ignores - test code never bloats your shipped binary.
  • Each test is a function named TestXxx taking *testing.T.
  • A test fails if it calls t.Errorf or t.Fatalf; otherwise it passes. That's the entire API you need today.

Because our test file declares package main - the same package as main.go - it can call searchNotes, loadNotes, and the rest directly, no exporting required.

The test file

Create main_test.go next to main.go:

package main

import (
	"path/filepath"
	"testing"
	"time"
)

func sample() []Note {
	return []Note{
		{ID: 1, Text: "Go interfaces are satisfied implicitly", Tags: []string{"go"}, Created: time.Now()},
		{ID: 2, Text: "git rebase rewrites history", Tags: []string{"git"}, Created: time.Now()},
		{ID: 3, Text: "go build makes one binary", Tags: []string{"go", "cli"}, Created: time.Now()},
	}
}

func TestSearchNotes(t *testing.T) {
	tests := []struct {
		name  string
		query string
		want  int
	}{
		{"case insensitive", "GO", 2},
		{"no match", "docker", 0},
		{"multi-word phrase", "one binary", 1},
		{"substring inside a word", "hist", 1},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := searchNotes(sample(), tt.query)
			if len(got) != tt.want {
				t.Errorf("searchNotes(%q) returned %d notes, want %d", tt.query, len(got), tt.want)
			}
		})
	}
}

func TestFilterByTag(t *testing.T) {
	if got := filterByTag(sample(), "go"); len(got) != 2 {
		t.Errorf("tag go: got %d notes, want 2", len(got))
	}
	if got := filterByTag(sample(), "GO"); len(got) != 2 {
		t.Errorf("tag GO should match like go: got %d notes, want 2", len(got))
	}
	if got := filterByTag(sample(), "python"); len(got) != 0 {
		t.Errorf("tag python: got %d notes, want 0", len(got))
	}
}

func TestSaveLoadRoundTrip(t *testing.T) {
	path := filepath.Join(t.TempDir(), "notes.json")

	if err := saveNotes(path, sample()); err != nil {
		t.Fatalf("saveNotes: %v", err)
	}
	got, err := loadNotes(path)
	if err != nil {
		t.Fatalf("loadNotes: %v", err)
	}
	if len(got) != 3 {
		t.Fatalf("got %d notes back, want 3", len(got))
	}
	if got[2].Text != "go build makes one binary" {
		t.Errorf("note text corrupted in round trip: %q", got[2].Text)
	}
	if len(got[2].Tags) != 2 {
		t.Errorf("tags lost in round trip: %v", got[2].Tags)
	}
}

func TestLoadMissingFile(t *testing.T) {
	notes, err := loadNotes(filepath.Join(t.TempDir(), "does-not-exist.json"))
	if err != nil {
		t.Fatalf("a missing file is a first run, not an error - got: %v", err)
	}
	if len(notes) != 0 {
		t.Errorf("want an empty list from a missing file, got %d notes", len(notes))
	}
}

Four tests, four different lessons. Let's take them in turn.

TestSearchNotes is table-driven - the pattern you'll see in nearly every serious Go codebase. Instead of one test function per case, you declare a slice of cases (an anonymous struct with a name, inputs, and the expected result) and loop over it. t.Run(tt.name, ...) makes each row a named subtest that passes or fails independently. The win is the marginal cost of a new case: when you wonder "what about a query with an apostrophe?", the answer is one more line in the slice, not a new function. Notice the cases test behavior you decided on in phase 4: case-insensitivity, the empty result, multi-word queries, substring matching.

TestFilterByTag guards a promise - that tag matching ignores case, because parseTags lowercases on the way in and filterByTag lowercases the query. That promise currently holds because of two lines in two different functions. If anyone "cleans up" either one, this test is the tripwire.

TestSaveLoadRoundTrip tests the disk code against a real disk - but not your disk. t.TempDir() hands the test a freshly created directory and registers automatic cleanup when the test ends; nothing to delete, nothing left behind, and - because saveNotes takes a path instead of calling storePath itself - the test can't touch your real ~/.til. This is the phase-3 design decision paying rent. The test then asserts the property that actually matters: what comes back out equals what went in.

TestLoadMissingFile pins down an edge case - the "first run is not an error" behavior from phase 3. Edge-case decisions like that one are precisely what future-you forgets; write them down as tests and they stop being folklore.

📝 Terminology: t.Errorf records a failure and lets the test keep running; t.Fatalf records a failure and stops the test immediately. Use Fatalf when continuing makes no sense - if loadNotes returned an error, inspecting the notes it didn't return would only produce noise.

Run them

$ go test
PASS
ok      til     0.184s

What just happened: go test found every _test.go file in the package, compiled it together with the code under test, ran all four test functions, and summarized. Quiet output is the Go way - passing tests have nothing interesting to say. Add -v when you want the play-by-play:

$ go test -v
=== RUN   TestSearchNotes
=== RUN   TestSearchNotes/case_insensitive
=== RUN   TestSearchNotes/no_match
=== RUN   TestSearchNotes/multi-word_phrase
=== RUN   TestSearchNotes/substring_inside_a_word
--- PASS: TestSearchNotes (0.00s)
    --- PASS: TestSearchNotes/case_insensitive (0.00s)
    --- PASS: TestSearchNotes/no_match (0.00s)
    --- PASS: TestSearchNotes/multi-word_phrase (0.00s)
    --- PASS: TestSearchNotes/substring_inside_a_word (0.00s)
=== RUN   TestFilterByTag
--- PASS: TestFilterByTag (0.00s)
=== RUN   TestSaveLoadRoundTrip
--- PASS: TestSaveLoadRoundTrip (0.00s)
=== RUN   TestLoadMissingFile
--- PASS: TestLoadMissingFile (0.00s)
PASS
ok      til     0.201s

Each table row shows up as its own named subtest - that's t.Run earning its keep.

Watch a test actually catch something

A passing suite proves nothing until you've seen it fail. So break the code the way a hurried future-you might: in main.go, edit searchNotes and remove the strings.ToLower around n.Text - the kind of change that survives a glance at a diff, because the function still compiles and still finds lowercase matches.

$ go test
--- FAIL: TestSearchNotes (0.00s)
    --- FAIL: TestSearchNotes/case_insensitive (0.00s)
        main_test.go:32: searchNotes("GO") returned 0 notes, want 2
FAIL
exit status 1
FAIL    til     0.198s

What just happened: one subtest failed - exactly the one guarding the behavior you broke - and its message says what was expected and what happened instead. The other three search cases still pass, because lowercase queries still work; that precision is the point of subtests. Manual testing would likely have missed this: you'd type a lowercase query, see results, and ship.

Put the strings.ToLower back and confirm you're green:

$ go test
PASS
ok      til     0.190s

That loop - break, see red, fix, see green - is the entire testing workflow. From now on, go test before every commit costs two seconds and buys you the confidence to change anything.

What you have now

A tool and a safety net: the search and filter behavior, the storage round trip, and the first-run edge case are all pinned down by tests that run in a fraction of a second. Notice what we didn't test - main's dispatch, usage printing, tabwriter's alignment. Thin wiring over the standard library, visible every time you run the tool, is not where the bugs that hurt live. Test judgment beats test coverage.

One thing left: til still only exists in this folder, on this machine. Next phase we compile it for every OS you care about and put it on your PATH, where a real tool belongs.

Quick check on the testing ideas:

[
  {
    "q": "Why does main_test.go declare package main, the same package as the code it tests?",
    "choices": [
      "So the tests can call unexported functions like searchNotes directly",
      "Because go test only works on package main",
      "To make the compiled binary include the tests"
    ],
    "answer": 0,
    "explain": "Same package means full access to unexported names. (go build still excludes _test.go files, so the shipped binary contains no test code.)"
  },
  {
    "q": "What does t.TempDir() give the storage tests?",
    "choices": [
      "A faster in-memory filesystem",
      "A fresh directory per test, deleted automatically afterward - so tests never touch your real ~/.til",
      "A directory shared by all tests so they can pass files to each other"
    ],
    "answer": 1,
    "explain": "Combined with saveNotes and loadNotes taking a path parameter, tests exercise the real file code against a disposable location."
  },
  {
    "q": "In TestSaveLoadRoundTrip, why t.Fatalf for the loadNotes error but t.Errorf for the text check?",
    "choices": [
      "Fatalf stops the test - if loading failed there are no notes worth inspecting; Errorf records the problem and continues",
      "Fatalf is required for errors returned by functions",
      "They behave identically; the choice is style"
    ],
    "answer": 0,
    "explain": "Fatalf for 'cannot meaningfully continue', Errorf for 'note this failure and keep checking'. Using Errorf after a failed load would produce a cascade of noise."
  }
]
Check your understanding 3 questions

1. Why does main_test.go declare package main, the same package as the code it tests?

2. What does t.TempDir() give the storage tests?

3. In TestSaveLoadRoundTrip, why t.Fatalf for the loadNotes error but t.Errorf for the text check?