Property-based тестирование в Go: ломаем код быстрее, чем вы пишете

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

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

Начать курс

Когда юнит-тесты врут, а баги остаются

Вы написали 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,                

Блоги

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