Мутационное тестирование в Go: как поймать то, что упускают юнит-тесты

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: тесты проходят, а баги остаются?

Представьте ситуацию: вы написали модуль на 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

Блоги

Рекомендаций по книгам