voidlearn
 

5.5Как писать тестируемый код

Разберём, как сделать код подменяемым и как тестировать контракт так, чтобы тест пережил рефакторинг.

Что сможешь объяснить после этой главы
  • что цикл «красный - зелёный - рефакторинг» проектирует на самом деле и где он мешает;
  • что такое шов, какие швы нужны бэкенд-сервису и чем каждый шов платит;
  • почему тест, который краснеет от рефакторинга без изменения поведения, проверяет не то;
  • с чего начинать правку легаси, у которого тестов нет вообще;
  • какие ветки обязаны быть покрыты в бэкенде и как это звучит в ответе на собеседовании.

TDD: что даёт цикл и где он буксует

TDD (test-driven development, «разработка через тестирование») укладывается в три слова: красный - зелёный - рефакторинг. Сначала пишешь тест на поведение, которого ещё нет, и убеждаешься, что он падает. Потом пишешь минимальный код, чтобы тест позеленел. Потом переставляешь внутренности как нравится, не трогая тест. И заново.

Пользу обычно объясняют покрытием, но покрытие тут побочный эффект. Работает другое: тест пишешь со стороны вызова, и подпись складывается раньше, чем тело. Разница видна на первом же неудобном требовании. Возьмём разбор заголовка Retry-After: в нём бывает либо число секунд, либо HTTP-дата. Первый тест написан до кода и даже не компилируется.

// retry_test.go появился раньше, чем retry.go
func TestRetryAfter_Seconds(t *testing.T) {
    got, ok := Parse("120")
    if !ok || got != 2*time.Minute {
        t.Fatalf("Parse(\"120\") = %v, %v; хотим 2m0s, true", got, ok)
    }
}
$ go test ./...
# retryafter [retryafter.test]
./retry_test.go:9:13: undefined: Parse
FAIL	retryafter [build failed]
FAIL

Ошибка сборки тоже считается красным: тест не прошёл, причина названа. Пишем минимум под этот кейс (strconv.Atoi и умножение на секунду), получаем ok  retryafter  0.318s и берём второй кейс, дату:

func TestRetryAfter_HTTPDate(t *testing.T) {
    got, ok := Parse("Wed, 21 Oct 2026 07:28:00 GMT")
    if !ok || got != 30*time.Second {
        t.Fatalf("Parse(дата) = %v, %v; хотим 30s, true", got, ok)
    }
}
$ go test ./...
--- FAIL: TestRetryAfter_HTTPDate (0.00s)
    retry_test.go:18: Parse(дата) = 0s, false; хотим 30s, true
FAIL
FAIL	retryafter	0.281s
FAIL

Вот здесь цикл отрабатывает то, ради чего его держат. Написать 30*time.Second в ожидании можно, только если знаешь, относительно какого момента считается разница. Тест упирается в вопрос «откуда функция берёт текущее время» раньше, чем ты успел написать внутри time.Now(). Ответ один:

// now параметром - иначе дату не с чем сравнивать
func Parse(h string, now time.Time) (time.Duration, bool) {
    if n, err := strconv.Atoi(h); err == nil {
        if n < 0 {
            return 0, false
        }
        return time.Duration(n) * time.Second, true
    }
    t, err := http.ParseTime(h)
    if err != nil {
        return 0, false
    }
    if d := t.Sub(now); d > 0 {
        return d, true
    }
    return 0, true              // дата в прошлом: повторять можно сразу
}

Параметр now тут не дань тестам, а правильная подпись: разбор заголовка не обязан знать про системные часы. Но заметил это не архитектурный вкус, а падающий тест.

Цикл на примере Parse: что каждый шаг доказывает 1. Красный тест на поведение, которого нет undefined: Parse доказано: тест умеет падать 2. Зелёный минимум кода под этот кейс ok retryafter 0.318s доказано: поведение появилось 3. Рефакторинг внутренности меняешь, тест нет Parse(h, now) доказано: тест держит правку следующий кейс - и снова красный; на втором витке тест потребовал параметр now Где цикл буксует Незнакомая библиотека: пока не увидел, что она возвращает на самом деле, ожидание в тесте - гадание. Исследовательский код: подпись меняется каждые полчаса, и тест переписывается вместе с ней по третьему разу. Чужая схема данных: ожидание берётся из реальной строки в базе, а не из головы. Вёрстка и отчёты: результат проще увидеть глазами и зафиксировать golden-файлом, чем описать заранее.
Красный - зелёный - рефакторинг. Цикл проектирует границу модуля со стороны вызова: на втором витке тест сам потребовал параметр now. Внизу случаи, где писать тест первым дороже, чем сначала посмотреть на поведение.

Границы цикла нарисованы на схеме, и они не выдуманные. Что вернёт платёжный шлюз на просроченную карту - 402 с телом или 200 с полем status: failed? Тест из головы зафиксирует фантазию. Рабочая середина такая. На чистой логике (расчёты, валидация, разбор форматов, правила скидок) пиши тест первым, тут цикл почти всегда окупается. А там, где дело упирается в чужой формат, сначала сходи и посмотри, и только потом пиши тест на увиденное. И отдельно про баги: репорт воспроизводят тестом до починки всегда, пока он вообще воспроизводится в тесте. Это самая дешёвая форма TDD, её применяют даже те, кто цикл не практикует.

Швы: места, куда можно подставить другое

Швом (seam, слово Майкла Физерса из «Working Effectively with Legacy Code», 2004) называют место, где поведение программы меняют, не правя код в этом месте. Если сервис зовёт time.Now() прямо в теле метода, шва там нет: чтобы подставить другое время, придётся редактировать саму строку. Если время приходит полем структуры или аргументом, шов есть, и тест им пользуется.

Интерфейс у потребителя, разобранный в главе 5.2, считается самой известной разновидностью шва, но он не единственный и часто не самый удобный. Есть ещё параметр функции (самый дешёвый: ни типа, ни мока), поле типа функции и готовые интерфейсы стандартной библиотеки: io.Reader, fs.FS, http.RoundTripper. А список того, что шва требует почти всегда, короткий.

