JavaRush /Курсы /Go SELF /Регрессионные тесты — «поймал баг → зафиксировал тестом»

Регрессионные тесты — «поймал баг → зафиксировал тестом»

Go SELF
33 уровень , 4 лекция
Открыта

1. Что такое регрессионный тест

Регрессионный тест — это не «ещё один тест в коллекцию», а маленькая страховка от повторения уже пережитого кошмара. В реальной разработке баги часто возвращаются не потому, что кто-то вредничает, а потому что код меняется, контекст забывается, и старое условие снова становится неверным. Регрессионный тест делает баг воспроизводимым по кнопке.

Представьте, что вы починили ошибку, выдохнули, отправили решение… а через неделю кто-то (в том числе вы сами) «слегка упростил код», и проблема вернулась. Только теперь она всплывает не у вас в IDE, а у пользователя, ночью, в пятницу. Регрессионный тест нужен, чтобы при следующем изменении код сам сказал: «стоп, мы уже наступали на эти грабли».

Есть ещё один важный момент: исправление бага само по себе может быть «правильным», но случайно сломать соседнее поведение. Тесты — один из главных способов удерживать совместимость поведения при изменениях: в больших проектах именно так и делают — прогоняют существующий набор тестов, чтобы изменения не ломали то, что раньше работало.

2. Практика: от симптома к тесту

Регрессионный тест редко появляется из воздуха. Обычно он рождается из нормального жизненного цикла бага: сначала есть симптом («иногда падает», «на пустом вводе странно», «неверно считает проценты»), потом вы делаете проблему воспроизводимой, находите причину, чините — и вот тут важно не остановиться.

В этом процессе полезно мыслить циклом: вы не просто «закрываете задачу», вы добавляете в проект предохранитель от повторения.

Цикл регрессии

flowchart TD
    A[Нашли баг / увидели panic] --> B[Сделали минимальный воспроизводимый пример]
    B --> C[Написали тест, который падает]
    C --> D[Исправили код]
    D --> E[Тест стал зелёным]
    E --> F[Оставили тест навсегда, чтобы баг не вернулся]

В этом цикле есть важная психологическая ловушка: «я же уже починил, зачем писать тест». На практике тест — это не бюрократия, а способ законсервировать знание: что именно считалось багом, какие входные данные его вызывают, и какое поведение мы считаем правильным.

Пример: мини‑пакет todo и деление на ноль

Чтобы не оторваться от реальности, продолжим учебный «мини‑проект» в виде пакета с логикой (без CLI и без файлов). Пусть это будет маленькое ядро для задач: структура Task и функция, которая считает прогресс выполнения.

Сделаем основу: структура задачи.

package todo

type Task struct {
	ID    int
	Title string
	Done  bool
}

Теперь добавим функцию ProgressPercent, которая возвращает процент выполненных задач (0..100). И вот тут легко допустить баг: если задач нет, len(tasks) == 0, а мы делим на ноль.

Версия с багом

package todo

func ProgressPercent(tasks []Task) int {
	done := 0
	for _, t := range tasks {
		if t.Done {
			done++
		}
	}
	return done * 100 / len(tasks) // BUG: panic при len(tasks)==0
}

Если tasks пустой, мы получим runtime panic «integer divide by zero». Это тот самый случай, когда программа падает, а вы потом читаете stack trace и грустно вспоминаете лекции про инварианты.

Регрессионный тест: фиксируем поведение на пустом вводе

Сейчас важен принцип: мы пишем тест так, чтобы он воспроизводил проблему. И да, этот тест на текущем коде должен «упасть» (паника — это тоже «упал»).

Но цель — не «поймать panic и порадоваться», а закрепить корректное поведение: для пустого списка прогресс логично считать 0%.

Сделаем тест, который говорит: на пустом вводе не должно быть паники, и результат должен быть 0.

package todo

import "testing"

