Skip to content

Day 027 — Project: Tests + README

Month 1 · Week 4 · ⬅ Day 026 · Day 028 ➡ · Journal index

🎯 Learning Objective

Make taskcli trustworthy: table-driven tests for the store and commands (using t.TempDir() for real file round-trips), plus a clear project README.

📚 Topics

  • testing — table-driven tests, subtests t.Run, helpers + t.Helper()
  • t.TempDir() for isolated, auto-cleaned filesystem tests
  • Testing errors with errors.Is (sentinel ErrNotFound)
  • go test -run/-v/-cover, go test ./... · writing a useful README

📖 Reading / Sources

📝 Notes

  • Table-driven tests are the Go idiom: a slice of {name, input, want} cases looped with t.Run(tc.name, ...) so each row is an independent, named subtest you can target with -run.
  • t.TempDir() returns a fresh directory auto-removed after the test — perfect for Load/Save round-trips without touching the real home dir or leaking files. No manual cleanup needed.
  • Test the round-trip: build a store, add tasks, Save, Load from the same path, assert equality with reflect.DeepEqual (or compare fields; use time.Time.Equal for timestamps). Connects to [[Day 025]].
  • Test error paths explicitly: MarkDone(999) should return an error matching a sentinel ErrNotFound — assert with errors.Is(err, ErrNotFound), never string-compare. Connects to the [[errors]] toolkit.
  • Mark reusable assertion code with t.Helper() so failures report the caller's line, not the helper's.
  • Use t.Fatalf when continuing is pointless (setup failed) and t.Errorf to record a failure but keep checking other assertions in the row.
  • Don't depend on map iteration order in assertions (it's randomized) — sort first or compare sets. Connects to [[maps-sets]].
  • Coverage: go test -cover ./...; target the store + command logic, not trivial getters. Run the whole repo with go test ./... before committing.
  • README should answer: what it does, install/build, usage examples for each subcommand, where data is stored, and how to run tests. A good README is part of the deliverable.

💻 Code Examples

func TestStoreRoundTrip(t *testing.T) {
    dir := t.TempDir() // isolated, auto-cleaned
    path := filepath.Join(dir, "tasks.json")

    s, err := Load(path) // missing file → empty store, no error
    if err != nil {
        t.Fatalf("Load empty: %v", err)
    }
    s.Add("write tests")
    s.Add("ship it")
    if err := s.Save(); err != nil {
        t.Fatalf("Save: %v", err)
    }

    reloaded, err := Load(path)
    if err != nil {
        t.Fatalf("reload: %v", err)
    }
    if got := len(reloaded.All()); got != 2 {
        t.Fatalf("len after reload = %d; want 2", got)
    }
}

func TestMarkDoneNotFound(t *testing.T) {
    s := &Store{} // empty
    err := s.MarkDone(999)
    if !errors.Is(err, ErrNotFound) { // sentinel match, not string compare
        t.Errorf("MarkDone(999) err = %v; want ErrNotFound", err)
    }
}

Project tests live under projects/taskcli/store_test.go. The same table-driven + errors.Is patterns are exercised stdlib-only in the Week-4 exercises.

🏋️ Exercises / Practice

Exercise Status Link
Table-driven tests across all Week-4 exercises green exercises/month-01/week-4
t.TempDir store round-trip + ErrNotFound path (project store_test.go)

🐛 Mistakes Made

  • Wrote a temp file in the test and forgot to clean it up; replaced manual os.MkdirTemp+defer os.RemoveAll with t.TempDir() (auto-cleanup, less boilerplate).
  • Asserted err.Error() == "task not found" — brittle. Switched to errors.Is(err, ErrNotFound).
  • A list test compared slices built from map iteration and flaked; sorted by ID before comparing.

❓ Open Questions

  • Fuzz testing (go test -fuzz) for the JSON decode path — worth a look in Month 2.

🧠 Active Recall (answer without looking)

  1. Q: What does t.TempDir() give you and why prefer it over os.MkdirTemp?

    A A unique temp directory scoped to the test that's **automatically removed** when the test finishes — no manual `defer os.RemoveAll`, and isolation between parallel tests.

  2. Q: Why assert error cases with errors.Is(err, ErrNotFound) instead of comparing the message string?

    A The message can change and may be wrapped; `errors.Is` walks the wrap chain and matches the sentinel by identity, so the test stays correct under `%w` wrapping and message edits.

🪶 Feynman Reflection

Tests pin down behavior so I can refactor fearlessly. The two power tools today: table-driven subtests (one loop, many named cases) and t.TempDir() (real file I/O with zero cleanup). Testing the error paths with errors.Is proves the contract, not the wording.

🕳️ Knowledge Gaps

  • Benchmarks (testing.B) and -race for the project — light here, will lean on them in concurrency month.

✅ Summary

taskcli is tested end-to-end with table-driven subtests and t.TempDir() round-trips, error paths asserted via errors.Is, and shipped with a README — it's a portfolio-ready mini-project.

⏭️ Next Steps / Prep for Tomorrow

  • Day 028: Month 1 review, write the monthly retro, and tag the v0.1.0 release.

Time spent Difficulty Confidence
95 min 🟦🟦⬜⬜⬜ 🟦🟦🟦🟦⬜

Suggested commit: test(taskcli): table-driven store tests and project README (day 027)