Введение: тесты проходят, а баги остаются?
Представьте ситуацию: вы написали модуль на Go, покрыли его юнит-тестами на 95% по go test -cover, все тесты зеленые. Вы уверены, что код работает идеально. Но в продакшене вылезает баг. Знакомо?
Проблема в том, что метрика покрытия кода (code coverage) — это лишь количество строк, которые были выполнены. Она не говорит, что именно проверяют тесты. Можно иметь 100% покрытие, но ни разу не проверить, что функция возвращает правильное значение, а не просто не падает с паникой.
Здесь на сцену выходит мутационное тестирование (mutation testing) в Go — методика, которая искусственно вносит ошибки в ваш код и смотрит, смогут ли тесты их обнаружить. Если тесты не падают на мутированном коде — значит, ваши тесты бесполезны.
В этой статье мы разберем, что такое мутационное тестирование в Go, как его настроить, и почему это must-have для проектов, где надежность критична. Поехали!
Что такое мутационное тестирование?
Мутационное тестирование — это метод оценки качества тестов, при котором в программу вносятся небольшие изменения (мутации), имитирующие типичные ошибки разработчика. Затем запускаются тесты. Если хотя бы один тест падает — мутация "убита", тест работает. Если все тесты проходят — мутация "выжила", и это красный флаг: у вас дыра в тестах.
В Go мутационное тестирование часто называют mutation testing golang, и оно особенно полезно для проверки бизнес-логики, валидаторов, парсеров и других критичных компонентов.
Пример мутации
Допустим, у вас есть простая функция:
func IsEven(n int) bool {
return n%2 == 0
}
Типичная мутация — замена == на !=:
func IsEven(n int) bool {
return n%2 != 0
}
Если ваш тест проверяет только, что IsEven(2) не падает, но не проверяет, что результат — true, то мутация выживет. Тест пройдет, хотя функция теперь работает с точностью до наоборот.
Почему простое покрытие кода недостаточно?
Многие разработчики на Go привыкли полагаться на go test -cover. Это полезная метрика, но она обманчива. Рассмотрим пример:
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
Тест:
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatal(err)
}
if result != 5 {
t.Errorf("expected 5, got %d", result)
}
}
Покрытие? 100% — все строки выполнены. Но что, если кто-то случайно заменит a / b на a * b? Тест не заметит, потому что 10 * 2 = 20, а мы ожидали 5. Тест упадет? Нет, если мы не проверяем точное значение.
Мутационное тестирование в Go сразу выявит эту проблему, создав мутанта с умножением и проверив, упадет ли тест.
Инструменты для мутационного тестирования в Go
Основной инструмент — go-mutesting. Это популярная библиотека, которая автоматически создает мутации и запускает тесты. Установка:
go get github.com/zimmski/go-mutesting/cmd/go-mutesting
Также есть альтернативы, такие как mutesting от компании "Go Test It", но go-mutesting — де-факто стандарт.
Как запустить
Базовый запуск:
go-mutesting ./...
Эта команда найдет все файлы _test.go в проекте и проверит, какие мутации выживают. Результат выводится в консоль с подробным отчетом.
Практический пример: мутационное тестирование на Go
Создадим простой пакет calculator с функциями сложения и вычитания, а затем протестируем его с помощью go-mutesting.
Шаг 1. Код
Файл calc.go:
package calculator
func Add(a, b int) int {
return a + b
}
func Subtract(a, b int) int {
return a - b
}
Файл calc_test.go:
package calculator
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("expected 5, got %d", result)
}
}
func TestSubtract(t *testing.T) {
result := Subtract(10, 4)
if result != 6 {
t.Errorf("expected 6, got %d", result)
}
}
Шаг 2. Запуск мутационного тестирования
go-mutesting ./calculator/...
Вывод будет примерно таким:
PASS: "calc.go:4" - mutation "a + b -> a - b" killed
PASS: "calc.go:8" - mutation "a - b -> a + b" killed
...
Mutation score: 100% (4/4 mutants killed)
Отлично! Все мутации убиты. Но что будет, если мы уберем проверку результата?
Шаг 3. Слабый тест
Изменим тест на вычитание:
func TestSubtract(t *testing.T) {
result := Subtract(10, 4)
if result != 0 { // глупая проверка
t.Errorf("expected 0, got %d", result)
}
}
Запустим снова:
go-mutesting ./calculator/...
Результат:
FAIL: "calc.go:8" - mutation "a - b -> a + b" survived
Mutation score: 75% (3/4 mutants killed)
Мутация выжила! Тест не заметил, что вычитание заменили на сложение, потому что проверял только на равенство нулю. Вот так мутационное тестирование в Go показывает реальную эффективность тестов.
Какие мутации бывают?
Инструмент go-mutesting поддерживает множество типов мутаций. Вот основные:
- Замена операторов:
+→-,==→!=,>→< - Инверсия условий:
if a > b→if a <= b