Testing CLI Tools with Go

11 January 2018

Ricardo Gerardi

Who?

Gopher courtesy of Gopherize.me

Introduction

What's wrong with this code?

package main

import "fmt"

func main() {

    fmt.Println("Type the first number:")
    n1 := 0.0
    fmt.Scanf("%f\n", &n1)

    fmt.Println("Type the second number:")
    n2 := 0.0
    fmt.Scanf("%f\n", &n2)

    fmt.Println("The sum is:", n1+n2)
}

1. Refactor code out of main()

main.go

func main() {

    fmt.Println("Type the first number:")
    n1 := 0.0
    fmt.Scanf("%f\n", &n1)

    fmt.Println("Type the second number:")
    n2 := 0.0
    fmt.Scanf("%f\n", &n2)

    fmt.Println("The sum is:", add(n1, n2))
}

func add(n1, n2 float64) float64 {
    return n1 + n2
}

main test.go

package main

import (
    "testing"
)

func Test_Add(t *testing.T) {
    sum := add(3.2, 6.8)

    if sum != 10.0 {
        t.Errorf("Sum should be 10. Got %f.", sum)
    }
}

2. Leverage interfaces to facilitate tests

E.g: Input data

func main() {
    n1 := askNumber(os.Stdin)
    n2 := askNumber(os.Stdin)
    sum := add(n1, n2)
    printSum(os.Stdout, sum)
}

func askNumber(in io.Reader) float64 {
    fmt.Println("Type a number:")
    n := 0.0
    fmt.Fscanf(in, "%f\n", &n)

    return n
}
func Test_askNumber(t *testing.T) {
    in := bytes.NewBufferString("5")

    var exp float64 = 5

    res := askNumber(in)

    if exp != res {
        t.Errorf("Expected %f, got %f\n", exp, res)
    }
}

2.1. Leverage interfaces to facilitate tests

E.g: or Output...

func printSum(out io.Writer, sum float64) {
    fmt.Fprintln(out, "The sum is:", sum)
}
func Test_PrintSum(t *testing.T) {
    out := bytes.NewBuffer([]byte{})

    var sum float64 = 5
    exp := fmt.Sprintln("The sum is:", sum)
    printSum(out, sum)

    if exp != out.String() {
        t.Errorf("Expected '%s', got '%s'\n", exp, out.String())
    }
}

Test Coverage

$ go test -coverprofile=coverage.out
$ go tool cover -html=coverage.out

3. Putting it all together

func main() {
    os.Exit(run(os.Stdin, os.Stdout))
}

func run(in io.Reader, out io.Writer) int {
    n1 := askNumber(in)
    n2 := askNumber(in)
    sum := add(n1, n2)
    printSum(out, sum)

    return 0
}
func Test_Run(t *testing.T) {

    in := bytes.NewBufferString("5\n5\n")
    out := bytes.NewBuffer([]byte{})

    expRetCode := 0
    exp := fmt.Sprintln("The sum is:", 10)

    retCode := run(in, out)

    if expRetCode != retCode {
        t.Errorf("Expected %d, got %d\n", expRetCode, retCode)
    }

    if exp != out.String() {
        t.Errorf("Expected '%s', got '%s'\n", exp, out.String())
    }

}

WARNING: Test Coverage can be misleading

Test cover blog article

4. Use Table-Driven tests and Sub-tests

func Test_Run(t *testing.T) {
    tests := []struct {
        name       string
        n1         string
        n2         string
        expRetCode int
        expSum     float64
    }{
        {"2 ints", "5", "5", 0, 10},
        {"2 floats", "3.2", "2.4", 0, 5.6},
        {"blank", "", "", 1, 0},
        {"first text", "r", "4", 1, 0},
        {"second text", "4", "r", 1, 0},
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            in := bytes.NewBufferString(fmt.Sprintf("%s\n%s\n", test.n1, test.n2))
            out := bytes.NewBuffer([]byte{})
            exp := fmt.Sprintln("The sum is:", test.expSum)
            if test.expRetCode != 0 {
                exp = ""
            }
            retCode := run(in, out)

            t.Log("Message:", out.String())
            if test.expRetCode != retCode {
                t.Errorf("Expected %d, got %d\n", test.expRetCode, retCode)
            }
            if exp != out.String() {
                t.Errorf("Expected '%s', got '%s'\n", exp, out.String())
            }
        })
    }
}

4.1. Use Table-Driven tests and Sub-tests

$ go test -v . 