func TestProgressPercent_empty_isZero_andDoesNotPanic(t *testing.T) {
	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("unexpected panic: %v", r)
		}
	}()

	got := ProgressPercent(nil)
	if got != 0 {
		t.Fatalf("ProgressPercent(nil)=%d, want 0", got)
	}
}

Здесь ключевой технический момент: recover() работает только внутри defer-функции — это базовая механика Go, и она специально сделана «узким инструментом», а не универсальным try/catch.

В тестах этот приём допустим: мы не лечим бизнес‑логику recover’ом, мы просто делаем тест устойчивым и превращаем «панику» в понятное сообщение о провале.

На баговой реализации этот тест упадёт с unexpected panic: runtime error: integer divide by zero. Отлично: мы получили стабильную воспроизводимость.

Исправляем код минимально и прозрачно

Теперь исправление. В Go очень любят простые guard clauses: «если условие нарушает инвариант — верни быстро».

package todo

func ProgressPercent(tasks []Task) int {
	if len(tasks) == 0 {
		return 0
	}

	done := 0
	for _, t := range tasks {
		if t.Done {
			done++
		}
	}
	return done * 100 / len(tasks)
}

После этого тест станет зелёным. И это главное: теперь если кто-то через месяц «оптимизирует» функцию и случайно уберёт проверку, тест снова загорится красным и остановит вас раньше, чем пользователь увидит падение.

3. Качества хорошего регрессионного теста

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

Хороший регрессионный тест обычно выглядит как маленькая история из трёх частей: входные данные, действие, ожидание. Чем короче и конкретнее эта история, тем лучше.

В нашем примере «пустой список» — идеальный минимальный ввод. Он сразу говорит, какой сценарий защищаем. И ожидание тоже одно: 0% и «не падаем».

Полезно думать о регрессионном тесте как о зафиксированном контракте: «для входа X поведение должно быть Y». Причём контракт — это не обязательно «какой процент должен получиться». Иногда контракт — это «должно вернуть ошибку, а не паниковать», или «должно вернуть (nil, false), а не создавать фейковый объект».

4. Регрессия без panic: логическая ошибка

Не все баги падают. Самые противные — те, что «работают», но дают неверный результат. Для таких багов recover не нужен: нужен тест, который проверяет got/want.

Допустим, мы захотели считать количество выполненных задач и написали функцию. И где-то ошиблись в условии (классика: == вместо !=, или перепутали ветку).

Вот простая функция:

package todo

func DoneCount(tasks []Task) int {
	done := 0
	for _, t := range tasks {
		if t.Done {
			done++
		}
	}
	return done
}

И тест:

package todo

import "testing"

func TestDoneCount_basic(t *testing.T) {
	tasks := []Task{
		{ID: 1, Title: "Read", Done: true},
		{ID: 2, Title: "Write", Done: false},
	}

	if got := DoneCount(tasks); got != 1 {
		t.Fatalf("DoneCount(...)=%d, want 1", got)
	}
}

Если вы однажды починили ошибку в логике подсчёта, такой тест становится регрессионным: он будет охранять именно тот сценарий, который когда-то был сломан.

5. Helper mustNotPanic в тестах

Когда в проекте появляется больше одного теста, который должен проверять «не паниковать», копировать defer + recover везде становится скучно. А скучный код люди рефакторят… иногда неудачно.

Можно сделать маленький helper. Главное — не превращать его в магию и не скрывать смысл.

package todo

import "testing"

func mustNotPanic(t *testing.T, f func()) {
	t.Helper()

	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("unexpected panic: %v", r)
		}
	}()

	f()
}

Теперь тест читается проще:

package todo

import "testing"

func TestProgressPercent_empty_isZero(t *testing.T) {
	mustNotPanic(t, func() {
		got := ProgressPercent(nil)
		if got != 0 {
			t.Fatalf("got %d, want 0", got)
		}
	})
}