ИсточникШовЧто будет без шва
Текущее времяnow func() time.Time или узкий Clock Тест на «через 30 минут» идёт 30 минут либо проверяет не то
Случайность*rand.Rand, io.Reader для crypto/rand Ожидание записать нечем, остаётся «не пусто и ладно»
ИдентификаторыnewID func() string Golden-файл и сравнение структур целиком расходятся на каждом прогоне
Файлыfs.FS параметром, io.Writer для вывода Тест лезет в настоящую файловую систему, тащит t.TempDir и уборку
СетьБазовый URL и *http.Client в конструкторе, ниже - http.RoundTripper Нужен живой сервер или интернет в CI
Текущий пользовательЯвный параметр либо значение из контекста, положенное на границе Каждый тест бизнес-логики поднимает авторизацию
КонфигурацияLoad(env func(string) string) (Config, error) Тестовый бинарь падает при загрузке, до первого теста

Глобальная переменная и init: почему шва там нет физически

Последняя строка таблицы заслуживает прогона, потому что её недооценивают. Пакет читает конфигурацию в переменную уровня пакета, а тест ставит переменную окружения в TestMain. Выглядит разумно.

var DSN = mustEnv("APP_DSN")   // считается при загрузке бинаря; паникует, если пусто

func TestMain(m *testing.M) {
    os.Setenv("APP_DSN", "postgres://test")   // поздно: DSN уже посчитан
    os.Exit(m.Run())
}
$ go test ./config/
panic: config: APP_DSN не задан

goroutine 1 [running]:
seams/config.mustEnv(...)
	/…/seams/config/config.go:11
seams/config.init()
	/…/seams/config/config.go:6 +0xa4
FAIL	seams/config	0.325s
FAIL

Переменные пакета и init() считаются, когда тестовый бинарь ещё только загружается: до TestMain, до t.Setenv, до всего, что ты мог бы написать. В стеке это видно прямо: рантайм показывает инициализатор переменной как config.init(). Подставить сюда нечего, и обходные пути (переменная окружения в скрипте запуска, отдельный файл с билд-тегом) лечат симптом. Речь именно про значение, посчитанное при загрузке. Переменная пакета, в которой лежит функция, подменяется спокойно, шов там есть - просто общий на весь пакет, поэтому с параллельными тестами он не дружит.

Было: шва нет

var DSN = mustEnv("APP_DSN")

// Тест не может ни подставить
// значение, ни поймать ошибку:
// паника летит из init().

Стало: значения приходят параметром

func Load(env func(string) string) (Config, error) {
    dsn := env("APP_DSN")
    if dsn == "" {
        return Config{}, errors.New(
            "config: APP_DSN не задан")
    }
    return Config{DSN: dsn}, nil
}
// прод:  Load(os.Getenv)
// тест:  Load(func(k string) string { return fake[k] })

Заодно исчезла паника: ошибка конфигурации стала обычным error, который main печатает по-человечески. Тест на «не задан DSN» пишется в четыре строки, а раньше его не существовало.

Шов без интерфейса

Когда зависимость состоит из одного метода, интерфейс и сгенерированный мок к нему только добавляют работы. Поле типа функции короче и подменяется замыканием прямо в тесте.

type Service struct {
    now   func() time.Time  // шов: часы
    newID func() string     // шов: идентификаторы
    ttl   time.Duration
}

// Умолчания боевые, поэтому продакшн-код про швы не знает.
func New(ttl time.Duration, opts ...Option) *Service {
    s := &Service{now: time.Now, newID: randomID, ttl: ttl}
    for _, o := range opts {
        o(s)
    }
    return s
}

// В тесте оба недетерминированных поля прибиты, и структура сравнивается целиком:
//   svc := New(30*time.Minute, WithClock(func() time.Time { return at }),
//       WithIDs(func() string { n++; return fmt.Sprintf("bk-%d", n) }))
//   want := Booking{ID: "bk-1", CreatedAt: at, Until: at.Add(30 * time.Minute)}

То же самое с файлами. Функция, которая внутри зовёт os.ReadFile("/etc/app/deny.txt"), тестируется только через настоящий диск: временная директория, запись файла, уборка, права. Переделать это на параметр fs.FS стоит одной строки: в проде приходит os.DirFS("/etc/app"), в тесте - fstest.MapFS{"deny.txt": {Data: ...}}, готовая файловая система в памяти из стандартной библиотеки. Она же отдаёт fs.ErrNotExist на отсутствующий файл, так что ветка обработки ошибки проверяется без всяких chmod.

Шов заводится под тест, который ты собираешься написать

Обратная крайность встречается не реже: интерфейс на каждую структуру, конструктор на восемь параметров, четыре опции, которые никто не передаёт. За это платят на каждом чтении кода. Замер на примере из этой главы: интерфейс из двух методов даёт 71 строку сгенерированного mockgen кода (go.uber.org/mock v0.6.0), плюс лишний переход «интерфейс → реализация» при навигации, плюс файл, который надо перегенерировать при каждой правке подписи. Критерий простой: шов оправдан, если ты прямо сейчас пишешь тест, который в него подставляет другое. Не «когда-нибудь появится вторая реализация», а сейчас. И выбирать стоит самый дешёвый шов из подходящих: параметр и функциональное поле почти бесплатны, а интерфейс с моком уже нет.

Поведение или реализация: что именно проверяет тест

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

Возьмём резерв товара на складе. В первой версии остаток читается дважды - в проверке через checkAvailable и в самом резерве:

func (s *Service) Reserve(ctx context.Context, sku string, n int) error {
    if err := s.checkAvailable(ctx, sku, n); err != nil {   // внутри repo.Get
        return err
    }
    it, err := s.repo.Get(ctx, sku)                         // и ещё раз repo.Get
    if err != nil {
        return fmt.Errorf("резерв %s: %w", sku, err)
    }
    return s.repo.SetReserved(ctx, sku, it.Reserved+n)
}

Два теста на одно и то же поведение. Слева протокол вызовов, справа результат:

Мок: тест знает, как ходят в базу

repo := mocks.NewMockRepo(gomock.NewController(t))

it := stock.Item{SKU: "sku-1", Stock: 10, Reserved: 2}
repo.EXPECT().Get(gomock.Any(), "sku-1").
    Return(it, nil).Times(2)
repo.EXPECT().SetReserved(gomock.Any(), "sku-1", 5).
    Return(nil)

require.NoError(t,
    stock.New(repo).Reserve(ctx, "sku-1", 3))

Фейк: тест знает, что стало со складом

repo := newFake(
    stock.Item{SKU: "sku-1", Stock: 10, Reserved: 2})

require.NoError(t,
    stock.New(repo).Reserve(ctx, "sku-1", 3))

got, _ := repo.Get(ctx, "sku-1")
require.Equal(t, 5, got.Reserved)

