03 сентября 2024

🦫 Самоучитель по Go для начинающих. Часть 16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты

Энтузиаст-разработчик, автор статей по программированию.
В статье познакомимся с концепцией тестирования кода и её основными видами, изучим инструменты стандартного пакета testing, научимся запускать и визуализировать тесты. В качестве практического задания напишем и протестируем алгоритм «Решето Эратосфена».
🦫 Самоучитель по Go для начинающих. Часть 16.  Тестирование кода и его виды. Table-driven подход. Параллельные тесты

Модульное тестирование

Модульное тестирование, также юнит-тестирование – это проверка корректности отдельных модулей (юнитов) программы. Модулями принято считать блоки кода, содержащие определенный функционал. Чаще всего ими выступают функции и методы.

Модульное тестирование проводится сразу после написания юнита, чтобы проверить, не привело ли изменение кода к появлению ошибок в уже протестированных частях программы. Такой подход позволяет своевременно отлавливать и устранять возможные ошибки.

Познакомимся с юнит-тестами на практическом примере. Создадим примитивную функцию IsEven, которая принимает на вход число типа int и возвращает строку “Even”, если оно четное, иначе – строку “Odd”.

iseven.go
        package main

func IsEven(input int) string {
	if input%2 == 0 {
		return "Even"
	}
	return "Odd"
}

    

Теперь напишем тест для функции IsEven. В Go приняты определенные правила по расположению и наименованию тестирующих файлов и функций:

  1. Файлы с тестами должны находиться в одном пакете с тестируемыми функциями или в соответствующем пакете с суффиксом _test
  2. Название файла с тестами должно оканчиваться на _test.go
  3. Название тестирующей функции должно начинаться префиксом Test и далее содержать название тестируемой функции.

Например, тест для нашей функции IsEven , которая располагается в папке folder и файле iseven.go , следует расположить в той же папке folder, файле iseven_test.go и назвать TestIsEven:

iseven_test.go
        package main

import "testing"

func TestIsEven(t *testing.T) {
	result1 := IsEven(-1)
	if result1 != "Odd" {
		t.Errorf("Result was incorrect, got: %s, want: %s.", result1, "Odd")

	}
	t.Log("Tested result1")

	result2 := IsEven(0)
	if result2 != "Even" {
		t.Errorf("Result was incorrect, got: %s, want: %s.", result2, "Even")
	}
	t.Log("Tested result2")
}

    

Тестирующая функция всегда принимает единственный параметр – *testing.T, где T – это тип для управления состоянием теста и поддержки тестовых логов.

Тест заканчивается в тот момент, когда тестирующая функция возвращает или вызывает один из методов: FailNow, Fatal, Fatalf, SkipNow, Skip, Skipf. Стоит учитывать, что они могут вызываться только из горутины, выполняющей тестирующую функцию.

В тестах часто используются методы Log(f) и Error(f). Первый из них форматирует переданный текст и записывает его в специальный лог ошибок. Для обычных тестов этот текст будет выведен при неудачном завершении теста или при указании флага -v, в то время как для бенчмарков (тестов производительности) он выводится всегда.

Метод Error под капотом вызывает методы Log и Fail, последний из которых при вызове отмечает, что в тестируемой функции есть ошибка, и продолжает выполнение теста.

🦫 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
🦫🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
🦫🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

Запуск тестов

Для запуска тестов достаточно написать в терминале команду go test. В зависимости от результата тестирования в консоли будет надпись PASS (все тесты пройдены) или FAIL (определенные тесты не пройдены), а также дополнительная информация: путь до go-модуля и время выполнения тестов:

        $ go test

PASS
ok      github.com/username/gomodule    0.002

    

Теперь поменяем местами строки “Even” и “Odd” в функции IsEven и после очередного тестирования получим сообщение об ошибке:

        $ go test

--- FAIL: TestIsEven (0.00s)
    iseven_test.go:8: Result was incorrect, got: Even, want: Odd.
    iseven_test.go:13: Result was incorrect, got: Odd, want: Even.
