Инженер-испытатель заходит в бар и просит пива;
Инженер-испытатель заходит в бар и просит чашку кофе;
Инженер-испытатель заходит в бар и просит 0,7 пива;
Инженер-испытатель заходит в бар и просит -1 пиво;
Инженер-испытатель заходит в бар и просит 2^32 пива;
Инженер-испытатель зашел в бар и попросил помыть ноги;
Инженер-испытатель заходит в бар и просит ящерицу;
Инженер-испытатель зашел в бар и попросил копию asdfQwer@24dg!&*(@;
Инженер-испытатель заходит в бар и ничего не просит;
Инженер-испытатель заходит в бар, выходит, заходит через окно, выходит через заднюю дверь и входит в канализацию;
Инженер-испытатель заходит в бар, выходит, входит, выходит, входит, выходит и, наконец, бьет босса снаружи;
Инженер-испытатель зашел в бар и попросил чашку горячего кунджин кхао;
Инженер-испытатель заходит в бар и просит стакан NaN Null;
Инженер-испытатель ворвался в бар и попросил 500 тонн пива, кофе, средство для мытья ног, чай с молоком дикой кошки;
Инженер-испытатель разобрал планку;
Инженер-испытатель, переодетый боссом, заходит в бар, просит 500 бутылок пива и не платит;
Десять тысяч инженеров-испытателей пронеслись мимо бара;
Инженер-испытатель заходит в бар и просит пива; батончик DROP TABLE;
Инженеры-испытатели ушли из бара довольными.
Затем клиент заказал жареный рис, и бар был жареным.
Вышеизложенный популярный анекдот о тестировании, основная идея которого заключается в том, что вы никогда не сможете полностью протестировать все вопросы.
В разработке программного обеспечения тестирование является чрезвычайно важной частью, и его вес часто может быть равен или даже больше, чем кодирование. Итак, на Голанге, как написать тест хорошо и правильно? Эта статья даст краткое введение в эту проблему. Текущая статья будет разделена на две основные части:
- Некоторые основные методы и инструменты написания тестов Golang
- Как написать «правильный» тест, хотя эта часть кода написана на golang, его основная идея не ограничивается языком
Из-за ограничений по объему эта статья не будет включать тестирование производительности и будет обсуждаться в другой статье позже.
Зачем писать тесты
Возьмем неуместный пример.Тесты это тоже коды.Предполагаем,что вероятность появления багов при написании кода равна p(0
P(ошибка в коде) * P(ошибка в тесте) = p^2
Например, если p равно 1%, то вероятность того, что ошибка появится в то же время, составляет всего 0,01%.
Тесты — это тоже код, и баги тоже могут быть написаны, так как же обеспечить корректность тестов? Писать тесты для тестов? Продолжать писать тесты для тестов тестов?
Мы определяем t(0) как исходный код, любое i, i > 0, t(i+1) как тест на t(i), правильность t(i+1) является необходимым условием для t(i) быть правильным , то для всех i, i>0, правильность t(i) является необходимым условием корректности t(0). . .
Вид теста
Существует множество типов тестов, здесь мы просто выберем несколько тестов, более важных для обычных разработчиков, и дадим краткое описание.
тестирование белого ящика, тестирование черного ящика
Во-первых, метод тестирования можно разделить на тестирование белого ящика и тестирование черного ящика (конечно, есть так называемое тестирование серого ящика, которое здесь не будет обсуждаться).
- Тестирование белого ящика: Тестирование белого ящика, также известное как тестирование прозрачного ящика, структурное тестирование и т. д., является одним из основных методов тестирования программного обеспечения, также известного как структурное тестирование, тестирование, управляемое логикой, или тестирование, основанное на самой программе. . Тестируйте внутреннюю структуру или работу приложения, а не функциональность приложения. В тестировании белого ящика тестовые примеры разрабатываются с точки зрения языков программирования. Тестер вводит данные для проверки пути потока данных в программе и определяет соответствующий вывод, аналогичный узлам в тестовой схеме.
- Тестирование «черного ящика». Тестирование «черного ящика», один из основных методов тестирования программного обеспечения, также может называться функциональным тестированием, тестированием на основе данных или тестированием на основе спецификаций. Тестировщик не знает внутренностей программы и не нуждается в специальных знаниях кода, внутренней структуры и языка программирования приложения. Знайте только ввод, вывод и системную функцию программы, которая заключается в проверке программного интерфейса, функции и внешней структуры с точки зрения пользователя, без учета внутренней логической структуры программы.
Модульные тесты, которые мы пишем, обычно представляют собой тесты белого ящика, потому что мы хорошо понимаем внутреннюю логику тестового объекта.
модульное тестирование, интеграционное тестирование
С точки зрения тестирования его можно разделить на модульное тестирование и интеграционное тестирование:
- В компьютерном программировании модульное тестирование, также известное как модульное тестирование, представляет собой задание по тестированию, которое проверяет программные модули на правильность. Программный модуль — это наименьшая тестируемая часть приложения. В процедурном программировании единицей является отдельная программа, функция, процедура и т. д., в объектно-ориентированном программировании наименьшая единица — это метод, включая методы в базовых классах, абстрактных классах или производных классах.
- Интеграционное тестирование, также известное как тестирование сборки, представляет собой тестовую работу, при которой программные модули собираются единовременно или с добавленной стоимостью для проверки правильности интерфейса системы. Интеграционное тестирование обычно выполняется после модульного тестирования и перед тестированием системы. Практика показала, что хотя модули иногда могут работать по отдельности, нет гарантии, что они могут работать одновременно в собранном виде.
Модульное тестирование может быть тестированием черного ящика, интеграционное тестирование также может быть тестированием белого ящика.
Регрессионное тестирование
- Регрессионное тестирование — это тип тестирования программного обеспечения, предназначенный для проверки того, остается ли исходная функциональность программного обеспечения неизменной после модификации.
Регрессионное тестирование в основном надеется сохранить неизменность программного обеспечения, мы приведем пример для иллюстрации. Например, мы обнаружили, что возникла проблема с запуском программного обеспечения, и открыли проблему на gitlab. После того, как мы обнаружили проблему, мы можем написать тест (имя теста может включать идентификатор проблемы), чтобы воспроизвести проблему (эта версия кода не может запустить этот тест). После того, как мы устраним проблему, снова запустите тест, и результат теста должен быть успешным. Затем каждый раз, когда мы запускаем тест в будущем, запустив этот тест, мы можем гарантировать, что та же проблема не повторится.
базовый тест
Давайте сначала посмотрим на код Golang:
// add.go
package add
func Add(a, b int) int {
return a + b
}
Тестовый случай можно записать так:
// add_test.go
package add
import (
"testing"
)
func TestAdd(t *testing.T) {
res := Add(1, 2)
if res != 3 {
t.Errorf("the result is %d instead of 3", res)
}
}
В командной строке используем go test
go test
В это время go выполнит все тесты в каталоге с суффиксом _test.go.Если тест пройдет успешно, будет выведен следующий вывод:
% go test
PASS
ok code.byted.org/ek/demo_test/t01_basic/correct 0.015s
Предположим, мы меняем функцию Add на неправильную реализацию в данный момент.
// add.go
package add
func Add(a, b int) int {
return a - b
}
Выполните тестовую команду еще раз
% go test
--- FAIL: TestAddWrong (0.00s)
add_test.go:11: the result is -1 instead of 3
FAIL
exit status 1
FAIL code.byted.org/ek/demo_test/t01_basic/wrong 0.006s
Тест не проходит.
Выполнить только один тестовый файл
Затем, если мы хотим протестировать только этот файл, введите
go test add_test.go
Вы найдете вывод командной строки
% go test add_test.go
# command-line-arguments [command-line-arguments.test]
./add_test.go:9:9: undefined: Add
FAIL command-line-arguments [build failed]
FAIL
Это связано с тем, что у нас нет кода, прикрепленного к тестовому объекту, модифицирующего тест для получения правильного результата:
% go test add_test.go add.go
ok command-line-arguments 0.007s
Несколько способов написания теста
субтест
Вообще говоря, когда мы тестируем функцию и метод, нам может понадобиться протестировать много разных случаев или предельных условий.Например, мы пишем два теста для функции добавления выше, которые можно записать как:
// add_test.go
package add
import (
"testing"
)
func TestAdd(t *testing.T) {
res := Add(1, 0)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
}
func TestAdd2(t *testing.T) {
res := Add(0, 1)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
}
Результаты теста: (используйте -v для большего вывода)
% go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestAdd2
--- PASS: TestAdd2 (0.00s)
PASS
ok code.byted.org/ek/demo_test/t02_subtest/non_subtest 0.007s
Другой способ написать его - написать его в виде субтеста.
// add_test.go
package add
import (
"testing"
)
func TestAdd(t *testing.T) {
t.Run("test1", func(t *testing.T) {
res := Add(1, 0)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
})
t.Run("", func(t *testing.T) {
res := Add(0, 1)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
})
}
Результаты:
% go test -v
=== RUN TestAdd
=== RUN TestAdd/test1
=== RUN TestAdd/#00
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/test1 (0.00s)
--- PASS: TestAdd/#00 (0.00s)
PASS
ok code.byted.org/ek/demo_test/t02_subtest/subtest 0.007s
Видно, что тесты классифицируются в соответствии со структурой вложенности в выводе.Ограничения на количество уровней вложенности подтестов нет.Если имя теста не написано, порядковый номер будет автоматически задан как название теста по порядку (например, #00 выше)
Подтесты, дружественные к IDE (Goland)
Один из способов написать тест:
tcList := map[string][]int{
"t1": {1, 2, 3},
"t2": {4, 5, 9},
}
for name, tc := range tcList {
t.Run(name, func(t *testing.T) {
require.Equal(t, tc[2], Add(tc[0], tc[1]))
})
}
Выглядит хорошо, однако есть недостаток, заключающийся в том, что этот тест не подходит для IDE:
Мы не можем повторно запустить один тест при ошибке, поэтому рекомендуется писать каждый t.Run независимо, насколько это возможно, например:
f := func(a, b, exp int) func(t *testing.T) {
return func(t *testing.T) {
require.Equal(t, exp, Add(a, b))
}
}
t.Run("t1", f(1, 2, 3))
t.Run("t2", f(4, 5, 9))
Тестовый субподряд
Приведенные выше файлы add.go и add_test.go находятся в одном каталоге, а имя пакета вверху — add, поэтому в процессе написания теста вы также можете включить тест с другим именем пакета, чем не- Например, теперь мы изменим имя пакета тестового файла на add_test:
// add_test.go
package add_test
import (
"testing"
)
func TestAdd(t *testing.T) {
res := Add(1, 2)
if res != 3 {
t.Errorf("the result is %d instead of 3", res)
}
}
В это время выполнение теста go найдет
% go test
# code.byted.org/ek/demo_test/t03_diffpkg_test [code.byted.org/ek/demo_test/t03_diffpkg.test]
./add_test.go:9:9: undefined: Add
FAIL code.byted.org/ek/demo_test/t03_diffpkg [build failed]
Поскольку имя пакета изменилось, мы больше не можем получить доступ к функции «Добавить», но сейчас мы можем добавить импорт:
// add_test.go
package add_test
import (
"testing"
. "code.byted.org/ek/demo_test/t03_diffpkg"
)
func TestAdd(t *testing.T) {
res := Add(1, 2)
if res != 3 {
t.Errorf("the result is %d instead of 3", res)
}
}
Мы можем использовать описанный выше метод для импорта функций в пакет. Но после использования этого метода неэкспортированные функции (начинающиеся со строчной буквы) в пакете будут недоступны.
библиотека тестовых инструментов
github.com/stretchr/testify
Мы можем использовать мощный testify, чтобы упростить нам написание тестов.Например, вышеприведенный тест мы можем использовать эту библиотеку для написания:
// add_test.go
package correct
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAdd(t *testing.T) {
res := Add(1, 2)
require.Equal(t, 3, res)
/*
must := require.New(t)
res := Add(1, 2)
must.Equal(3, res)
*/
}
Если выполнение завершится ошибкой, вы увидите следующий вывод в командной строке:
% go test
ok code.byted.org/ek/demo_test/t04_libraries/testify/correct 0.008s
--- FAIL: TestAdd (0.00s)
add_test.go:12:
Error Trace: add_test.go:12
Error: Not equal:
expected: 3
actual : -1
Test: TestAdd
FAIL
FAIL code.byted.org/ek/demo_test/t04_libraries/testify/wrong 0.009s
FAIL
Библиотека предоставляет отформатированные сведения об ошибках (стек, значение ошибки, ожидаемое значение и т. д.) для облегчения отладки.
github.com/DATA-DOG/go-sqlmock
Вы можете использовать go-sqlmock для проверки того, где вам нужно проверить sql
- Преимущество: не нужно полагаться на базу данных
- Недостатки: Это выходит за рамки конкретной реализации базы данных, поэтому необходимо писать более сложный тестовый код.
github.com/golang/mock
Мощная mock-библиотека для интерфейсов, например, мы хотим протестировать функцию ioutil.ReadAll.
func ReadAll(r io.Reader) ([]byte, error)
Мы издеваемся над io.Reader
// package: 输出包名
// destination: 输出文件
// io: mock对象的包
// Reader: mock对象的interface名
mockgen -package gomock -destination mock_test.go io Reader
Вы можете увидеть файл mock_test.go в каталоге, который содержит фиктивную реализацию io.Reader.Мы можем использовать эту реализацию, например, для тестирования ioutil.Reader
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := NewMockReader(ctrl)
m.EXPECT().Read(gomock.Any()).Return(0, errors.New("error"))
_, err := ioutil.ReadAll(m)
require.Error(t, err)
net/http/httptest
Обычно, когда мы тестируем код на стороне сервера, мы сначала запускаем службу, а затем запускаем тест. Официальный пакет httptest предоставляет нам удобный способ запустить экземпляр службы для тестирования.
разное
Некоторые другие инструменты тестирования можно найти в awesome-go#testing.
Как писать хорошие тесты
Основные инструменты и методы написания теста были представлены выше.Мы завершили «должен иметь острый инструмент», и теперь мы познакомим вас с тем, как «сделать это хорошо».
Параллельное тестирование
В обычное время, когда мы пишем сервисы, мы должны в основном учитывать параллелизм.Когда мы используем IDE для тестирования, IDE по умолчанию активно не проверяет статус параллелизма, так как же гарантировать, что код, который мы пишем, безопасен для параллелизма? Давайте возьмем пример.Например, если у нас есть счетчик, функция состоит в том, чтобы считать.
type Counter int32
func (c *Counter) Incr() {
*c++
}
Очевидно, что этот счетчик небезопасен в условиях параллелизма, так как же нам написать тест для параллельного тестирования этого счетчика?
import (
"sync"
"testing"
"github.com/stretchr/testify/require"
)
func TestA_Incr(t *testing.T) {
var a Counter
eg := sync.WaitGroup{}
count := 10
eg.Add(count)
for i := 0; i < count; i++ {
go func() {
defer eg.Done()
a.Incr()
}()
}
eg.Wait()
require.Equal(t, count, int(a))
}
Выполняя приведенный выше тест много раз, мы обнаружили, что иногда результат теста возвращает OK, а иногда результат теста возвращает FAIL. То есть, даже если тест написан, он может быть помечен как пройденный тест в определенном тесте. Так есть ли способ найти проблему напрямую? Ответ заключается в том, чтобы добавить флаг -race при тестировании
-гоночный флаг не подходит для эталонного тестирования
go test -race
После этого терминал выдаст:
WARNING: DATA RACE
Read at 0x00c00001ca50 by goroutine 9:
code.byted.org/ek/demo_test/t05_race/race.(*A).Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race.go:6 +0x6f
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr.func1()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:18 +0x66
Previous write at 0x00c00001ca50 by goroutine 8:
code.byted.org/ek/demo_test/t05_race/race.(*A).Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race.go:6 +0x85
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr.func1()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:18 +0x66
Goroutine 9 (running) created at:
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:16 +0xe4
testing.tRunner()
/usr/local/Cellar/go/1.15/libexec/src/testing/testing.go:1108 +0x202
Goroutine 8 (finished) created at:
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:16 +0xe4
testing.tRunner()
/usr/local/Cellar/go/1.15/libexec/src/testing/testing.go:1108 +0x202
go активно подсказывало, что в нашем коде обнаружено состояние гонки, и в это время нам нужно было исправить код
type Counter int32
func (c *Counter) Incr() {
atomic.AddInt32((*int32)(c), 1)
}
После завершения ремонта снова протестируйте с -race, наш тест успешно пройден!
Нативное тестирование параллелизма в Golang
Тестовый класс Golang testing.T имеет метод Parallel(), все вызовы этого метода в тесте будут помечены как параллельные, но обратите внимание, что если вам нужно использовать результаты параллельных тестов, вы должны использовать дополнительную тестовую функцию в внешний слой
func TestA_Incr(t *testing.T) {
var a Counter
t.Run("outer", func(t *testing.T) {
for i := 0; i < 100; i++ {
t.Run("inner", func(t *testing.T) {
t.Parallel()
a.Incr()
})
}
})
t.Log(a)
}
Строка 11 будет печататься неправильно без t.Run в строке 3
Тестирование Golang.T имеет много других практических методов, вы можете проверить это сами, я не буду подробно обсуждать это здесь.
Правильно тестировать возвращаемые значения
Как суслик, я обычно много пишу if err != nil, поэтому при тестировании ошибки, возвращаемой функцией, мы имеем следующий пример
type I interface {
Foo() error
}
func Bar(i1, i2 I) error {
i1.Foo()
return i2.Foo()
}
Функция Bar ожидает обработки двух входных данных i1 и i2 по очереди и возвращает значение при обнаружении первой ошибки, поэтому мы пишем тест, который выглядит «правильным».
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
)
type impl string
func (i impl) Foo() error {
return errors.New(string(i))
}
func TestBar(t *testing.T) {
i1 := impl("i1")
i2 := impl("i2")
err := Bar(i1, i2)
require.Error(t, err) // assert err != nil
}
Этот тест «выглядит» идеально, и функция корректно возвращает ошибку. Но на самом деле мы знаем, что возвращаемое значение этой функции неверно, поэтому нам следует немного изменить тест и использовать error в качестве возвращаемого значения для проверки содержимого, а не просто оценивать нулевую обработку.
func TestBarFixed(t *testing.T) {
i1 := impl("i1")
i2 := impl("i2")
err := Bar(i1, i2)
// 两种写法都可
require.Equal(t, errors.New("i1"), err)
require.Equal(t, "i1", err.Error())
}
В это время мы можем обнаружить, что в коде есть ошибка, которую необходимо исправить. То же самое можно применить и к другим возвращаемым значениям, мы должны не просто делать какие-то простые суждения, но должны делать суждения о "точных значениях" в максимально возможной степени.
Входные параметры теста
Мы обсуждали возвращаемое значение теста выше, и входное значение также необходимо проверить.В основном мы комбинируем это с gomock.Например, наш код выглядит следующим образом:
type I interface {
Foo(ctx context.Context, i int) (int, error)
}
type bar struct {
i I
}
func (b bar) Bar(ctx context.Context, i int) (int, error) {
i, err := b.i.Foo(context.Background(), i)
return i + 1, err
}
Мы хотим проверить, что класс bar правильно вызывает метод Foo в методе.Мы используем gomock, чтобы смоделировать фиктивную реализацию интерфейса I, который мы хотим:
mockgen -package gomock -destination mock_test.go io Reader
Далее мы написали тест:
import (
"context"
"testing"
. "code.byted.org/ek/testutil/testcase"
"github.com/stretchr/testify/require"
)
func TestBar(t *testing.T) {
t.Run("test", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := context.Background()
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))
}
Тест прошел успешно, но на самом деле мы посмотрели код и обнаружили, что контекст в коде передан не корректно, так как же правильно тестировать эту ситуацию? Один из способов — написать аналогичный тест, в котором context.Background() меняется на другой контекст:
t.Run("correct", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := context.WithValue(context.TODO(), "k", "v")
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))
Другой способ — включить случайные элементы тестирования.
Добавить случайные элементы в тест
То же самое, что и тест выше, мы вносим некоторые изменения
import (
"context"
"testing"
randTest "code.byted.org/ek/testutil/rand"
. "code.byted.org/ek/testutil/testcase"
"github.com/stretchr/testify/require"
)
t.Run("correct", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := context.WithValue(context.TODO(), randTest.String(), randTest.String())
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))
Таким образом, можно в значительной степени избежать некоторых пограничных случаев, вызванных фиксированными тестовыми переменными, поскольку они были правильными по ошибке.Если вы вернетесь к предыдущему примеру функции добавления, вы можете записать его как
import (
"math/rand"
"testing"
"github.com/stretchr/testify/require"
)
func TestAdd(t *testing.T) {
a := rand.Int()
b := rand.Int()
res := Add(a, b)
require.Equal(t, a+b, res)
}
Изменены входные параметры
Если мы изменим предыдущий пример Bar
func (b bar) Bar(ctx context.Context, i int) (int, error) {
ctx = context.WithValue(ctx, "v", i)
i, err := b.i.Foo(ctx, i)
return i + 1, err
}
Функции в основном такие же, за исключением того, что ctx, переданный в метод Foo, становится подконтекстом.В это время предыдущий тест не может быть выполнен правильно.Как судить, что переданный контекст является подконтекстом топ- уровень контекста?
Суждение по почерку
Один из способов — передать context.WithValue в Bar в тесте, а затем в реализации Foo определить, имеет ли полученный контекст определенный kv
t.Run("correct", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
k := randTest.String()
v := randTest.String()
ctx := context.WithValue(context.TODO(), k, v)
impl.EXPECT().Foo(gomock.Any(), i).
Do(func(ctx context.Context, i int) {
s, _ := ctx.Value(k).(string)
must.Equal(v, s)
}).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))
gomock.Matcher
Другой способ — реализовать интерфейс gomock.Matcher.
import (
randTest "code.byted.org/ek/testutil/rand"
)
t.Run("simple", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := randTest.Context()
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))
Основной код randTest.Context выглядит следующим образом:
func (ctx randomContext) Matches(x interface{}) bool {
switch v := x.(type) {
case context.Context:
return v.Value(ctx) == ctx.value
default:
return false
}
}
gomock автоматически использует этот интерфейс для определения соответствия входных параметров.
Протестируйте функцию с большим количеством подвызовов
Давайте посмотрим на следующую функцию:
func foo(i int) (int, error) {
if i < 0 {
return 0, errors.New("negative")
}
return i + 1, nil
}
func Bar(i, j int) (int, error) {
i, err := foo(i)
if err != nil {
return 0, err
}
j, err = foo(j)
if err != nil {
return 0, err
}
return i + j, nil
}
Логика здесь кажется относительно простой, но если представить, что логика Bar и логика foo очень сложны и содержат больше логических ветвей, то при тестировании мы столкнемся с двумя проблемами
- При тестировании функции Bar вам может понадобиться учитывать возвращаемое значение различных функций foo, и вам нужно специально сконструировать входные параметры в соответствии с потребностями foo.
- Сценарии, которые могут потребовать обширного дублирования тестов для foo , дублирования тестов для самого foo
Итак, как решить эту проблему? Я здесь, чтобы дать вам идею, хотя это может быть и не лучшее решение. Если есть лучшее решение, я надеюсь разместить его в области комментариев. Моя идея состоит в том, чтобы изменить функцию foo с фиксированной функции на изменяемый указатель функции, который можно динамически заменять во время тестирования.
var foo = func(i int) (int, error) {
if i < 0 {
return 0, errors.New("negative")
}
return i + 1, nil
}
func Bar(i, j int) (int, error) {
i, err := foo(i)
if err != nil {
return 0, err
}
j, err = foo(j)
if err != nil {
return 0, err
}
return i + j, nil
}
Итак, при тестировании Bar мы можем заменить foo:
func TestBar(t *testing.T) {
f := func(newFoo func(i int) (int, error), cb func()) {
old := foo
defer func() {
foo = old
}()
foo = newFoo
cb()
}
t.Run("first error", TF(func(must *require.Assertions, tc *TC) {
expErr := randTest.Error()
f(func(i int) (int, error) {
return 0, expErr
}, func() {
_, err := Bar(1, 2)
must.Equal(expErr, err)
})
}))
t.Run("second error", TF(func(must *require.Assertions, tc *TC) {
expErr := randTest.Error()
first := true
f(func(i int) (int, error) {
if first {
first = false
return 0, nil
}
return 0, expErr
}, func() {
_, err := Bar(1, 2)
must.Equal(expErr, err)
})
}))
t.Run("success", TF(func(must *require.Assertions, tc *TC) {
f(func(i int) (int, error) {
return i, nil
}, func() {
r, err := Bar(1, 2)
must.NoError(err)
must.Equal(3, r)
})
}))
}
Приведенная выше запись может тестировать foo и Bar по отдельности.
- После использования этого метода вам может понадобиться написать больше кода, связанного с mock (эту часть можно рассмотреть с помощью gomock).
- При тестировании параллелизма с помощью этого метода вам необходимо учитывать, правильно ли ваша фиктивная функция обрабатывает параллелизм.
- Необходимое условие корректности этого теста в целом состоит в том, что тест функции foo корректен, а макет функции foo также согласуется с поведением корректной функции foo, поэтому при необходимости необходимо написать дополнительный общий тест, который не имитирует функцию foo
тестовое покрытие
При написании тестов мы часто упоминаем одно слово — покрытие. Так что же такое тестовое покрытие?
Покрытие тестами — это метрика программного обеспечения в тестировании программного обеспечения или разработке программного обеспечения, которая представляет собой процент программ, которые были протестированы. Покрытие — это способ оценить строгость тестирования. Существует множество различных видов тестового покрытия: Покрытие кода Покрытие функций Покрытие сценария Покрытие элементов экрана Покрытие модулей Каждое покрытие предполагает, что тестируемая система уже имеет эталонную форму. Таким образом, когда меняется система, меняется и тестовое покрытие.
В общем, мы можем думать, что чем выше тестовое покрытие, тем более полное наше тестовое покрытие и тем выше эффективность теста.
Тестовое покрытие в Golang
В golang мы проверяем покрытие при тестировании кода, добавляя флаг -cover
% go test -cover
PASS
coverage: 100.0% of statements
ok code.byted.org/ek/demo_test/t10_coverage 0.008s
Мы видим, что текущее покрытие тестами составляет 100%.
100% покрытие тестами не означает правильных тестов
Чем выше тестовое покрытие, тем выше тестовое покрытие, тем выше тестовое покрытие.
неправильно тестирует ввод и вывод
Об этом уже говорилось выше, можно сослаться на пример «корректного тестирования возвращаемого значения» выше, в примере охват тестами достиг 100%, но проблема кода не была протестирована корректно.
не охватывает всю логику ветвления
func AddIfBothPositive(i, j int) int {
if i > 0 && j > 0 {
i += j
}
return i
}
Следующие тестовые случаи имеют 100% покрытие, но тестируются не все ветки
func TestAdd(t *testing.T) {
res := AddIfBothPositive(1, 2)
require.Equal(t, 3, res)
}
не обрабатывает исключения/граничные условия
func Divide(i, j int) int {
return i / j
}
Функция Divide не обрабатывает деление на 0, а покрытие юнит-тестами составляет 100%.
func TestAdd(t *testing.T) {
res := Divide(6, 2)
require.Equal(t, 3, res)
}
Приведенный выше пример показывает, что 100-процентное покрытие тестами на самом деле не является «100-процентным покрытием» всех операций с кодом.
Статистические методы охвата
Статистический метод покрытия тестами, как правило, представляет собой: количество строк кода, выполненных в тесте / общее количество протестированных строк кода. Однако в реальной работе кода вероятность выполнения каждой строки, серьезность ошибок, и т. д. также отличаются, поэтому мы, преследуя высокий охват, мы не можем быть суеверными в отношении охвата.
Тесты не боятся повторного написания
Повторяющееся написание здесь в определенной степени можно рассматривать как антоним «повторного использования кода». В основном мы фокусируемся на следующих аспектах.
Неоднократно писать похожие тест-кейсы
Пока тест-кейсы не полностью согласованы, даже похожие тест-кейсы можно считать значимыми, и их не нужно удалять специально для упрощения кода, например, мы тестируем функцию «Добавить» выше.
func TestAdd(t *testing.T) {
t.Run("fixed", func(t *testing.T) {
res := Add(1, 2)
require.Equal(t, 3, res)
})
t.Run("random", func(t *testing.T) {
a := rand.Int()
b := rand.Int()
res := Add(a, b)
require.Equal(t, a+b, res)
})
}
Хотя кажется, что второй тест покрывает первый тест, нет необходимости специально удалять первый тест, чем больше тестов у нас будет, тем надежнее будет наш код.
Повторное написание определений и логики в (исходном) коде
Например, у нас есть код
package add
const Value = 3
func AddInternalValue(a int) int {
return a + Value
}
проверить как
func TestAdd(t *testing.T) {
res := AddInternalValue(1)
require.Equal(t, 1+Value, res)
}
Выглядит идеально, но если однажды значение внутренней переменной Value будет случайно изменено, тест не сможет отразить это изменение, и ошибку не удастся обнаружить вовремя. если мы напишем
func TestAdd(t *testing.T) {
const value = 3
res := AddInternalValue(1)
require.Equal(t, 1+value, res)
}
Не беспокойтесь о том, что вы не сможете обнаружить изменения постоянных значений.