Оба зелёные. Теперь обычная правка на ревью: «зачем два одинаковых запроса, прочитай один раз». Убираем checkAvailable, проверку остатка делаем по уже прочитанному it. Поведение снаружи не меняется ни на байт, а прогон выглядит так:

$ go test -count=1 -v ./stock/
=== RUN   TestReserve_Mock
    controller.go:97: missing call(s) to *mocks.MockRepo.Get(is anything, is equal to sku-1 (string)) /…/stock/stock_test.go:22
    controller.go:97: aborting test due to missing call(s)
--- FAIL: TestReserve_Mock (0.00s)
=== RUN   TestReserve_Fake
--- PASS: TestReserve_Fake (0.00s)
=== RUN   TestReserve_NotEnough
--- PASS: TestReserve_NotEnough (0.00s)
FAIL
FAIL	behavior/stock	0.334s
FAIL

Красный тест сообщает «ожидали два вызова Get, случился один». Это правда, но никому не нужная: складу от числа запросов ни холодно ни жарко. Пользователь правки не заметит, а разработчик потратит десять минут на подгон ожиданий. Умножь на двадцать тестов сервиса - получишь команду, которая боится рефакторить.

Как отличить одно от другого, если сомневаешься

Задай тесту вопрос: «какую ошибку в проде этот тест поймает?» Ответ «поймает, что кто-то убрал лишний запрос» выдаёт тест про реализацию. Ответ «поймает, что при нехватке остатка склад всё равно списали» говорит про поведение. Второй вопрос, если первый не помог: переписал бы я этот тест заново, если бы переписал модуль с нуля с той же подписью? Тест про поведение переносится без правок, тест про реализацию придётся выбросить целиком. Проверка вызовов остаётся законной ровно там, где вызов и есть наблюдаемый результат: письмо отправлено, деньги списаны, событие опубликовано в брокер (это разобрано в главе 5.2).

«Надо бы покрыть приватную функцию»

Желание дотянуться тестом до неэкспортируемой функции стоит читать как сигнал, а не как задачу. Сигнал о том, что внутри модуля завёлся кусок логики со своей ответственностью и своими граничными случаями, но без собственного имени в проекте.

Было: тест лезет внутрь

package cart

func (s *Service) Total(o Order) int64 {
    return o.Sum - s.discount(o)
}

// discount - тридцать строк правил скидки
func (s *Service) discount(o Order) int64 { ... }

// cart_internal_test.go, package cart
func TestDiscount(t *testing.T) {
    s := &Service{}
    got := s.discount(Order{Sum: 100000, VIP: true})
    if got != 10000 {
        t.Fatalf("discount = %d", got)
    }
}

Стало: у правил свой пакет

package pricing

type Rules struct {
    VIPPercent  int64
    Coupons     map[string]int64
    PickupBonus int64
}

func (r Rules) Discount(o Order) int64 { ... }

// pricing_test.go, package pricing_test
// Таблица на семь кейсов через публичный API,
// внутренности сервиса корзины не задеты.

Разница не косметическая. Слева тест лежит в файле с package cart, видит неэкспортируемое (про два имени пакета в одной директории читай главу 5.1) и намертво привязан к тому, что правила живут методом сервиса. Переложишь их в отдельный тип - переписывай тест. Справа правила стали значением с явными параметрами, таблица кейсов читается как список требований, а сервису корзины хватает трёх сценариев вместо перебора всех правил: арифметика скидок уехала к своему хозяину. Обратная крайность тоже бывает: приватную функцию на три строки арифметики выносить незачем. Правило на глаз: захотелось для неё таблицу кейсов - у неё своя ответственность; хватает одной проверки заодно с публичным вызовом - пусть остаётся приватной.

Легаси: сначала зафиксировать, потом трогать

Ситуация, которую на собеседовании описывают почти дословно: функция на триста строк, написана четыре года назад, тестов нет, автор уволился, а поправить в ней надо одну строчку. Правильный ответ начинается не с рефакторинга и не с «перепишу с нуля», а с характеризующего теста (characterization test): теста, который фиксирует не то, что код должен делать, а то, что он делает сейчас, включая странное. Пока поведение не записано, любая правка меняет неизвестно что на неизвестно что.

Разберём на чеке. Код печатает строки, считает скидку и итог, а дату берёт из time.Now():

func Receipt(lines []Line, discountPct int) string {
    var b strings.Builder
    fmt.Fprintf(&b, "ЧЕК от %s\n", time.Now().Format("02.01.2006 15:04:05"))

    var sum int64
    for _, l := range lines {
        cost := l.Price * int64(l.Qty)
        sum += cost
        fmt.Fprintf(&b, "%-20s %3d x %8s = %10s\n", l.Name, l.Qty, money(l.Price), money(cost))
    }
    disc := sum * int64(discountPct) / 100
    fmt.Fprintf(&b, "%-20s %28s\n", "скидка", money(disc))
    fmt.Fprintf(&b, "%-20s %28s\n", "ИТОГО", money(sum-disc))
    return b.String()
}

func money(c int64) string { return fmt.Sprintf("%d.%02d", c/100, c%100) }

Первый заход самый очевидный: снять вывод golden-файлом и сравнивать с ним. Снимаем эталон флагом -update, прогоняем тест ещё раз:

$ go test -count=1 -update ./...
PASS
ok  	legacy	0.284s

$ go test -count=1 ./...
--- FAIL: TestReceipt_Golden (0.00s)
    receipt_test.go:29: чек разошёлся с эталоном:
        --- эталон ---
        ЧЕК от 20.09.2026 23:38:09
        кофе                   2 x   199.90 =     399.80
        круассан               1 x    85.00 =      85.00
        скидка                                      48.48
        ИТОГО                                      436.32
        --- сейчас ---
        ЧЕК от 20.09.2026 23:38:16
        кофе                   2 x   199.90 =     399.80
        круассан               1 x    85.00 =      85.00
        скидка                                      48.48
        ИТОГО                                      436.32
FAIL
FAIL	legacy	0.172s
FAIL

Семь секунд между прогонами - и тест красный. Зафиксировать поведение не выйдет, пока в функции сидит time.Now(). Значит, шаг ноль любой работы с легаси: найти недетерминизм и поставить шов. Правка минимальная, подпись для всех вызывающих прежняя:

// Публичная подпись та же: ни один вызов снаружи не тронут.
func Receipt(lines []Line, discountPct int) string {
    return receiptAt(time.Now(), lines, discountPct)
}