FAIL
exit status 1
FAIL    github.com/username/gomodule    0.002s

    

Иногда требуется запустить тесты в определенном пакете. Сделать это можно следующей командой: go test ./<название_пакета> . Для тестирования всех пакетов используется команда go test ./...

Стоит отметить, что утилита go test имеет множество флагов, позволяющих детально настроить вывод результатов тестирования.

Например, ранее упомянутый флаг -v выведет названия всех выполненных тестовых функций, время их выполнения, а также содержимое лога ошибок:

        $ go test -v

=== RUN   TestIsEven
    iseven_test.go:11: Tested result1
    iseven_test.go:17: Tested result2
--- PASS: TestIsEven (0.00s)
PASS
ok      github.com/username/gomodule    0.003s

    

В рамках одной статьи не представляется возможным рассмотреть все флаги, поэтому для детального ознакомления рекомендуем обратиться к соответствующему разделу из официальной документации.

Визуализация покрытия кода тестами

Часто возникает необходимость проверить покрытие кода тестами. В этом поможет команда go test с флагом -cover, который выведет процент протестированного кода:

        $ go test -cover

PASS
coverage: 100.0% of statements
ok      github.com/username/gomodule    0.003s

    

По этой информации трудно понять, какие именно участки кода покрыты тестами, а какие – нет. Более наглядное представление этой информации можно получить с помощью двух последовательных команд:

  1. go test -coverprofile=coverage.out ****- генерация файла с информацией о покрытии
  2. go tool cover -html=coverage.out – генерация HTML-страницы, наглядно иллюстрирующей покрытие кода тестами
сгенерированная страница
сгенерированная страница

Table-driven тесты

