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

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Senior Java Developer
Москва, по итогам собеседования
Go-разработчик
по итогам собеседования

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