Здесь мы используем t.Helper(), чтобы при падении теста Go показал строку вызова в тесте, а не строку внутри helper’а. Это не «обязательная магия», но она делает диагностику приятнее.

6. Что фиксировать тестом: результат, ошибку, отсутствие паники

После исправления бага иногда хочется протестировать «всё и сразу»: и результат, и внутренние поля структуры, и порядок обхода, и формат строки. Но регрессионный тест ценен именно тем, что он защищает существо бага, а не случайные детали реализации.

Удобная краткая таблица, чтобы не промахнуться с тем, что проверять:

Симптом бага Что фиксируем в тесте Почему так
Программа падала с panic «не паниковать» + контракт результата/ошибки Паника — самое опасное, её превращаем в тестовый провал
Возвращалось неверное значение got/want Это чистая логика, никаких recover не нужно
Ошибка была «не того типа/сообщения» сравнение error (или части сообщения) Фиксируем UX/контракт ошибки, а не внутренности
Проблема была в «краевом случае» (пусто, 0, граница индекса) минимальный вход, который воспроизводит кейс Края ломаются чаще всего, тест должен быть максимально простым

7. Как называть регрессионные тесты

Название теста — это не формальность. Через месяц вы забудете детали, а тест останется. Если тест назван TestBugFix — через месяц это «тест чего-то». Если тест назван TestProgressPercent_empty_isZero_andDoesNotPanic — это уже документация поведения.

Хороший стиль для регрессионных тестов: в имени отразить вход и ожидаемое поведение. Не обязательно писать роман, но минимум empty, nil, out_of_range, invalid и doesNotPanic часто спасают.

8. Типичные ошибки

Ошибка №1: «Я исправил — тест не нужен».
Это самый дорогой самообман. Исправление живёт в вашей памяти пару дней, а код живёт годами. Без теста вы не закрепили знание о том, что именно считалось багом, и почему выбранное поведение важно.

Ошибка №2: регрессионный тест слишком большой и проверяет сразу всё.
Когда тест строит огромный набор данных и проверяет пять условий, он перестаёт быть «сигнализацией про конкретный баг». При падении непонятно, какая часть поведения сломалась, и вы тратите время на раскопки вместо быстрого исправления.

Ошибка №3: тест ловит panic, но не проверяет смысл исправления.
Иногда пишут тест вида «лишь бы не упало». Это полезно, но часто недостаточно. Если баг был «деление на ноль», то важно не только отсутствие паники, но и разумный контракт результата (например, 0%). Иначе можно вернуть 42 и формально «не падать».

Ошибка №4: использование recover как способ “починить” бизнес‑логику.
В тесте recover уместен как защитный механизм, превращающий панику в понятный провал теста. Но в обычном коде recover почти никогда не должен быть способом обработки ошибок: в Go принято возвращать error, а recover оставлять для границы приложения. Механика recover специально ограничена deferred‑функциями именно для того, чтобы его не использовали как повседневный try/catch.

Ошибка №5: тест привязан к деталям реализации, а не к контракту.
Если вы тестируете внутренние временные переменные или порядок обхода, то при нормальном рефакторинге тест начнёт «ложно краснеть». Регрессионный тест должен держать договор: вход → выход/ошибка/отсутствие паники, а не внутреннюю кухню функции.

1
Задача
Go SELF, 33 уровень, 4 лекция
Недоступна
Процент прогресса
Процент прогресса
1
Задача
Go SELF, 33 уровень, 4 лекция
Недоступна
Счётчик чётных
Счётчик чётных
1
Задача
Go SELF, 33 уровень, 4 лекция
Недоступна
Возраст из строки
Возраст из строки
1
Задача
Go SELF, 33 уровень, 4 лекция
Недоступна
Удаление без паники
Удаление без паники
1
Опрос
Отладка, 33 уровень, 4 лекция
Недоступен
Отладка
Отладка: stack trace
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