Представим, что необходимо протестировать работу функции IsEven на 5 разных числах. При модульном тестировании придется прописывать много повторяющегося кода с проверкой каждого случая. Такой подход нарушает принцип разработки DRY (Don't repeat yourself), отнимает много времени и подвержен ошибкам.

В подобных случаях рациональнее применить table-driven подход, при котором тест-кейсы организуются в виде таблицы, содержащей заданные входные и ожидаемые выходные значения.

Для создания таблицы используется слайс структур ([]struct) с несколькими полями, содержащими входные и выходные данные и дополнительную информацию, например, текст.

Далее с помощью цикла for производится итерация по значениям слайса структур с запуском метода t.Run, который в качестве второго параметра принимает функцию, проверяющую соответствие входных и выходных данных.

Применим разобранную схему table-driven подхода на тесте для функции IsEven:

        func TestIsEvenTableDriven(t *testing.T) {
	// задание столбцов таблицы
	var testcases = []struct {
		text  string
		input int
		want  string
	}{
		// строки таблицы
		{"-1 - нечетное число", -1, "Odd"},
		{"0 - четное число", 0, "Even"},
		{"1 - нечетное число", 1, "Odd"},
		{"2 - четное число", 2, "Even"},
	}

	// итерация по слайсу структур
	for _, tt := range testcases {
		t.Run(tt.text, func(t *testing.T) {
			result := IsEven(tt.input)
			
			// проверка на соответствие входных и выходных данных
			if result != tt.want {
				t.Errorf("got %s, want %s", result, tt.want)
			}
		})
	}
}

    

Запустите приведенный выше тест с флагом -v и посмотрите на получившийся вывод.

Как вы можете заметить, table-driven тесты упрощают проверку и добавление тест-кейсов и довольно просты в реализации, поэтому являются оптимальным и часто используемым способом тестирования программ.

Параллельный запуск тестов

По умолчанию тесты в Go исполняются последовательно. Чтобы запустить конкретный тест параллельно с другими, следует добавить в него метод t.Parallel().

Утилита go test приостанавливает тесты, содержащие метод t.Parallel() , и возобновляет их после завершения непараллельных тестов. Стоит отметить, что количество тестов, способных исполнятся параллельно в единицу времени, определяет рассмотренный нами в предыдущей статье параметр GOMAXPROCS .

Тест можно сделать параллельным, добавив всего три строки, две из которых – это вызов t.Parallel() в начале теста и внутри метода t.Run(), а третья – инициализация в цикле for дополнительной переменной для избежания распространенной ошибки использования горутин с итераторами и замыканиями:

        func TestIsEvenParallel(t *testing.T) {
	t.Parallel() // помечает тест как параллельный
	var testcases = []struct {
		text  string
		input int
		want  string
	}{
		{"-1 - нечетное число", -1, "Odd"},
		{"0 - четное число", 0, "Even"},
		{"1 - нечетное число", 1, "Odd"},
		{"2 - четное число", 2, "Even"},
	}

	for _, tc := range testcases {
		tc := tc // для избежания ошибки замыкания внутри горутины
		t.Run(tc.text, func(t *testing.T) {
			t.Parallel() // помечает тест-кейс как параллельный
			result := IsEven(tc.input)
			if result != tc.want {
				t.Errorf("got %s, want %s", result, tc.want)
			}
		})
	}
}

    

Стоит отметить, что упомянутая ошибка была решена после изменения поведения цикла for в версии Go 1.22, поэтому строка tc := tc имеет смысл только для версий Go < 1.22.

Для простых функций разница в скорости выполнения параллельных и обычных тестов несущественная. Она становится заметна при увеличении количества и сложности тест-кейсов.

Тесты производительности

Тесты производительности (бенчмарк-тесты, бенчмарки) позволяют оценить эффективность кода. Путем многократного запуска одной и той же функции с разными параметрами бенчмарки рассчитывают среднее время её выполнения и объем задействованной памяти.

Стоит учитывать, что результаты бенчмарков напрямую зависят от характеристик и физического состояния компьютера, на котором они выполняются. Архитектура и кэш процессора, скорость RAM, система охлаждения, конфигурация ОС – эти и другие параметры имеют прямое влияние на качество и результативность тестов производительности.

В Go правила для бенчмарк-тестов такие же, как и для обычных, но с некоторыми отличиями: бенчмарки должны начинаться с префикса Benchmark, принимать параметр *testing.B и содержать тестируемую функцию в цикле for c верхней границей b.N . Таким образом, тестируемый код будет запущен N раз, причем N автоматически корректируется до тех пор, пока время выполнения каждой итерации не станет статистически стабильным.

Чтобы применить бенчмарки на практике, для начала напишем функцию нахождения простых чисел от 1 до заданного целого числа:

        func FindPrimes(limit int) []int {
	var primeNums []int

	for i := 2; i < limit; i++ {
		isPrime := true

		for j := 2; j <= int(math.Sqrt(float64(i))); j++ {
			if i%j == 0 {
				isPrime = false
				break
			}
		}

		if isPrime {
			primeNums = append(primeNums, i)
		}
	}

	return primeNums
}

    

Простейший бенчмарк-тест для функции FindPrimes выглядит следующим образом:

        func BenchmarkFindPrimes(b *testing.B) {
	for i := 0; i < b.N; i++ {
		FindPrimes(100)
	}
}

    

Такой тест дает мало информации об эффективности функции, так как оперирует только одним числом. Более объективным и показательным будет table-driven бенчмарк-тест с несколькими значениями разного порядка, что позволит сделать выводы о работе функции при возрастающих параметрах:

        func BenchmarkFindPrimes(b *testing.B) {
	var testcases = []struct {
		input int
	}{
		{input: 50},
		{input: 100},
		{input: 500},
		{input: 1000},
	}

	for _, tc := range testcases {
		b.Run(fmt.Sprintf("input=%d", tc.input), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				FindPrimes(tc.input)
			}
		})
	}
}

    

Запуск бенчмарков производится при помощи команды go test с флагом -bench и регулярным выражением. Например, для запуска всех бенчмарков используется команда go test -bench=. , что эквивалентно go test -bench .

        $ go test -bench=.