func receiptAt(now time.Time, lines []Line, discountPct int) string {
    var b strings.Builder
    fmt.Fprintf(&b, "ЧЕК от %s\n", now.Format("02.01.2006 15:04:05"))
    // дальше тело как было
}

Теперь характеризующий тест. Ожидания берутся не из головы, а из вывода прогона: запускаешь функцию, смотришь, что она напечатала, вставляешь в тест дословно. Кейсы подбираются так, чтобы задеть углы: пустой вход, отрицательные значения, границы.

var fixed = time.Date(2026, 9, 20, 23, 38, 9, 0, time.UTC)

func TestReceipt_Characterize(t *testing.T) {
    tests := []struct {
        name     string
        lines    []Line
        discount int
        want     string
    }{
        // Это не то, чего мы хотим. Это то, что есть. Записываем как есть.
        {
            name:     "возврат: количество -1",
            lines:    []Line{{"кофе", 19990, -1}},
            discount: 0,
            want: "ЧЕК от 20.09.2026 23:38:09\n" +
                "кофе                  -1 x   199.90 =   -199.-90\n" +
                "скидка                                       0.00\n" +
                "ИТОГО                                    -199.-90\n",
        },
        // плюс обычный чек на две позиции со скидкой 10 % и пустой чек со скидкой 20 %
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            if got := receiptAt(fixed, tc.lines, tc.discount); got != tc.want {
                t.Errorf("получили:\n%s\nхотим:\n%s", got, tc.want)
            }
        })
    }
}
$ go test -count=10 ./...
ok  	legacy	0.288s

В ожидании стоит -199.-90. Это настоящий баг - money делит и берёт остаток от отрицательного числа, оба результата выходят отрицательными, и в чек попадает минус в середине суммы. Соблазн починить прямо сейчас велик, и поддаваться ему рано: характеризующий тест не судит, он протоколирует. То, что выглядит багом, может оказаться чьим-то контрактом - отчётом, выгрузкой, соседним сервисом.

С зелёным тестом внутренности переставляются свободно. Разложим одну длинную функцию на три (writeLines, discount, writeTotal) - тесты остаются зелёными, править их не приходится. И только теперь баг: чиним money, выносим знак наружу.

$ go test -count=1 ./...
--- FAIL: TestReceipt_Characterize (0.00s)
    --- FAIL: TestReceipt_Characterize/возврат:_количество_-1 (0.00s)
        receipt_test.go:50: получили:
            ЧЕК от 20.09.2026 23:38:09
            кофе                  -1 x   199.90 =    -199.90
            скидка                                       0.00
            ИТОГО                                     -199.90

            хотим:
            ЧЕК от 20.09.2026 23:38:09
            кофе                  -1 x   199.90 =   -199.-90
            скидка                                       0.00
            ИТОГО                                    -199.-90
FAIL
FAIL	legacy	0.270s
FAIL

Вот это красное запланировано - единственное во всей истории. Оно доказывает, что правка дошла до вывода, и показывает ровно одну строку, которая изменилась. Ожидание правится тем же коммитом, что и код, и ревьюер видит в диффе причину и следствие рядом.

Шесть шагов: порядок важнее скорости 1. Как есть печатает чеки четыре года, тестов нет time.Now() внутри функции трогать страшно: не с чем сверить 2. Эталон плывёт сняли golden, прогнали через семь секунд 23:38:09 → 23:38:16 красное отличие - только время 3. Шов одна правка, вызовы снаружи те же receiptAt(now, lines, pct) вывод стал повторяемым 4. Характеризуем три кейса, ожидания сняты с прогона ИТОГО -199.-90 это баг, но записываем как есть 5. Рефакторинг writeLines, discount, writeTotal go test -count=1 ok тесты не трогали, они зелёные 6. Починка правим money, тест краснеет -199.-90 → -199.90 красное здесь - доказательство Шаги не переставляются: без шва нечего фиксировать, без зафиксированного поведения рефакторинг идёт вслепую. Пункт 6 отделён от пункта 5 специально: одна правка меняет либо структуру кода, либо его поведение, но не оба сразу. Ожидание в тесте правится тем же коммитом, что и код, - тогда в диффе рядом видно и причину, и следствие.
Порядок работы с легаси. Сначала ставят шов против недетерминизма, потом протоколируют текущее поведение вместе со странностями, потом переставляют внутренности под зелёным тестом, и только последним шагом чинят найденный баг.

Две детали, про которые спрашивают чаще всего. Где искать точку для шва: дешевле всего на границе функции - добавить параметр и завести обёртку со старой подписью, как Receipt выше; дифф в три строки, вызывающие не тронуты. Дороже поле структуры, там правится конструктор и все места сборки. Совсем дорого обходятся глобальная переменная и init(): их сначала придётся убрать, и это отдельная задача. Сколько кейсов записывать: хватает того, чтобы задеть все ветки, которые ты собираешься трогать; прогон с -coverprofile показывает, куда тест не дошёл. Это редкий случай, когда покрытие работает по прямому назначению - не как оценка качества, а как карта непройденного.

Шесть способов написать бесполезный тест

Все шесть встречаются в живых репозиториях и все шесть выглядят как работа: тесты есть, покрытие растёт, CI зелёный.

1. Мок на всё подряд

Симптом: пятнадцать строк настройки ожиданий и две строки проверки. Причина обычно архитектурная - сервис тянет шесть зависимостей, и для одного сценария приходится объяснить каждой, как себя вести. Лечит это не библиотека: сервис режут надвое, чистую логику отделяют от оркестрации, и у логики зависимостей не остаётся вовсе. Особняком стоит мок на соседнюю функцию внутри одного пакета: почти всегда признак того, что эти двое слиплись.

2. Тест-зеркало

Ожидание в тесте считается тем же кодом, который тест проверяет. Выглядит аккуратно, даже DRY. Вернёмся к чеку и соберём ожидание теми же fmt.Sprintf и той же money, что и в самой функции:

