Практический любой современный язык из коробки предоставляет инструментарий для написания и запуска тестов. Так и в Go есть встроенная библиотека для написания тестов и команда для запуска. Видов тестов существует достаточно много: модульные, интеграционные, функциональные, end-to-end и так далее. Также существуют различные подходы к написанию тестов: BDD, табличные, golden, property-based, fuzzy и много других. Стандартная библиотека не навязывает какой-то стиль или подход к написанию тестов, но как и многие стандартные пакеты Go даёт базовый инструментарий, с помощью которого можно реализовать практически любой из подходов на любом уровне.
Для того, чтобы написать и запустить тест необходимо
-
создать файл с именем, заканчивающимся на
_test.go
; -
описать в этом файле функцию, название которой начинается с
Test
, принимающую один аргументtesting.T
и ничего не возвращающую.
func TestSomething(t *testing.T) {
// ...
}
Внутри функции пишется код вызова тестируемых методов и проверки результатов. Если ожидаемый результат неверен, то необходимо пометить тест как проваленный, для этого можно воспользоваться методом t.Fail
. Также можно писать в лог о причине провала теста, для этого можно использовать функцию t.Log
или вариант с подстановками t.Logf
. Также можно воспользоваться формой t.Error
или t.Errorf
, совмещающей запись в лог и пометку теста как проваленного.
Давайте протестируем функцию сортировки вставкам, написанную в предыдущей главе. Для этого воспользуемся популярным в Go подходом «табличные тесты». В этом подходе создаётся массив структур в которых описываются входные и выходные параметры, а потом в цикле для каждого элемента этого массива выполняется проверка результата при заданных аргументах.
link:examples/07_insert_sort/main_test.go[role=include]
Note
|
В данном примере используется синтаксис объявления массива через три точки. По факту эти три точки в момент компиляции будут заменены на количество элементов в объявлении. Например выражение a := [...]int{1, 2, 3, 4, 5} будет заменено на a := [5]int{1, 2, 3, 4, 5} Такой синтаксис удобно использовать потому, что при добавлении или удалении одного из элементов не нужно переписывать количество в квадратных скобках. Конечно, в данном случае можно было бы воспользоваться и срезом, но массив занимает меньше места и не использует указатели, что более дружелюбно к сборщику мусора. |
Работа этого нехиторого теста вполне очевидна, за исключением двух строк:
-
на 29-й строке выполняется запуск вложенного теста. С помощью вложенных тестов удобно организовывать сложные сценарии тестирования. Идея подтестов заключается в том, что при провале такого теста остальные тесты выполнятся независимо, но родительский тест также будет помечен как проваленный. При этом
t.Run
возвращаетfalse
при провале вложенного теста, так что с использованием этой концепции легко построить и сценарные тесты, отделив логические шаги друг от друга, но сохранив последовательность шагов и прервать выполнение теста при провале одного из шагов. -
на 37-й строке вызывается специальный метод
t.Helper()
. Этот метод исключает эту функцию из трейса ошибки. Таким образом, если, например, у срезов будет разная длинна, то в логах будет показано, что ошибка возникла не на 39 строке, а на 31. В данном случае это не особо меняет дело, но если такая вспомогательная функция используется в нескольких местах, то удобно видеть в каком именно месте использования она вернула ошибку.
Для запуска этого теста необходимо в командной строке выполнить go test
, после чего должны получить сообщение примерно следующего содержания:
PASS ok github.com/vporoshok/go-introduction/examples/07_insert_sort 0.442s
Для того, чтобы увидеть более развёрнутый отчёт о запущенных тестах, можно добавить флаг -v
в команду запуска. Для отображения тестового покрытия добавить флаг -cover
.
При запуске тестов нельзя указать только конкретный файл, содержащий тест, потому что в таком случае компилятор не включит другие файлы, а соответственно, в нашем случае, например, не будет включена сама тестируемая функция InsertSort
, о чём мы и получим сообщение
$ go test main_test.go # command-line-arguments [command-line-arguments.test] ./main_test.go:30:4: undefined: InsertSort FAIL command-line-arguments [build failed] FAIL
Но можно включить все необходимые файлы для запуска теста: go test main.go main_test.go
.
Для того же, чтобы запустить конкретный тест, можно через флаг -run
указать регулярное выражение, для фильтрации тестов по именам функций. Например
$ go test -v -run Sort/inversed === RUN TestSort === RUN TestSort/inversed --- PASS: TestSort (0.00s) --- PASS: TestSort/inversed (0.00s) PASS ok github.com/vporoshok/go-introduction/examples/07_insert_sort 0.099s
Некоторые тесты не всегда с первого раза выявляют ошибку, особенно, если ошибка проявляется только при определённой нагрузке на сервис или при написании внешних тестов на АПИ. Такие тесты удобно запускать многократно, для этого можно воспользоваться флагом -count
. С помощью этого же флага можно «заставить» компилятор заново выполнить закешированный тест.
Начиная с версии 1.10 успешный результат тестирования кешируется. И если файлы не менялись после его запуска, то тест не будет выполняться, а просто покажется сообщение, что тест пройден успешно.
$ go test main.go main_test.go ok command-line-arguments (cached)
Для того, чтобы тест всё же выполнился, можно добавить флаг -count 1
.
С помощью флага -failfast
можно прервать выполнение тестов при первом проваленном тесте. Этот флаг может оказаться полезным для CI систем, если тесты выполняются значительное время.
Также в библиотеку тестирования встроена концепция коротких тестов. При запуске тестов можно добавить флаг -short
, после чего функция testing.Short()
возвращать истину. Таким образом можно пропустить долгие тесты:
func TestSomethingLong(t *testing.T) {
if testing.Short() {
t.Skip("Long test skipped")
}
// do something
}
Ничего так не радует юного программиста, как сравнение своего решения с чужим. И сравнивать, конечно, необходимо с доказательствами. Для таких сравнений отлично подойдёт встроенный инструментарий для написания тестов производительности или бенчмарков. С другой стороны с помощью таких тестов удобно профилировать узкие участки кода, а также контролировать скорость их работы.
Для написания теста производительности необходимо реализовать репрезентативный идемпотентный тест, то есть приближенный к реальным условиям эксплуатации, и такой, чтобы его можно было повторить много раз. В нашем случае это должен быть достаточно большой массив с равномерным распределением чисел (на самом деле нет, но будем рассматривать идеальный случай). Для того, чтобы сгенерировать такой массив воспользуемся стандартным пакетом генерации случайных чисел.
const size = 10000
data := [size]int{}
for i := range data {
data[i] = rand.Int()
}
Note
|
В данном случае используется библиотека math/rand в которой реализован алгоритм генерации случайных чисел с помощью вихря Мерсена. При этом мы не будем задавать зерно для этого генератора, так что при каждом запуске мы будем получать абсолютно одинаковые данные. Это сделано специально, потому что наша задача в данном случае не проверить корректность работы алгоритма, а оценить его время работы. И при этом сделать эту проверку повторяемой.
|
Сортировка даже 10000 элементов на современном компьютере займёт доли секунды и получить реальное время выполнения будет проблематично. Поэтому для получения конкретных цифр тест запускают несколько раз, считают суммарное время, а потом делят его на количество запусков. Выбор количества запусков зависит от времени выполнения одной операции, и может быть оценён последовательным увеличением. Этот механизм встроен в библиотеку тестирования:
func BenchmarkInsertSort(b *testing.B) {
const size = 10000
data := [size]int{}
for i := range data {
data[i] = rand.Int()
}
args := make([]int, len(data))
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
copy(args, data[:])
b.StartTimer()
InsertSort(args)
}
}
В данном примере мы специально убрали из подсчёта времени вспомогательные операции по генерации и копированию данных. Для этого мы использовали функцию b.ResetTimer
с помощью которой занулили таймер теста, исключив таким образом время, затраченное на генерацию данных. А также использовали функции StopTimer
и StartTImer
, чтобы не считать время на копирование данных.
Для запуска этого теста необходимо выполнить команду go test -bench InsertSort
, после чего мы получим примерно такой отчёт:
$ go test -bench InsertSort goos: darwin goarch: amd64 pkg: github.com/vporoshok/go-introduction/examples/07_insert_sort BenchmarkInsertSort-8 18 65151932 ns/op PASS ok github.com/vporoshok/go-introduction/examples/07_insert_sort 1.678s
В отчёте мы видим на какой операционной системе запускался тест, сколько было повторений в цикле и сколько заняла одна операция. Также в виде суффикса теста указывается максимальное доступное число системных тредов (в нашем случае код однопоточный, поэтому можно игнорировать этот показатель).
В целом получилось 65 мс. Но как понять — много это или мало? В данном случае мы можем сравнить это со стандартной библиотекой. Для этого расширим тест, сделав два вложенных теста: для нашей функции сортировки и для стандартной:
link:examples/07_insert_sort/main_benchmark_test.go[role=include]
На что получим примерно следующий отчёт:
$ go test -bench Sort goos: darwin goarch: amd64 pkg: github.com/vporoshok/go-introduction/examples/07_insert_sort BenchmarkSort/insert-8 16 68174407 ns/op BenchmarkSort/standard-8 940 1272137 ns/op PASS ok github.com/vporoshok/go-introduction/examples/07_insert_sort 2.729s
Пожалуй лучше использовать стандартную библиотеку сортировки.
Тесты производительности также могут показывать размер выделяемой в рамках операции памяти, а также количество запросов памяти. Это также может оказаться полезным при профилировании, но стоит помнить, что сбор этой информации замедляет выполнение кода, поэтому время выполнения операции будет уже не совсем «честным».
При написании библиотеки оказывается полезно дать пример использования. Для этого в тестовых файлах можно добавлять примеры. Описываются они как обычные функции в тестовых файлах, с названием, начинающимся на Example
. Например, мы можем переделать в пример нашу функцию main
func ExampleInsertSort() {
a := []int{12, 8, 22, 11, 1, 3}
InsertSort(a)
fmt.Println(a)
// Output: [1 3 8 11 12 22]
}
Такой пример будет не только примером, но при запуске тестов он будет также выполняться как тест и результат вывода будет сравниваться со строкой в комментарии // Output
. В некоторых случаях такой пример вполне достаточен как единственный тест.
Также порой возникает необходимость выполнить дополнительные действия до запуска всех тестов и после. Например, для интеграционных тестов необходимо наполнить базу данных тестовыми данными и очистить в случае, если тесты прошли успешно. Или тесты могут быть дополнительно сконфигурированны с помощью переменных окружения или флагов запуска, соответственно до начала тестирования эти флаги необходимо прочитать и разложить в переменные конфигурации. В этом случае поможет специальная функция:
func TestMain(m *testing.M) {
// setup flags if needed
flag.Parse()
// setup
res := m.Run()
// tear down
os.Exit(res)
}
Если тестовые файлы содержат такую функцию, то при выполнении команды go test
будет выполнена только эта функция. Метод m.Run
выполнит все тесты в пакете (по фильтру, если таковой указан). Если все тесты пройдены успешно, то m.Run()
вернёт 0, в другом случае вернётся код выхода, отличный от нуля. Этот результат необходимо использовать в команде os.Exit
.
Стоит помнить, что при наличии функции TestMain
флаги запуска не будут разобраны автоматически, необходимо явно вызвать функцию flag.Parse()
.