Testing and Logging
Test the Commands and the Shell
Goal
By the end of this lesson, Tasker's commands have tests that run them exactly as a user would: tasker add, tasker done, tasker list, flags, the config file, and error messages. You also test the interactive shell by giving it its input from a string. None of these tests touch your real tasks or settings.
What you will learn
- How to run a Cobra command inside a test with
SetArgs - How to capture output with
SetOutand abytes.Buffer - How to isolate tests from the user's environment with
t.Setenv - How to test code that reads input, using
strings.NewReader - Why
io.Readerandio.Writermake code easy to test - How to test error messages with a table
Why this matters
The tests in Lesson 5.2 checked the core. But users never call store.Add directly. They type commands. Many bugs live in the layer between the user and the core: a flag that is not bound, a wrong argument check, or an output format that changed. Tests at this level check what the user actually sees.
Two decisions from Module 4 make these tests easy:
- Commands write to
cmd.OutOrStdout(), not toos.Stdout. A test can replace the output. - The shell reads from an
io.Readerand writes to anio.Writer. A test can give it any input.
Step 1: Isolate the test from the real world
Tasker reads the home folder, environment variables, and a config file. A test that uses your real home folder could read your config, or even change your tasks. Each test needs a clean, private environment.
Create internal/cli/cli_test.go:
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yourname/tasker/internal/storage"
"github.com/yourname/tasker/internal/task"
)
// setupHome gives the test an empty home folder and clears Tasker's
// environment variables, so the user's real settings cannot affect the test.
// It returns the path of the home folder.
func setupHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) // the home folder on Windows
t.Setenv("TASKER_FILE", "")
t.Setenv("TASKER_LIST_OPEN", "")
return home
}
t.Setenv sets an environment variable for this test only. When the test ends, Go restores the old value automatically.
os.UserHomeDir reads HOME on macOS and Linux and USERPROFILE on Windows, so the helper sets both. Now Tasker looks for ~/.tasker inside a temporary folder, and your real files stay untouched.
Tests that use
t.Setenvcannot run in parallel witht.Parallel(), because environment variables are shared by the whole program. Go stops the test with an error if you try.
Step 2: Run a command and capture its output
Add a second helper:
// runTasker runs the tasker command with args and returns everything it printed.
func runTasker(t *testing.T, args ...string) (string, error) {
t.Helper()
var out bytes.Buffer
root := NewRootCmd()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
err := root.Execute()
return out.String(), err
}
args ...stringmakesrunTaskera variadic function: it accepts any number of string arguments, and inside the functionargsis a[]string. You call it likerunTasker(t, "add", "Buy", "milk"). You already used variadic functions:fmt.Printlnandappendare both variadic.bytes.Bufferis a growing block of memory that satisfiesio.Writer. Everything the command writes goes into it, andout.String()returns it as text.SetOutandSetErrreplace the output streams.cmd.OutOrStdout()now returns the buffer instead of the terminal.SetArgsgives the command its arguments. Without it, Cobra would reados.Args, the arguments of the test program itself.
Each call creates a fresh root command with NewRootCmd(), with its own Viper instance. This is why Lesson 4.4 avoided the global Viper functions: global settings from one test would leak into the next.
Step 3: Test a full user session
func TestAddDoneList(t *testing.T) {
setupHome(t)
out, err := runTasker(t, "add", "Buy", "milk", "#shop")
require.NoError(t, err)
assert.Equal(t, "Added: 1. [ ] Buy milk #shop\n", out)
_, err = runTasker(t, "add", "Pay", "rent")
require.NoError(t, err)
out, err = runTasker(t, "done", "1")
require.NoError(t, err)
assert.Equal(t, "Completed: 1. [x] Buy milk #shop\n", out)
out, err = runTasker(t, "list")
require.NoError(t, err)
assert.Equal(t, "1. [x] Buy milk #shop\n2. [ ] Pay rent\n", out)
out, err = runTasker(t, "list", "--open")
require.NoError(t, err)
assert.Equal(t, "2. [ ] Pay rent\n", out)
}
This test runs five commands in a row against the same temporary task file, just as a user would in a terminal. It checks the exact output, including the new lines (\n).
In a test, arguments are not split by a terminal, so "#shop" needs no quotes. Each string in the call is one argument.
Run it:
go test ./internal/cli
Now change the output on purpose. In add.go, change "Added:" to "Created:" and run the test again:
--- FAIL: TestAddDoneList (0.00s)
cli_test.go:47:
Error: Not equal:
expected: "Added: 1. [ ] Buy milk #shop\n"
actual : "Created: 1. [ ] Buy milk #shop\n"
Diff:
--- Expected
+++ Actual
@@ -1,2 +1,2 @@
-Added: 1. [ ] Buy milk #shop
+Created: 1. [ ] Buy milk #shop
testify shows a diff for long strings. A changed output is not always a bug, but it should always be a decision. Scripts that read Tasker's output depend on it. Change the text back.
Step 4: Test flags and the config file
func TestFileFlag(t *testing.T) {
setupHome(t)
path := filepath.Join(t.TempDir(), "other.json")
_, err := runTasker(t, "--file", path, "add", "Test")
require.NoError(t, err)
assert.FileExists(t, path)
}
func TestConfigFileSetsListOpen(t *testing.T) {
home := setupHome(t)
dir := filepath.Join(home, ".tasker")
require.NoError(t, os.MkdirAll(dir, 0o755))
config := "list:\n open: true\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644))
_, err := runTasker(t, "add", "Done task")
require.NoError(t, err)
_, err = runTasker(t, "done", "1")
require.NoError(t, err)
_, err = runTasker(t, "add", "Open task")
require.NoError(t, err)
out, err := runTasker(t, "list")
require.NoError(t, err)
assert.Equal(t, "2. [ ] Open task\n", out)
out, err = runTasker(t, "list", "--open=false")
require.NoError(t, err)
assert.Equal(t, "1. [x] Done task\n2. [ ] Open task\n", out, "the flag must win over the config file")
}
setupHome returns the temporary home folder, so the test can write a config file into it. The test checks the settings order from Lesson 4.4: the config file turns the filter on, and the flag turns it off again. If someone breaks the flag binding later, this test fails.
Step 5: Test error messages with a table
func TestCommandErrors(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{name: "add without title", args: []string{"add"}, wantErr: "requires at least 1 arg"},
{name: "done without id", args: []string{"done"}, wantErr: "accepts 1 arg"},
{name: "done with text", args: []string{"done", "abc"}, wantErr: "is not a number"},
{name: "done missing task", args: []string{"done", "5"}, wantErr: "task 5: task not found"},
{name: "unknown command", args: []string{"fly"}, wantErr: `unknown command "fly"`},
{name: "unknown flag", args: []string{"list", "--colour"}, wantErr: "unknown flag: --colour"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setupHome(t)
_, err := runTasker(t, tt.args...)
assert.ErrorContains(t, err, tt.wantErr)
})
}
}
runTasker(t, tt.args...) passes the items of a slice to a variadic function, one by one. You saw the same ... with append in Lesson 2.2.
Every subtest calls setupHome, so each case starts with an empty home folder. Cases cannot affect each other.
Step 6: Test the shell
The shell is interactive, so it looks hard to test. But runShell only needs an io.Reader and an io.Writer, and strings.NewReader turns any string into an io.Reader:
func TestShell(t *testing.T) {
store, err := task.Open(storage.NewMemory())
require.NoError(t, err)
input := strings.NewReader("add Buy milk #shop\nadd Pay rent\ndone 1\ndone 1\nlist\nquit\n")
var out bytes.Buffer
err = runShell(store, input, &out)
require.NoError(t, err)
got := out.String()
assert.Contains(t, got, "Added: 1. [ ] Buy milk #shop")
assert.Contains(t, got, "Completed: 1. [x] Buy milk #shop")
assert.Contains(t, got, "error: task is already done")
assert.Contains(t, got, "1. [x] Buy milk #shop\n2. [ ] Pay rent\n")
assert.Contains(t, got, "Bye!")
assert.Equal(t, 2, store.Len())
}
func TestShellEndOfInput(t *testing.T) {
store, err := task.Open(storage.NewMemory())
require.NoError(t, err)
var out bytes.Buffer
err = runShell(store, strings.NewReader("add Test"), &out)
require.NoError(t, err, "the shell must end without an error when the input ends")
assert.Equal(t, 1, store.Len())
}
The input string is a whole session, one command per line. The shell reads it exactly as if a user typed it. runShell is unexported, and this test can call it because the test file uses package cli.
The shell's output also contains prompts (> ) and the welcome line, so assert.Contains checks for the important parts instead of comparing the whole text.
TestShellEndOfInput covers an edge case: input that ends without quit and without a final new line, as when a user presses Ctrl+D. The shell must save the task and end cleanly.
The shell test uses the memory storage, so it needs no files and no setupHome.
Step 7: Run everything
go test ./...
go test -cover ./internal/cli
? github.com/yourname/tasker/cmd/tasker [no test files]
ok github.com/yourname/tasker/internal/cli 0.015s
ok github.com/yourname/tasker/internal/storage (cached)
ok github.com/yourname/tasker/internal/task (cached)
ok github.com/yourname/tasker/internal/cli 0.020s coverage: 64.3% of statements
cmd/tasker has no tests, and that is fine. It contains only the three lines of main, and every real piece of code it calls is tested.
Why it works: inject everything from the outside
Code is easy to test when everything it depends on comes from the outside:
| Dependency | In production | In tests |
|---|---|---|
| Arguments | os.Args | SetArgs(...) |
| Output | the terminal | bytes.Buffer |
| Input | the keyboard | strings.NewReader(...) |
| Storage | a JSON file | memory storage |
| Settings | the real home folder and environment | t.TempDir() and t.Setenv |
This idea is called dependency injection. In Go, it needs no framework. You pass values and interfaces as parameters or struct fields. You will use exactly this approach for the API in Part 2: handlers get their store and logger from the outside, and tests replace them.
Exercises
- Test JSON output. Write a test that runs
list --json, parses the output withjson.Unmarshalinto a[]task.Task, and checks the titles. - Test the
configcommand. Write a test that runstasker configwith and without a config file, and checks that the output contains the right config file path. - Test
--version. Check thattasker --versionprints theVersionconstant. - Shell edge cases. Add a table test for the shell with inputs such as an empty line, an unknown command, and
donewithout an ID. Check the error message for each. - A test that leaks. Remove the
t.Setenv("TASKER_LIST_OPEN", "")line fromsetupHome. RunTASKER_LIST_OPEN=true go test ./internal/cli. Which tests fail, and why? Put the line back.
Checkpoint
You are ready for the next lesson if you can:
- Run a Cobra command in a test with
SetArgsand capture its output withSetOut - Explain why each test creates a new root command
- Isolate a test from the real home folder and environment
- Test interactive code with
strings.NewReaderandbytes.Buffer - Explain what dependency injection means and why it makes testing easy