func TestReceipt_Mirror(t *testing.T) {
    lines := []Line{{"кофе", 19990, -1}}

    want := fmt.Sprintf("ЧЕК от %s\n", fixed.Format("02.01.2006 15:04:05"))
    want += fmt.Sprintf("%-20s %3d x %8s = %10s\n", "кофе", -1, money(19990), money(-19990))
    want += fmt.Sprintf("%-20s %28s\n", "скидка", money(0))
    want += fmt.Sprintf("%-20s %28s\n", "ИТОГО", money(-19990))

    if got := receiptAt(fixed, lines, 0); got != want {
        t.Errorf("получили:\n%s\nхотим:\n%s", got, want)
    }
}
$ go test -count=1 -v ./...          # баг -199.-90 на месте
=== RUN   TestReceipt_Mirror
--- PASS: TestReceipt_Mirror (0.00s)
=== RUN   TestReceipt_Characterize
=== RUN   TestReceipt_Characterize/обычный_чек
=== RUN   TestReceipt_Characterize/возврат:_количество_-1
=== RUN   TestReceipt_Characterize/пустой_чек
--- PASS: TestReceipt_Characterize (0.00s)
    --- PASS: TestReceipt_Characterize/обычный_чек (0.00s)
    --- PASS: TestReceipt_Characterize/возврат:_количество_-1 (0.00s)
    --- PASS: TestReceipt_Characterize/пустой_чек (0.00s)
PASS
ok  	legacy	0.267s

$ go test -count=1 -v ./...          # money починили, вывод изменился
=== RUN   TestReceipt_Mirror
--- PASS: TestReceipt_Mirror (0.00s)
=== RUN   TestReceipt_Characterize
=== RUN   TestReceipt_Characterize/обычный_чек
=== RUN   TestReceipt_Characterize/возврат:_количество_-1
                                     # дальше тот же дифф, что в разделе про легаси
=== RUN   TestReceipt_Characterize/пустой_чек
--- FAIL: TestReceipt_Characterize (0.00s)
    --- PASS: TestReceipt_Characterize/обычный_чек (0.00s)
    --- FAIL: TestReceipt_Characterize/возврат:_количество_-1 (0.00s)
    --- PASS: TestReceipt_Characterize/пустой_чек (0.00s)
FAIL
FAIL	legacy	0.262s
FAIL

Зеркальный тест зелёный в обоих случаях. Он проверяет, что money равно money, и любую правку форматирования пропустит молча. Литеральные ожидания в табличном тесте, наоборот, поймали изменение с первого прогона. Отсюда правило: в ожидании не должно быть вызовов тестируемого кода - ни функций, ни констант формата, ни общих хелперов, которыми пользуется реализация.

Мягкая форма зеркала, которую обычно не замечают

Полное зеркало видно на ревью. А вот его половинки живут годами: ожидаемая строка собирается через ту же константу формата (fmt.Sprintf(tmplOrderID, id)), ожидаемая сумма считается той же функцией округления, ожидаемый JSON собирается тем же json.Marshal по той же структуре. Каждый раз тест теряет способность заметить изменение именно в этом месте, причём выборочно: остальные проверки продолжают работать, и дыра не бросается в глаза. Проверить легко: испорти реализацию нарочно и посмотри, что покраснеет. Это ровно та идея, на которой стоит мутационное тестирование из главы 5.1, только руками и точечно.

3. Один гигантский тест на сценарий

Двести строк: создали пользователя, положили заказ, оплатили, отменили, проверили отчёт. Падает на строке 140 - и ты узнаёшь ровно один факт. Что сломалось дальше, покажет только следующий прогон, после починки. Такой тест ещё и невозможно запустить частично, потому что каждый шаг опирается на предыдущий. Лечение простое: сабтесты через t.Run со своей подготовкой у каждого, а сквозной сценарий целиком оставить одному-двум e2e-тестам (про их количество написано в главе 5.2).

4. Сравнение по одному полю вместо структуры целиком

Пять require.Equal на пять полей вместо одного сравнения. В коде опечатки в двух местах сразу: валюта берётся не из параметра, а скидка вычитается дважды.

// По полю
require.Equal(t, "INV-1", got.Number)
require.Equal(t, "USD", got.Currency)
require.Equal(t, int64(700), got.Total)
require.Equal(t, int64(300), got.Discount)
require.False(t, got.Paid)

// Целиком
want := Invoice{Number: "INV-1", Currency: "USD", Total: 700, Discount: 300}
if diff := cmp.Diff(want, Build(1000, 300, "USD")); diff != "" {
    t.Errorf("Build() разошёлся с ожиданием (-want +got):\n%s", diff)
}
$ go test -count=1 ./invoice/
--- FAIL: TestBuild_ByField (0.00s)
    invoice_test.go:15: 
        	Error Trace:	/…/behavior/invoice/invoice_test.go:15
        	Error:      	Not equal: 
        	            	expected: "USD"
        	            	actual  : "RUB"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -1 +1 @@
        	            	-USD
        	            	+RUB
        	Test:       	TestBuild_ByField
--- FAIL: TestBuild_Whole (0.00s)
    invoice_test.go:26: Build() разошёлся с ожиданием (-want +got):
          invoice.Invoice{
          	Number:   "INV-1",
        - 	Currency: "USD",
        + 	Currency: "RUB",
        - 	Total:    700,
        + 	Total:    400,
          	Discount: 300,
          	Paid:     false,
          }
FAIL
FAIL	behavior/invoice	0.311s
FAIL

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

5. Тесты, зависящие друг от друга

Общая переменная пакета, в которую первый тест кладёт данные, а второй их читает (var shared = &Cart{}, TestAdd наполняет, TestCheckout оформляет). Локально зелено всегда, потому что go test запускает тесты в порядке объявления.

$ go test ./...
ok  	orderdep	0.531s

$ go test -count=1 -shuffle=on ./...
-test.shuffle 1789936374539306000
--- FAIL: TestCheckout (0.00s)
    cart_test.go:17: Checkout: cart: пусто
FAIL
FAIL	orderdep	0.174s
FAIL

Флаг -shuffle=on перемешивает порядок и печатает использованный seed, а seed можно передать обратно и воспроизвести ту же перестановку: go test -shuffle=1789936374539306000. Порядок выбирается случайно и один раз на запуск, поэтому на двух связанных тестах беда вылезает примерно в половине прогонов: один зелёный прогон с перемешиванием ничего не доказывает. Вторая тонкость: строка с seed идёт в обычный вывод пакета, а его go test показывает только у упавшего пакета или под -v. На зелёном прогоне seed не виден, и это сбивает с толку тех, кто ждёт его всегда. Тот же приём ловит и более тонкую связанность: общий контейнер с базой, глобальный реестр метрик, закешированный клиент.

6. «Сначала напишем, потом покроем»

Отдельная задача «покрыть модуль тестами» через две недели после того, как модуль написан, даёт предсказуемый результат: тесты подгоняются под то, как код уже устроен. Швов к этому моменту нет, и вместо них появляются моки на всё подряд и проверки вызовов. Покрытие вырастает, а ошибки тест ловить не начинает. Та же болезнь, что в пункте 1, просто с другого конца: код, написанный без единого теста, почти никогда не оказывается тестируемым случайно.

