Дайджесты новостей
Натюрморт рабочего места программиста от первого лица: руки над клавиатурой и терминал с прохождением параллельных тестов TDD в Go.

TDD на практике в Go: почему зелёный тест требует проверки на падение

Ты наверняка слышал классическую шутку про программиста, у которого стопроцентное покрытие тестами, но при первом же запуске в продакшене сервис падает с паникой. Мы уже обсуждали эту иллюзию в материале о том, почему зелёный линтер не гарантирует надежность тестов. В модульном тестировании ситуация аналогичная: зелёная плашка в терминале сама по себе не доказывает, что твой тест умеет ловить баги.

Известный инженер и автор книги «The Power of Go: Tests» Джон Арундел (John Arundel) сформулировал этот парадокс предельно наглядно: тест, который ни разу не падал на твоих глазах, не заслуживает доверия.

Давай разберём классический цикл TDD (Test-Driven Development) на примере, казалось бы, тривиальной задачи — функции IsSorted, проверяющей, отсортирован ли срез целых чисел по возрастанию.

Программирование задом наперёд: от красного к зелёному

Как поступает разработчик без TDD? Он сразу пишет цикл for, сравнивает элементы и затем набрасывает проверку на []int{1, 2, 3}. Тест проходит. Все довольны.

А что, если в коде закралась опечатка или функция вообще состоит из одной строчки return true? Тест всё равно останется зелёным! Мы получили ложное чувство безопасности.

В TDD мы переворачиваем процесс с головы на ноги:

  1. Красная фаза (Red). Мы объявляем желаемый вызов sorted.IsSorted(input) и пишем минимальную заглушку для компиляции, которая возвращает заведомо неверный ответ: return false. Запускаем go test и лично убеждаемся: тест упал с понятным сообщением об ошибке. Тест живой!
  2. Симметричная проверка. Добавляем кейс на неотсортированные данные []int{1, 3, 2} -> false. Теперь ни одна константа (true или false) не сможет обмануть наш тестовый раннер.
  3. Зелёная фаза (Shameless Green). Пишем простейший рабочий код, даже если он кажется наивным.

Эволюция пограничных условий алгоритма

Именно при переходе от наивного кода к продакшену вскрываются классические грабли алгоритма:

  • Отрицательные числа: если инициализировать переменную предыдущего значения нулём (prev := 0), срез []int{-1, 2, 3} ошибочно посчитается неотсортированным (-1 < 0). Инициализировать нужно строго первым элементом среза!
  • Пустые срезы: обращение input[0] при пустом срезе []int{} немедленно приведёт к фатальной панике времени выполнения panic: runtime error: index out of range. Требуется граничная защита len(input) < 2.
  • Дубликаты: элементы []int{1, 2, 2} должны считаться отсортированными, условие сбоя — строго v < prev.

Табличные тесты и параллелизм в Go 1.22+

Идеальный паттерн для таких проверок в Go — табличные тесты (table-driven tests) в сочетании с подтестами t.Run и параллельным запуском t.Parallel():

package sorted_test

import (
    "testing"
    "github.com/example/sorted"
)

func TestIsSorted_TableDriven(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name  string
        input []int
        want  bool
    }{
        {"отсортированный срез", []int{1, 2, 3}, true},
        {"повторяющиеся элементы", []int{1, 2, 2}, true},
        {"отрицательные числа", []int{-1, 2, 3}, true},
        {"пустой срез", []int{}, true},
        {"один элемент", []int{42}, true},
        {"нарушение в конце", []int{1, 3, 2}, false},
        {"обратный порядок", []int{3, 2, 1}, false},
    }

    for _, tc := range tests {
        // В Go 1.22+ затенение tc := tc больше не требуется:
        // переменные цикла теперь изолированы на каждой итерации
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            got := sorted.IsSorted(tc.input)
            if got != tc.want {
                t.Errorf("IsSorted(%v) = %v, ожидалось %v", tc.input, got, tc.want)
            }
        })
    }
}

Фаза рефакторинга: переход на slices.IsSorted

Когда все пограничные сценарии надёжно зафиксированы тестами, наступает любимый этап любого инженера — рефакторинг. Зачем поддерживать собственный рукописный цикл, если в стандартной библиотеке Go 1.21+ появился generic-пакет slices?

Мы просто выбрасываем свой код и заменяем его вызовом библиотечной функции:

package sorted

import "slices"

// IsSorted проверяет упорядоченность элементов любого сравнимого типа
func IsSorted(input []int) bool {
    return slices.IsSorted(input)
}

Запускаем команду go test -v -race ./... — все семь параллельных подтестов горят зелёным. Мы провели полный рефакторинг кодовой базы за пару секунд, будучи на 100% уверенными, что ни одно граничное условие не сломано. В этом и заключается истинная суперсила TDD.