=== RUN   Test_Add
--- PASS: Test_Add (0.00s)
=== RUN   Test_askNumber
--- PASS: Test_askNumber (0.00s)
=== RUN   Test_PrintSum
--- PASS: Test_PrintSum (0.00s)
=== RUN   Test_Run
=== RUN   Test_Run/2_ints
=== RUN   Test_Run/2_floats
=== RUN   Test_Run/blank
=== RUN   Test_Run/first_text
=== RUN   Test_Run/second_text
--- PASS: Test_Run (0.00s)
    --- PASS: Test_Run/2_ints (0.00s)
        main_test.go:70: Message: The sum is: 10
    --- PASS: Test_Run/2_floats (0.00s)
        main_test.go:70: Message: The sum is: 5.6
    --- PASS: Test_Run/blank (0.00s)
        main_test.go:70: Message:
    --- PASS: Test_Run/first_text (0.00s)
        main_test.go:70: Message:
    --- PASS: Test_Run/second_text (0.00s)
        main_test.go:70: Message:
PASS
ok      github.com/rgerardi/go-cli-testing/demo6      0.002s

5. Use different techniques to test files and flags

Considerations

5.1 Require interfaces instead of *os.File

Consider the minimal interface that implements only what is required by the function

Instead of:

func Find(f *os.File) ([]string, error)

Use:

func Find(r io.Reader) ([]string, error)

5.2 Test your business logic, not the Standard Library

type myStruct struct {
    data []byte
}

func newFromFile(filename string) (*myStruct, error) {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, err
    }

    return &myStruct{data: data}, nil
}

func (m *myStruct) save(filename string) error {
    return ioutil.WriteFile(filename, m.data, 0644)
}

func (m *myStruct) doMagic() {
    m.data = bytes.ToUpper(m.data)
}
func Test_DoMagic(t *testing.T) {
    inStr := "Some test Data..."
    m := myStruct{
        data: []byte(inStr),
    }

    exp := strings.ToUpper(inStr)
    m.doMagic()

    if exp != string(m.data) {
        t.Errorf("Expected '%s', got '%s'\n", exp, m.data)
    }
}

5.3 Use 'testdata' dir for test files if you need them

Directory and file names that begin with "." or "_" are ignored by the go tool, as are directories named "testdata"

$ cat testdata/test1.txt
Some dummy data for test purposes only
func Test_NewFromFile(t *testing.T) {
    exp := "Some dummy data for test purposes only\n\n"

    m, err := newFromFile("./testdata/test1.txt")

    if err != nil {
        t.Fatalf("There should be no error. Got %s\n", err)
    }

    if exp != string(m.data) {
        t.Errorf("Expected '%s', got '%s'\n", exp, m.data)
    }
}

5.4 ... Or temporary files

func Test_NewFromFile_TempFile(t *testing.T) {
    tempFile, err := ioutil.TempFile(os.TempDir(), "test1_")
    if err != nil {
        t.Fatalf("Cannot create temp file: %s\n", err)
    }
    defer os.Remove(tempFile.Name())

    exp := []byte("Some test data.\n")
    if _, err := tempFile.Write(exp); err != nil {
        t.Fatalf("Cannot write to temp file: %s\n", err)
    }

    if err := tempFile.Close(); err != nil {
        t.Fatalf("Cannot close temp file: %s\n", err)
    }

    m, err := newFromFile(tempFile.Name())

    if err != nil {
        t.Fatalf("There should be no error. Got %s\n", err)
    }

    if bytes.Compare(exp, m.data) != 0 {
        t.Errorf("Expected '%s', got '%s'\n", exp, m.data)
    }
}

5.5 Wrap it in a helper func for cleaner test

func createTempFile(t *testing.T, data []byte) (filename string, cleanFunc func()) {
    t.Helper()
    tempFile, err := ioutil.TempFile(os.TempDir(), "test2_")
    if err != nil {
        t.Fatalf("Cannot create temp file: %s\n", err)
    }

    if _, err := tempFile.Write(data); err != nil {
        t.Fatalf("Cannot write to temp file: %s\n", err)
    }

    if err := tempFile.Close(); err != nil {
        t.Fatalf("Cannot close temp file: %s\n", err)
    }

    return tempFile.Name(), func() { os.Remove(tempFile.Name()) }
}

func Test_NewFromFile_TempFile_Helper(t *testing.T) {
    exp := []byte("Some test data.\n")
    tempFile, cleanFunc := createTempFile(t, exp)
    defer cleanFunc()

    m, err := newFromFile(tempFile)

    if err != nil {
        t.Fatalf("There should be no error. Got %s\n", err)
    }

    if bytes.Compare(exp, m.data) != 0 {
        t.Errorf("Expected '%s', got '%s'\n", exp, m.data)
    }
}

Thank you