Сколько тестов достаточно

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

$ go test -count=1 -coverprofile=cover.out ./stock/
ok  	behavior/stock	0.304s	coverage: 100.0% of statements
$ go tool cover -func=cover.out
behavior/stock/stock.go:24:	New		100.0%
behavior/stock/stock.go:27:	Reserve		100.0%
total:				(statements)	100.0%

А теперь пишем тест на отменённый контекст: обрабатывать отмену бэкенд обязан.

func TestReserve_ContextCancelled(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel()

    repo := newFake(stock.Item{SKU: "sku-1", Stock: 10})
    err := stock.New(repo).Reserve(ctx, "sku-1", 3)

    require.ErrorIs(t, err, context.Canceled)
    got, _ := repo.Get(context.Background(), "sku-1")
    require.Equal(t, 0, got.Reserved, "по отменённому контексту склад трогать нельзя")
}
$ go test -count=1 ./stock/
--- FAIL: TestReserve_ContextCancelled (0.00s)
    stock_test.go:87: 
        	Error Trace:	/…/behavior/stock/stock_test.go:87
        	Error:      	Expected error with "context canceled" in chain but got nil.
        	Test:       	TestReserve_ContextCancelled
FAIL
FAIL	behavior/stock	0.293s
FAIL

Сто процентов покрытия - и отмена контекста не обработана вообще. Метрика этого не увидела и увидеть не могла: непокрытой была не строка, а отсутствующая строка. Добавили проверку ctx.Err() и тест к ней, и счётчик показывает те же 100 %. Цифра не изменилась, поведение изменилось. Поэтому в бэкенде ориентируются не на процент, а на список ветвлений, которые обязаны быть проверены у любого кода, пересекающего границу процесса.

Что проверяетсяКак выглядит тестЧто ловит в проде
Ошибка зависимости Фейк или стаб возвращает ошибку; проверяем, что она обёрнута и ловится errors.Is Проглоченную ошибку и «успех», записанный при неудаче
Частичный сбой Первый шаг прошёл, второй упал; проверяем, что состояние не разъехалось Деньги списаны, заказ не помечен оплаченным
Границы Ноль, единица, ровно лимит, лимит плюс один, пустой список, nil Классику < вместо <= и панику на пустом слайсе
Отмена контекста Отменённый ctx на входе и отмена посреди работы Запись в базу после того, как клиент отвалился
Повтор Тот же вызов дважды; проверяем, что второй ничего не испортил Двойное списание при ретрае и при at-least-once доставке
Мусор на входе Не тот тип, битый JSON, отрицательное количество, слишком длинная строка Паники в хендлере и записи, которые потом некому чинить

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

Тестов бывает и слишком много. Признаки узнаваемые: правка в одну строку тянет за собой правку в двенадцати тестах; половина прогона уходит на пакет с тремя функциями логики; в тесте больше кода, чем в том, что он проверяет. Это значит, что тесты привязались к реализации. Выбрасывать половину не надо: разбери их по одному, какой проверяет поведение, а какой протокол.

Откуда эти слова и почему их стоит называть своими именами

Цикл «красный - зелёный - рефакторинг» описал Кент Бек в «Test-Driven Development: By Example» (2002); там же появилась формулировка про минимальный код, который заставляет тест пройти. Термины seam и characterization test пришли из «Working Effectively with Legacy Code» Майкла Физерса (2004), и определение легаси там же: код без тестов, независимо от возраста. Проверку состояния и проверку вызовов (state verification и behavior verification) развёл Мартин Фаулер в «Mocks Aren't Stubs» (2004, переписана в 2007); словарь он там берёт у Джерарда Месароша, а не придумывает свой. Из этой статьи выросла половина споров про моки. На собеседовании эти ссылки работают не как эрудиция: они показывают, что ты отличаешь приём с границами применимости от лозунга.

Вопросы

5
Суть: цикл «красный - зелёный - рефакторинг» нужен не ради покрытия, а ради того, что подпись модуля проектируется со стороны вызова: тест первым натыкается на неудобную зависимость и вынуждает вынести её наружу. Работает там, где ожидаемый результат известен заранее; на незнакомом API и в исследовательском коде дороже, чем полезен.

Что даёт цикл

Три вещи, и покрытия среди них нет. Первая: подпись пишется со стороны вызывающего. Пока функции нет, описать её можно единственным способом: позвать так, как удобно потребителю. Вторая: красный шаг проверяет сам тест. Тест, который ни разу не падал, легко оказывается тестом, который не падает никогда: перепутанный -run, забытый ассерт, проверка на err != nil без содержимого. Третья: рефакторинг идёт под страховкой, критерий «ничего не сломалось» уже записан.

Показательнее всего второй виток. Разбор Retry-After начинается с кейса «число секунд», а на кейсе «HTTP-дата» тест упирается в вопрос: относительно какого момента считать разницу? Записать ожидание нельзя, пока не решено, откуда берётся текущее время. И решение приходит само.

// Было: время внутри, тест на дату написать нечем
func Parse(h string) (time.Duration, bool)

// Стало: тест продиктовал подпись
func Parse(h string, now time.Time) (time.Duration, bool)

Где цикл не работает

  • Незнакомый API. Пока не видел настоящий ответ платёжного шлюза на просроченную карту, ожидание в тесте остаётся выдумкой. Сначала пробный вызов и запись ответа, потом тест на записанном.
  • Исследовательский код. Подпись живёт полчаса, тест переписывается вместе с ней трижды. Проще набросать вариант, выбрать, а потом накрыть тестами то, что осталось.
  • Чужая схема данных. Как лежит NULL, что в jsonb, в каком часовом поясе created_at - всё это выясняется запросом.
  • Вывод без одного правильного ответа: отчёты, письма, вёрстка. Честнее сгенерировать, посмотреть глазами и зафиксировать golden-файлом.

Как отвечать честно

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

Подвох, который любят подкинуть

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

Суть: не с рефакторинга и не с переписывания. Сначала убрать недетерминизм швом, потом записать характеризующими тестами то, что код делает сейчас (включая баги), потом переставлять внутренности под зелёным, и только последним шагом менять поведение - тогда красный тест на этом шаге доказывает, что правка дошла до вывода.

