Skip to content

Latest commit

 

History

History
218 lines (176 loc) · 20.6 KB

03-tests.adoc

File metadata and controls

218 lines (176 loc) · 20.6 KB

Тестирование

Практический любой современный язык из коробки предоставляет инструментарий для написания и запуска тестов. Так и в 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 подходом «табличные тесты». В этом подходе создаётся массив структур в которых описываются входные и выходные параметры, а потом в цикле для каждого элемента этого массива выполняется проверка результата при заданных аргументах.

07_insert_sort/main_test.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 мс. Но как понять — много это или мало? В данном случае мы можем сравнить это со стандартной библиотекой. Для этого расширим тест, сделав два вложенных теста: для нашей функции сортировки и для стандартной:

07_insert_sort/main_benchmark_test.go
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().

Задача: оптимизация сортировки вставками бинарным поиском

Добавьте оптимизации в функцию сортировки InsertSort и сравните производительность:

  • используйте функцию copy;

  • используйте бинарный поиск места вставки;

  • используйте экспоненциальный поиск места вставки;