Testing and Logging

Test the Commands and the Shell

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

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 SetOut and a bytes.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.Reader and io.Writer make 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 to os.Stdout. A test can replace the output.
  • The shell reads from an io.Reader and writes to an io.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.Setenv cannot run in parallel with t.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 ...string makes runTasker a variadic function: it accepts any number of string arguments, and inside the function args is a []string. You call it like runTasker(t, "add", "Buy", "milk"). You already used variadic functions: fmt.Println and append are both variadic.
  • bytes.Buffer is a growing block of memory that satisfies io.Writer. Everything the command writes goes into it, and out.String() returns it as text.
  • SetOut and SetErr replace the output streams. cmd.OutOrStdout() now returns the buffer instead of the terminal.
  • SetArgs gives the command its arguments. Without it, Cobra would read os.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:

DependencyIn productionIn tests
Argumentsos.ArgsSetArgs(...)
Outputthe terminalbytes.Buffer
Inputthe keyboardstrings.NewReader(...)
Storagea JSON filememory storage
Settingsthe real home folder and environmentt.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

  1. Test JSON output. Write a test that runs list --json, parses the output with json.Unmarshal into a []task.Task, and checks the titles.
  2. Test the config command. Write a test that runs tasker config with and without a config file, and checks that the output contains the right config file path.
  3. Test --version. Check that tasker --version prints the Version constant.
  4. Shell edge cases. Add a table test for the shell with inputs such as an empty line, an unknown command, and done without an ID. Check the error message for each.
  5. A test that leaks. Remove the t.Setenv("TASKER_LIST_OPEN", "") line from setupHome. Run TASKER_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 SetArgs and capture its output with SetOut
  • Explain why each test creates a new root command
  • Isolate a test from the real home folder and environment
  • Test interactive code with strings.NewReader and bytes.Buffer
  • Explain what dependency injection means and why it makes testing easy