Порядок шагов

  1. Найти недетерминизм. time.Now(), случайные идентификаторы, порядок обхода мапы, чтение файлов, сеть. Пока он внутри, фиксировать поведение нечем: golden-файл, снятый минуту назад, уже не совпадёт.
  2. Вставить шов минимальной правкой. Обёртка со старой подписью плюс внутренняя функция с параметром: вызывающие не тронуты, дифф в три строки.
  3. Записать поведение. Ожидания берутся из вывода прогона, а не из головы. Кейсы берут по углам: пустой вход, отрицательные значения, границы. Странное фиксируется как есть.
  4. Рефакторить. Разбивай, переименовывай, выноси; тесты при этом не трогаются. Тронул тест во время рефакторинга - потерял страховку.
  5. Менять поведение. Отдельным коммитом. Тест краснеет, ожидание правится там же.
// Шаг 2: шов на границе функции. Снаружи подпись прежняя.
func Receipt(lines []Line, discountPct int) string {
    return receiptAt(time.Now(), lines, discountPct)
}

func receiptAt(now time.Time, lines []Line, discountPct int) string { /* тело как было */ }

Почему странное записывают как есть

В примере с чеком характеризующий тест поймал строку -199.-90: функция форматирования денег делила отрицательное число и получала минус в середине суммы. Это настоящий баг, и чинить его в одном заходе с рефакторингом нельзя по двум причинам. Первая техническая: если правка меняет и структуру кода, и его поведение, красный тест перестаёт что-либо доказывать - непонятно, рефакторинг сломал или починка. Вторая практическая: поведение, которое выглядит багом, может быть чьим-то контрактом - отчёт, выгрузка, соседний сервис. Выяснять это надо отдельно, а до выяснения поведение зафиксировано.

Куда вставлять шов, по возрастанию цены: параметр функции плюс обёртка со старой подписью (три строки, вызывающие не тронуты), это вариант по умолчанию; поле структуры с боевым умолчанием в конструкторе, когда зависимость нужна нескольким методам; интерфейс у потребителя для зависимости с состоянием. Глобальная переменная, посчитанная при загрузке, и init() стоят особняком: подставить туда нечего, и сначала придётся убрать саму глобалку.

Чем добить ответ

Назвать определение: легаси - это код без тестов, возраст тут ни при чём (формулировка Майкла Физерса). И добавить границу разумного: если после шага 3 выясняется, что поведение зафиксировать не выходит (функция пишет в четыре глобальные переменные и дёргает сеть), характеризующий тест переносят на уровень выше, на HTTP-хендлер или на consumer очереди целиком. Он будет медленнее, зато шов там есть всегда: у запроса и ответа границы определены.

Суть: такие тесты проверяют реализацию, а не поведение: протокол вызовов вместо результата, приватные функции вместо публичного API, ожидания, собранные самим тестируемым кодом. Лечится переходом на проверку наблюдаемого эффекта - фейк вместо мока, сравнение состояния вместо счёта вызовов.

Три источника хрупкости

  • Мок, фиксирующий протокол. EXPECT().Get(...).Times(2) требует «сходи в базу дважды». Убрали лишний запрос, поведение снаружи то же, тест красный с сообщением missing call(s).
  • Тест на приватную функцию. Он привязан к тому, что логика лежит именно этим методом именно этого типа. Переложил в отдельный тип - переписывай.
  • Ожидание, посчитанное кодом под тестом. Обратная беда: такой тест не краснеет никогда, даже когда должен.

Как выглядит переход

Проверяем протокол

repo.EXPECT().Get(gomock.Any(), "sku-1").
    Return(it, nil).Times(2)
repo.EXPECT().
    SetReserved(gomock.Any(), "sku-1", 5).
    Return(nil)

require.NoError(t, svc.Reserve(ctx, "sku-1", 3))
// Убрали лишний Get - тест красный,
// хотя склад ведёт себя ровно так же.

Проверяем результат

repo := newFake(stock.Item{
    SKU: "sku-1", Stock: 10, Reserved: 2})

require.NoError(t, svc.Reserve(ctx, "sku-1", 3))

got, _ := repo.Get(ctx, "sku-1")
require.Equal(t, 5, got.Reserved)
// Сколько запросов сделал сервис - его дело.

Два вопроса, которые разделяют случаи

Первый: какую ошибку в проде поймает этот тест? Ответ «поймает, что кто-то объединил два запроса» означает тест про реализацию. Второй: переживёт ли тест переписывание модуля с нуля при той же подписи? Тест про поведение переносится без единой правки; тест про реализацию выбрасывается целиком. Проверка вызовов остаётся законной там, где вызов и есть наблюдаемый результат: письмо ушло, деньги списаны, событие опубликовано, кеш инвалидирован (словарь дублёров разобран в главе 5.2). Признак законности простой: эффект нельзя увидеть никак иначе, кроме как зафиксировав сам факт вызова.

Про «покрыть приватную функцию»

Вопрос «как протестировать неэкспортируемую функцию» чаще всего задают с ожиданием ответа про файл package x в той же директории. Технически верно, но сильный ответ другой: если для приватной функции хочется отдельную таблицу кейсов, у неё своя ответственность и ей пора в свой тип или пакет. Тогда она тестируется через публичный API, а тест переживает перекладку кода. А если хватает одной проверки заодно с публичным вызовом, пусть остаётся приватной, выносить её никуда не надо.

Суть: швом называют место, где поведение программы меняется без правки кода в этом месте. В Go их обычно четыре: параметр функции, функциональное поле, интерфейс у потребителя, готовый интерфейс стандартной библиотеки. Обязательные швы в бэкенде - время, случайность, идентификаторы, файлы, сеть, текущий пользователь, конфигурация. Платит шов кодом и лишней косвенностью, поэтому заводится под конкретный тест, а не про запас.

Четыре формы шва

ФормаПримерЦена
Параметр функцииParse(h string, now time.Time)Ноль: ни типа, ни мока
Функциональное полеnow func() time.TimeСтрока в структуре и умолчание в конструкторе
Интерфейс у потребителяtype Repo interface{...}Интерфейс плюс мок: у двух методов это 71 строка генерации
Интерфейс стандартной библиотекиfs.FS, io.Reader, http.RoundTripperНоль: реализации уже написаны

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

Почему глобальная переменная и init убивают тестируемость

Это не преувеличение, а буквальный порядок выполнения: инициализаторы переменных пакета и init() отрабатывают при загрузке тестового бинаря - раньше TestMain, раньше t.Setenv, раньше любого кода теста.

