Когда юнит-тесты врут, а баги остаются
Вы написали 20 юнит-тестов. Все зеленые. CI проходит. Вы довольны. А потом — баг в продакшене. Знакомо? Классический сценарий: вы протестировали только те случаи, которые придумали сами. А реальность, как назло, подкидывает комбинации, о которых вы даже не подумали.
В Go есть мощный, но недооцененный подход — property-based тестирование (или тестирование свойств). Вместо того чтобы проверять конкретные входы и выходы, вы описываете свойства, которым должна удовлетворять функция, а фреймворк сам генерирует сотни случайных тестовых данных. Если свойство нарушается — вы нашли баг.
В этой статье мы разберем, как внедрить property-based тестирование в Go с помощью библиотеки gopter, напишем реальные примеры и посмотрим, какие баги можно поймать. Поехали!
Что такое property-based тестирование и зачем оно нужно?
Представьте, что вы тестируете функцию сортировки. Обычный юнит-тест:
func TestSort(t *testing.T) {
input := []int{3, 1, 2}
result := sort(input)
expected := []int{1, 2, 3}
if !reflect.DeepEqual(result, expected) {
t.Errorf("got %v, want %v", result, expected)
}
}
Ок, работает. Но что если массив пустой? А если все элементы одинаковые? А если отрицательные числа? Вы можете написать еще 10 тестов, но всё равно что-то пропустите.
Property-based тестирование решает эту проблему кардинально: вы формулируете инварианты — свойства, которые должны быть истинными для любого корректного входа. Например, для сортировки:
- Длина отсортированного массива равна длине исходного.
- Каждый элемент исходного массива присутствует в отсортированном (и наоборот).
- Массив отсортирован по возрастанию.
Фреймворк (например, gopter или встроенный testing/quick) генерирует случайные входные данные и проверяет, что все свойства выполняются. Если баг есть — вы узнаете о нем, даже не зная, где искать.
Go-библиотеки для property-based тестирования
В экосистеме Go есть несколько инструментов:
testing/quick— встроенный пакет, минималистичный, но с ограниченными возможностями.gopter— мощная библиотека с генераторами, шринкингом (минимизацией входных данных) и гибкими настройками. Рекомендуем именно её.rapid— современная альтернатива, более производительная.
Мы будем использовать gopter, так как у нее лучшая документация и сообщество. Установка:
go get github.com/leanovate/gopter
Пишем первый property-based тест на Go
Допустим, у нас есть функция Reverse, которая переворачивает строку:
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
Какое свойство можно проверить? Если перевернуть строку дважды, мы должны получить исходную:
import (
"testing"
"github.com/leanovate/gopter"
"github.com/leanovate/gopter/gen"
)
func TestReverseTwice(t *testing.T) {
properties := gopter.NewProperties(nil)
properties.Property("reverse(reverse(s)) == s", prop.ForAll(
func(s string) bool {
return Reverse(Reverse(s)) == s
},
gen.AnyString(),
))
properties.TestingRun(t)
}
Запустите тест — и gopter сгенерирует 100 случайных строк, проверит свойство. Если всё ок — тест зеленый. Если нет — вы получите контрпример (самую маленькую строку, на которой тест упал).
Более сложный пример: проверка парсера URL
Представьте, что вы пишете парсер URL. У вас есть функция ParseURL, которая возвращает структуру с протоколом, хостом и путем. Какие свойства можно проверить?
- Если склеить протокол, хост и путь обратно, получится исходный URL (с точностью до перестановки слешей).
- Хост не может быть пустым для абсолютных URL.
- Путь не должен содержать протокол.
Вот пример теста на первое свойство:
func TestURLParseReconstruct(t *testing.T) {
parameters := gopter.DefaultTestParameters()
parameters.MinSuccessfulTests = 500 // увеличим число тестов
properties := gopter.NewProperties(parameters)
properties.Property("reconstruct URL", prop.ForAll(
func(protocol, host, path string) bool {
// Генерируем URL
url := protocol + "://" + host + "/" + path
parsed, err := ParseURL(url)
if err != nil {
return false
}
reconstructed := parsed.Protocol + "://" + parsed.Host + "/" + parsed.Path
return reconstructed == url
},
gen.OneConstOf("http", "https", "ftp"),
gen.AlphaString(),
gen.AlphaString(),
))
properties.TestingRun(t)
}
Обратите внимание: мы ограничили протоколы константами, а хост и путь — только буквами, чтобы избежать заведомо невалидных URL. gopter позволяет настраивать генераторы под конкретные требования.
Шринкинг (shrink) — почему это важно
Когда property-based тест находит ошибку, он показывает контрпример. Но если входные данные огромны (например, строка на 1000 символов), разбираться в причине сложно. Шринкинг автоматически уменьшает контрпример до минимального, который всё ещё вызывает ошибку.
gopter делает это автоматически. Например, если баг в функции Reverse проявляется только на строках с Unicode-символами, шринкер сведет контрпример к чему-то вроде "é".
Когда property-based тестирование реально спасает
Рассмотрим реальный кейс: функция Add для калькулятора, которая должна поддерживать только целые числа. Кажется, что может пойти не так? Напишем тест:
func TestAddCommutative(t *testing.T) {
properties := gopter.NewProperties(nil)
properties.Property("a + b == b + a", prop.ForAll(
func(a, b int) bool {
return Add(a, b) == Add(b, a)
},
gen.Int(),
gen.Int(),
))
properties.TestingRun(t)
}
Тест зеленый? Отлично. Но добавим еще одно свойство — ассоциативность:
properties.Property("(a + b) + c == a + (b + c)", prop.ForAll(
func(a, b,