goos: linux
goarch: amd64
pkg: github.com/username/gomodule
cpu: AMD Ryzen 3 5300U with Radeon Graphics         
BenchmarkFindPrimes/input=50-8           2237236               541.4 ns/op
BenchmarkFindPrimes/input=100-8           874279              1208 ns/op
BenchmarkFindPrimes/input=500-8           122217              9658 ns/op
BenchmarkFindPrimes/input=1000-8           48544             24804 ns/op
PASS
ok      github.com/username/gomodule    5.569s

    

Первые 4 параметра (goos, goarch, pkg и cpu) описывают соответственно ОС, архитектуру процессора, go-модуль и маркировку процессора. Далее написаны названия бенчмарк-тестов с указанием входных данных и количества ядер (параметр GOMAXPROCS), задействованных при бенчмарке, а правее – итерации цикла и среднее время выполнения каждой из них.

Стоит учитывать, что при наличии обычных тестов они также выполняются во время бенчмарков. Для избежания этого поведения следует запустить бенчмарк с флагом -run и регулярным выражением, не подходящим под названия обычных тестов. Например, -run=^# или -run=ZZZ

Fuzzing-тестирование

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

Разработчики языка Go добавили поддержку fuzz-тестов в версии 1.18 и в официальном руководстве сформулировали основные правила их написания. Они схожи с правилами для бенчмарков и юнит-тестов, но содержат дополнительную информацию о допустимых типах и наименовании компонентов.

структура fuzz-теста, источник: <a href="https://go.dev/doc/security/fuzz/" target="_blank" rel="noopener noreferrer nofollow">https://go.dev/doc/security/fuzz/</a>
структура fuzz-теста, источник: https://go.dev/doc/security/fuzz/

Задача

В качестве практического задания предлагаем оптимизировать функцию поиска простых чисел с помощью алгоритма “Решето Эратосфена” и написать для нее модульные и бенчмарк-тесты. Некоторые подзадачи потребуют обращения к дополнительным источникам информации, что поспособствует развитию навыка самостоятельного обучения и углублению знаний. Ответы на большинство вопросов содержатся в официальной документации языка Go, поэтому не стоит ею пренебрегать.

Решето Эратосфена

Напишите функцию sieveOfEratosthenes для поиска простых чисел с помощью алгоритма «Решето Эратосфена».

Юнит-тестирование

Напишите юнит-тест по table-driven подходу для функции sieveOfEratosthenes с проверкой пяти и более значений.

Запустите написанный тест одной командой с выполнением следующих условий:

  1. Выводится процент покрытия кода тестами
  2. Включено перемешивание порядка выполнения тестов
  3. Количество запусков теста равно трем
  4. Количество используемых процессоров равно четырем

Бенчмарки

Напишите бенчмарк-тест по table-driven подходу для функции sieveOfEratosthenes с входными числами 50, 100, 500, 1000.

Запустите написанный бенчмарк одной командой с выполнением следующих условий:

  1. Продолжительность каждого теста 3 секунды
  2. Выводится статистика распределения памяти

В качестве полезного упражнения предлагаем провести бенчмарки с одинаковыми параметрами для функций FindPrimes и sieveOfEratosthenes и на основании полученных результатов определить наиболее эффективный алгоритм.

Заключение

В 16 части самоучителя мы затронули важные аспекты тестирования кода, подробно изучили основные виды тестов и на практических примерах рассмотрели их реализацию в языке Go.

В следующей статье изучим основы сетевого программирования и клиент-серверного взаимодействия, рассмотрим инструменты пакета net и напишем первый HTTP-запрос.

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики
  13. Работа с датой и временем. Пакет time
  14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
  15. Конкурентность. Горутины. Каналы
  16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты
  17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net
  18. Протокол HTTP. Создание HTTP-сервера и клиента. Пакет net/http

Комментарии

 
 

ВАКАНСИИ

Добавить вакансию
Ведущий SRE инженер
Москва, по итогам собеседования
Senior DevOps Developer
Лимасол, по итогам собеседования
Senior Software Engineer (Java)
от 4500 USD до 6000 USD

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

LIVE >

Подпишись

на push-уведомления