Мой подход к тестированию. Часть вторая

В первой статье цикла о тестировании, которая вышла почти два года назад, я описал свой подход к тестированию, который был актуален в 2016 году. Время идёт, всё изменяется, стандартная библиотека Go становится лучше и вот пару-тройку мажорных версий назад в пакете testing появился новый метод Run(), который позволяет запускать именованные подтесты. Теперь Гоблина можно отправить обратно в пещеру и уменьшить число зависимостей проекта.

Run tester Run

Не будем вспоминать, как мы без этого жили раньше, сразу же рассмотрим пример. Имеется у нас функция странного умножения:

func Multiply(a, b int) int {
    res := a * b
    if res == 0 {
        return 0
    }

    if res % 2 == 0 {
        return res - 1
    }

    return res + 1
}

Нам нужно её протестировать. Как обычно, используем табличное тестирование, но теперь мы можем вложить дополнительную информацию в таблицу и вывести её:

package multi

import (
    "fmt"
    "testing"
)

func TestMultiply(t *testing.T) {
    testData := []struct{
        name string
        a, b, res int
    }{
        {
            name: "First",
            a: 1,
            b: 1,
            res: 2,
        },
        {
            name: "Second",
            a: 2,
            b: 1,
            res: 1,
        },
        {
            name: "First",
            a: 2,
            b: 2,
            res: 3,
        },
    }

    for i, v := range testData {
        t.Run(fmt.Sprintf("#%d name: %s", i, v.name), func(t  *testing.T) {
            res := Multiply(v.a, v.b)
            if res != v.res {
                t.Errorf("Multiply(%d, %d) => %d, expected %d", v.a, v.b, res, v.res)
            }
        })
    }
}

Выполнив данный тест, мы увидим следующее:

go test -v
=== RUN   TestMultiply
=== RUN   TestMultiply/#0_name:_First
=== RUN   TestMultiply/#1_name:_Second
=== RUN   TestMultiply/#2_name:_First
--- PASS: TestMultiply (0.00s)
    --- PASS: TestMultiply/#0_name:_First (0.00s)
    --- PASS: TestMultiply/#1_name:_Second (0.00s)
    --- PASS: TestMultiply/#2_name:_First (0.00s)
PASS
ok  	multi	0.008s

Теперь усложним нашу функцию. Предположим, мы хотим к результату кратному десяти прибавлять пять. Добавим этот случай в таблицу:

    testData := []struct{
        name string
        a, b, res int
    }{
        {
            name: "First",
            a: 1,
            b: 1,
            res: 2,
        },
        {
            name: "Second",
            a: 2,
            b: 1,
            res: 1,
        },
        {
            name: "First",
            a: 2,
            b: 2,
            res: 3,
        },
        {
            name: "Twenty",
            a: 2,
            b: 10,
            res: 25,
        },
    }

Тут же видим на каком элементе наш тест завалился:

go test -v
=== RUN   TestMultiply
=== RUN   TestMultiply/#0_name:_First
=== RUN   TestMultiply/#1_name:_Second
=== RUN   TestMultiply/#2_name:_First
=== RUN   TestMultiply/#3_name:_Twenty
--- FAIL: TestMultiply (0.00s)
    --- PASS: TestMultiply/#0_name:_First (0.00s)
    --- PASS: TestMultiply/#1_name:_Second (0.00s)
    --- PASS: TestMultiply/#2_name:_First (0.00s)
    --- FAIL: TestMultiply/#3_name:_Twenty (0.00s)
    	ttt_test.go:43: Multiply(2, 10) => 19, expected 25
FAIL
exit status 1
FAIL     _/multi    0.008s

Теперь подправим нашу функцию:

func Multiply(a, b int) int {
    res := a * b
    if res == 0 {
        return 0
    }

    if res % 10 == 0 {
        return res + 5
    }

    if res % 2 == 0 {
        return res - 1
    }

    return res + 1
}

Всё встало на свои места, все проверки пройдены:

go test -v
=== RUN   TestMultiply
=== RUN   TestMultiply/#0_name:_First
=== RUN   TestMultiply/#1_name:_Second
=== RUN   TestMultiply/#2_name:_First
=== RUN   TestMultiply/#3_name:_Twenty
--- PASS: TestMultiply (0.00s)
    --- PASS: TestMultiply/#0_name:_First (0.00s)
    --- PASS: TestMultiply/#1_name:_Second (0.00s)
    --- PASS: TestMultiply/#2_name:_First (0.00s)
    --- PASS: TestMultiply/#3_name:_Twenty (0.00s)
PASS
ok  	multi	0.008s

Покрытие

Не смотря на то, что проверки в тесте выполнены успешно, мы не можем с уверенностью сказать, что протестировали все возможные результаты. Значительно повысить (либо наоборот) уверенность нам поможет ключ -cover команды go test. Запустим выполнение тестов нашего примера с ключом -cover:

go test -cover
PASS
coverage: 87.5% of statements
ok  	multi	0.009s

Да, опасения были небеспочвенны, у нас покрыто только 87.5%. Наш старый знакомый GoConvey в очень удобном виде покажет какие именно части кода не покрыты тестами: test coverage 01

У нас не отработан кейс, при котором произведение получается равным нулю, следует его добавить:

    testData := []struct{
        name string
        a, b, res int
    }{
        {
            name: "First",
            a: 1,
            b: 1,
            res: 2,
        },
        {
            name: "Second",
            a: 2,
            b: 1,
            res: 1,
        },
        {
            name: "First",
            a: 2,
            b: 2,
            res: 3,
        },
        {
            name: "Twenty",
            a: 2,
            b: 10,
            res: 25,
        },
        {
            name: "Zero",
            a: 2,
            b: 0,
            res: 0,
        },
    }

Проверяем покрытие:

go test -cover
PASS
coverage: 100.0% of statements
ok  	multi	0.007s

С таким покрытием не стыдно и в продакшн!

Немного о проверках

В стандартном пакете testing нет никаких инструментов для проведения проверок, т.н. ассертов (assert). Многие рекомендуют использовать пакет assert из поставки testify. Я для себя решил, что обойдусь. Виной тому несколько причин:

Сложнее читать код

Сравним:

    assert.Equal(t, a, b, "The two words should be the same.")

// и

   if b != a {
       t.Errorf("got %s, want %s", b, a)
   }

Если не знать API testify, не сразу догадаешься, что к чему. Обычное сравнение и вывод ошибки гораздо нагляднее, не смотря на то, что более громоздко выглядит.

Не канонично

Обратимся к официальным комментариям к коду, в частости о проверках в тестах:

Some test frameworks encourage writing these backwards: 0 != x, “expected 0, got x”, and so on. Go does not. (Некоторые тест-фреймворки подстрекают писать проверки в обратном порядке: 0 != x, “хотели 0, получили x” и в таком духе. Go не такой!)

Посмотрим на сигнатуру метода Equal:

func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool

Видим, что первым аргументом идёт ожидаемый результат, а лишь затем полученный, что идёт в разрез с рекомендациями. И тут не трудно запутиться. Какзалось бы, для проверки на равенство не имеет значение что с чем сравнивать, но, в случае, если проверка завалится, вывод в консоль может быть некорректным, что только добавит путаницы.

Именно поэтому я не использую testify, а пишу размашисто, зато более понятно.