var DSN = mustEnv("APP_DSN")   // паникует, если переменной нет

func TestMain(m *testing.M) {
    os.Setenv("APP_DSN", "postgres://test")   // поздно
    os.Exit(m.Run())
}
$ go test ./config/
panic: config: APP_DSN не задан

goroutine 1 [running]:
seams/config.mustEnv(...)
	/…/seams/config/config.go:11
seams/config.init()
	/…/seams/config/config.go:6 +0xa4
FAIL	seams/config	0.325s
FAIL

Подставить сюда нечего: у значения, посчитанного при загрузке, точки подмены физически не существует. Остаются обходные пути (переменная окружения в скрипте запуска, отдельный файл с билд-тегом), и все они лечат симптом. Чинит это превращение инициализации в функцию с параметром: Load(env func(string) string) (Config, error), в проде Load(os.Getenv), в тесте Load с мапой. Побочная выгода: паника превращается в обычную ошибку, которую main печатает читаемым текстом.

Цена шва, которого никто не подменяет

Обратная крайность стоит дороже, чем кажется: интерфейс на каждую структуру, конструктор на восемь параметров, набор опций, которые никто не передаёт. Каждый такой шов добавляет лишний переход при чтении кода («где же настоящая реализация») и файл, который надо перегенерировать после правки подписи. Критерий: шов оправдан, если прямо сейчас пишется тест, который в него что-то подставляет. Гипотетическая вторая реализация в счёт не идёт: когда она появится, интерфейс выделяется за две минуты.

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

Почему сто процентов ничего не значат

Прямой пример. Метод резервирования покрыт на 100 %: удачный резерв, нехватка остатка, ошибка репозитория. Пишем тест на отменённый контекст:

$ go test -count=1 -coverprofile=cover.out ./stock/
ok  	behavior/stock	0.304s	coverage: 100.0% of statements

$ go test -count=1 ./stock/     # добавили тест на отменённый контекст
--- FAIL: TestReserve_ContextCancelled (0.00s)
    stock_test.go:87: 
        	Error Trace:	/…/behavior/stock/stock_test.go:87
        	Error:      	Expected error with "context canceled" in chain but got nil.
        	Test:       	TestReserve_ContextCancelled
FAIL
FAIL	behavior/stock	0.293s
FAIL

Отмена не обработана вообще, и покрытие этого не показало: непокрытой была не строка, а отсутствующая строка. После добавления проверки ctx.Err() и теста к ней счётчик снова показывает 100 %. Цифра та же, поведение другое. Механику счётчиков разбирает глава 5.1, здесь важен вывод: процент говорит, где точно не смотрели, и больше ни о чём.

Список обязательного

  • Ошибка зависимости. База недоступна, шлюз вернул 503. Проверяем, что ошибка обёрнута через %w и ловится errors.Is, а не проглочена.
  • Частичный сбой. Первый шаг прошёл, второй упал. Самый дорогой класс багов в проде: деньги списаны, заказ не помечен оплаченным.
  • Границы. Ноль, единица, ровно лимит, лимит плюс один, пустой слайс, nil-мапа.
  • Отмена контекста. Отменённый ctx на входе и отмена посреди работы. Ловит запись в базу после того, как клиент отвалился.
  • Повтор. Тот же вызов дважды. Прямая проверка идемпотентности, без которой ретраи и at-least-once доставка превращаются в двойные списания.
  • Мусор на входе. Битый JSON, отрицательное количество, строка вместо числа.

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

Как это звучит в ответе

«Процент держу как индикатор, а не как цель: смотрю на покрытие диффа в мердж-реквесте и на то, чтобы оно не падало. Достаточность определяю по списку ветвлений: ошибка зависимости, частичный сбой, границы, отмена контекста, повтор, мусор на входе. Если все шесть закрыты, мне неважно, 78 там процентов или 91. А если закрыт только удачный путь, то и 100 % меня не успокоят: отсутствующую обработку отмены покрытие не показывает по определению.»

Что стоит запомнить

  • Тест проверяет наблюдаемое поведение.
  • Seam даёт точку подмены там, где код пересекает границу.
  • Для легаси сначала фиксируют текущее поведение.

Частые ошибки

  • Покрытие не доказывает корректность assertion.
  • Не проверяй внутренний порядок без контрактной причины.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Разбери проверку

Какое наблюдаемое поведение подтверждает TestDiscount и переживёт ли проверка замену алгоритма?

func TestDiscount(t *testing.T) {
  got := discount(100, 10)
  if got != 90 { t.Fatal(got) }
}
func discount(price, percent int) int { return price - price*percent/100 }
Ответ и разбор

Для диапазона 100 и скидки 10 тест проверяет наблюдаемое поведение, а не внутреннее устройство функции. При рефакторинге алгоритма тест останется полезным, пока результат тот же.

Код-ревью

2. Найди дефект и исправь

Тест фиксирует внутренний вызов normalize, хотя публичный контракт - результат Service.Run. Какой assertion хрупок и что оставить?

func TestService(t *testing.T) {
  calls := 0
  svc := Service{normalize: func(string) string { calls++; return "x" }}
  got := svc.Run(" X ")
  if calls != 1 { t.Fatalf("normalize called %d times", calls) }
  if got != "x" { t.Fatalf("got %q", got) }
}
Ответ и разбор

Счётчик вызовов normalize закрепляет внутреннее устройство. Удали эту проверку и оставь assertion на публичный результат got == "x". Изменение или объединение внутреннего вызова не должно ломать тест, если контракт результата тот же. Количество вызовов проверяй, только если оно защищает внешний эффект, например одно списание.

Применение

3. Реши сценарий

В легаси функции смешаны нормализация входа, SQL и вычисление итоговой суммы. С чего начать, чтобы безопасно изменить округление?

Ответ и разбор

Сначала зафиксируй текущее поведение characterization tests для репрезентативных входов и границ округления, используя текущую БД только там, где она существенна. Выдели чистое вычисление за узкий seam и напиши тесты на точные суммы до изменения правила. Затем поменяй правило и обнови только тесты, чей контракт изменился.

Применение

4. Проверь альтернативу

Когда стоит добавить ещё тест, если branch coverage уже 100%?

Ответ и разбор

Добавь тест, если существует важный сценарий/инвариант, который текущие assertions не проверяют: граничное округление, повторный запрос, отмена, неверное состояние. Coverage показывает выполнение ветвей, но не качество ожиданий. Если новый тест только повторяет тот же вход и проверку, пользы мало.