«Этот код невозможно нормально протестировать» — почти всегда не жалоба на тесты, а диагноз коду. Тест — первый внешний пользователь вашего API: он приходит со стороны, ничего не знает про внутренности и пытается подставить свои данные. Если ему больно — значит, зависимости спрятаны внутри, состояние глобально, а ответственности перемешаны. Тестопригодность — это свойство дизайна, а не техника тестирования, и лечится она не хитрым мок-фреймворком, а швами.
Разберём четыре рычага: швы (точки подмены), инъекцию недетерминизма (часы, случайность, идентификаторы), функциональное ядро с императивной оболочкой и зависимость от абстракций на границах. Плюс отдельно — как заходить в легаси, который переписывать страшно, а тестов нет: через characterization-тесты. Всё заземлено на живой стенд digital-cookbook/architecture/testing/testable: одна и та же фича до и после введения швов, чистое ядро против оболочки и легаси под «золотым мастером». Go, числа сверены на go1.26.3.
Это парная статья к flaky-тестам: там чинят недетерминизм в уже написанных тестах, здесь — проектируют код так, чтобы недетерминизм был инъецируемым с самого начала.
В статье
- Тестопригодность — свойство дизайна
- Швы (seams)
- Инъекция недетерминизма
- Functional core, imperative shell
- Зависеть от абстракций на границах
- Легаси: characterization-тесты
- Демо и версии
- Документация и первоисточники
Тестопригодность — свойство дизайна
Возьмём типовую функцию: выпустить купон и уведомить пользователя. Написана она «как обычно» — всё нужное берётся прямо на месте.
func IssueBefore(userID string) Coupon {
return Coupon{
ID: fmt.Sprintf("CPN-%06d", rand.IntN(1_000_000)), // случайность внутри
UserID: userID,
IssuedAt: time.Now(), // часы внутри
ExpiresAt: time.Now().Add(couponTTL), // и снова часы
}
}
func NotifyBefore(c Coupon) error {
// адрес — константа, транспорт — net/http напрямую
resp, err := http.Post(notifyURL, "application/x-www-form-urlencoded", body)
// ...
}Формально с кодом «всё нормально»: он работает. Но у него три скрытые зависимости — часы, генератор случайных чисел и сеть. Часы и rand не подменить вообще, не правя функцию; сеть — подменить можно, но только глобально на весь процесс (к этому вернёмся). Посмотрим, что из этого выходит в тесте.
Соблазнительно махнуть рукой: «тут ничего не проверить». Это неправда — и стенд специально выжимает из такого дизайна максимум: точный формат ID и границы времени, снятые вокруг вызова.
func TestIssueBefore_МаксимумВозможного(t *testing.T) {
idFormat := regexp.MustCompile(`^CPN-[0-9]{6}$`)
before := time.Now()
c := IssueBefore("user-1")
after := time.Now()
// 1) ID — точный ФОРМАТ, но не значение: rand внутри функции
if !idFormat.MatchString(c.ID) { /* ... */ }
// 2) время — только ГРАНИЦЫ, снятые вокруг вызова
if c.IssuedAt.Before(before) || c.IssuedAt.After(after) { /* ... */ }
// А вот РАВЕНСТВО ExpiresAt == IssuedAt+TTL проверить нельзя: это два
// РАЗНЫХ вызова time.Now() внутри функции.
}Тест зелёный и вовсе не пустой — но он проверяет свойства, а не значения: форму ID вместо самого ID, диапазон вместо точного времени. Любая регрессия внутри этих рамок — другой купон, сдвиг TTL на секунду — пройдёт незамеченной. И главное: инвариант «ExpiresAt ровно IssuedAt + TTL» здесь недоказуем — не потому, что тест ленив, а потому, что дизайн его не гарантирует: внутри два разных вызова time.Now(), и совпадения их значений никто не обещал.
А что с уведомлением? Здесь напрашивается вывод «NotifyBefore герметично не протестировать» — и это типичная ловушка. Так говорить нельзя. http.Post ходит через глобальный http.DefaultClient, а его Transport подменяем — тест реально перехватывает запрос, не выходя в сеть:
func TestNotifyBefore_ТолькоЧерезГлобальнуюПодмену(t *testing.T) {
// НЕ t.Parallel() — подмена глобальная.
orig := http.DefaultClient.Transport
stub := &stubTransport{}
http.DefaultClient.Transport = stub
t.Cleanup(func() { http.DefaultClient.Transport = orig })
if err := NotifyBefore(Coupon{ID: "CPN-000042", UserID: "user-1"}); err != nil { /* ... */ }
// stub.gotBody содержит to=user-1 и CPN-000042
}Тест проходит. Но посмотрите, ценой чего:
- шов процессный: подмена меняет поведение всего процесса, а не одного объекта; забыли восстановить — поедут чужие тесты;
- нельзя
t.Parallel()— параллельные тесты подрались бы за глобал; - тест опирается на деталь реализации (что
http.Postиспользует именноDefaultClient): переход на свойhttp.Clientвнутри функции молча сломает подмену, хотя поведение не изменится; - URL остаётся константой: адрес можно сверить, но не подставить.
Вот честная формулировка, к которой стоит прийти: не «протестировать невозможно», а «возможно только через хрупкую глобальную подмену». Дизайн не отнимает тест совсем — он делает его слабым (свойства вместо значений) и хрупким (процессные швы вместо локальных). Явный локальный шов даёт то же самое без единого из этих минусов — к нему и перейдём.
Швы (seams)
Шов (термин Майкла Фезерса) — место, где поведение можно подменить, не правя логику вокруг. В Go шов — это маленький интерфейс на границе плюс инъекция зависимости. Три спрятанные зависимости превращаются в три шва:
// Каждый интерфейс описан у ПОТРЕБИТЕЛЯ и содержит ровно то, что нужно.
type Clock interface{ Now() time.Time }
type IDGen interface{ NewID() string }
type Sender interface {
Send(ctx context.Context, to, text string) error
}
type Issuer struct {
Clock Clock
IDs IDGen
Sender Sender
TTL time.Duration
}
func (i Issuer) Issue(ctx context.Context, userID string) (Coupon, error) {
now := i.Clock.Now()
c := Coupon{
ID: i.IDs.NewID(),
UserID: userID,
IssuedAt: now,
ExpiresAt: now.Add(i.TTL), // ОДИН вызов часов — срок точно = IssuedAt+TTL
}
if err := i.Sender.Send(ctx, userID, "Ваш купон "+c.ID); err != nil {
return Coupon{}, fmt.Errorf("отправка уведомления: %w", err)
}
return c, nil
}Логика не изменилась — изменилось, откуда приходят часы, ID и отправка. И тест сразу становится другим: фиксированные часы, предсказуемый генератор, фейк-отправитель, запоминающий вызовы.
base := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
sender := &captureSender{}
iss := Issuer{Clock: fixedClock{base}, IDs: seqIDGen{"CPN-000042"}, Sender: sender, TTL: 24 * time.Hour}
got, _ := iss.Issue(context.Background(), "user-1")
// ID — точное значение, а не префикс
if got.ID != "CPN-000042" { /* ... */ }
// время — точное равенство, без допусков
if !got.ExpiresAt.Equal(base.Add(24 * time.Hour)) { /* ... */ }
// побочный эффект — захвачен, без сети
if sender.calls[0].Text != "Ваш купон CPN-000042" { /* ... */ }Фейки здесь — три коротких типа на пять строк каждый, без мок-фреймворка. Ветка ошибки отправки тоже стала дешёвой: одно поле в фейке. Строго говоря, достать её можно было и раньше — через ту же глобальную подмену Transport, вернув из неё ошибку. Выигрыш нового дизайна не в принципиальной достижимости, а в локальности и простоте: фейк на пять строк вместо возни с процессным глобалом.
Покрытие врёт, а дизайн — нет
Самое интересное — что показывает go tool cover -func по этому пакету (сверено, итог по пакету 86.2%):
| Функция | Покрытие | Что тест реально может |
|---|---|---|
IssueBefore (до) |
100.0% | даже строжайший тест — только формат ^CPN-[0-9]{6}$ и границы времени; инвариант ExpiresAt == IssuedAt+TTL недоказуем |
NotifyBefore (до) |
83.3% | тест есть, но через подмену глобального http.DefaultClient — процессный шов, без t.Parallel(), на детали реализации |
Issue (после) |
100.0% | ID, IssuedAt, ExpiresAt — точным равенством, плюс факт отправки и ветка ошибки; локальные фейки, параллелится |
HTTPSender.Send (адаптер) |
86.7% | контракт: форма, метод, заголовок, ctx, non-2xx → ошибка |
formatCouponID (чистая) |
100.0% | контракт формата вынесен из генератора и проверен детерминированно, на границах |
RandomIDGen.NewID (адаптер) |
100.0% | формат доказан выше; генератору остаётся отдать номер из диапазона через форматтер |
SystemClock.Now |
0.0% | чистое делегирование stdlib (return time.Now()) — своего контракта нет |
У IssueBefore и у Issue — одинаковые 100% покрытия. Но первый утверждает про форму, второй — про значения. Покрытие меряет, что строка исполнилась, а не что тест придирчив; та же мысль, что в mutation-разделе статьи про TDD и BDD. Дизайн улучшает не цифру покрытия, а то, что вообще можно утверждать — и какой ценой (локальный фейк против глобальной подмены).
Адаптер за швом — не «тестировать нечего»
Здесь я сам чуть не оставил в статье опасный совет. Соблазнительно сказать: «за швом остались тонкие адаптеры, логики нет, ломаться нечему — потому и 0%». Проверка показала обратное. Вот первая версия HTTPSender.Send:
resp, err := cl.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil // ← HTTP 500? Тоже nil. Недоставленное считается доставленным.Адаптер молча возвращал nil на любой ответ сервера, включая 500. Это не мелочь: уведомление не ушло, а вызывающий уверен, что всё хорошо. Поймал это контрактный тест, а не ревью:
for _, code := range []int{http.StatusInternalServerError, http.StatusBadRequest, http.StatusTooManyRequests} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
}))
s := HTTPSender{URL: srv.URL, Client: srv.Client()}
if err := s.Send(context.Background(), "user-1", "текст"); err == nil {
t.Errorf("HTTP %d принят за успешную доставку", code)
}
srv.Close()
}Мораль: у адаптера есть свой контракт — кодирование формы, метод, заголовок, проброс ctx, трактовка кода ответа, — и он ломается ровно так же, как любая другая логика. Правильная формулировка не «адаптеры не тестируют», а «адаптерам нужно меньше тестов, чем доменной логике, но не ноль»: короткие контрактные тесты через httptest.Server или подменный RoundTripper — форма, метод, non-2xx, ошибка транспорта, отмена.
Возникает вопрос: а где же ноль тогда честен? Напрашивается критерий «нет ветвлений — нечего тестировать», но и он неверен. Посмотрите на RandomIDGen:
func (RandomIDGen) NewID() string { return fmt.Sprintf("CPN-%06d", rand.IntN(1_000_000)) }Ветвлений ноль. Но он сам решает: префикс CPN-, диапазон, формат %06d с ведущими нулями. Замените %06d на %6d — и потребители, разбирающие ID регуляркой, молча поедут. Никакой if для этого не нужен.
Первым порывом было проверить его выборкой: 500 случайных вызовов, у каждого сверить ^CPN-[0-9]{6}$. Тест не мигает — но ловит ту самую регрессию %06d → %6d лишь вероятностно: она видна только на малых номерах. В статье про детерминированные тесты это так себе образец. Правильный ход — тот же, что и в разделе про чистое ядро: вынести решение из окружения недетерминизма.
// formatCouponID — ЧИСТАЯ функция: весь контракт формата живёт здесь.
func formatCouponID(n int64) string { return fmt.Sprintf("CPN-%06d", n) }
// Генератор своего формата больше не знает: выбирает номер и отдаёт форматтеру.
func (RandomIDGen) NewID() string { return formatCouponID(int64(rand.IntN(idSpace))) }Теперь контракт проверяется на границах и детерминированно — formatCouponID(0) обязан дать CPN-000000, formatCouponID(999_999) → CPN-999999. Сломайте %06d на %6d, и тест падает сразу: formatCouponID(0) = "CPN- 0". Никакой вероятности. Генератору же остаётся проверить лишь то, что он отдаёт номер из договорённого диапазона через тот самый форматтер.
Рабочий критерий, значит, другой: есть ли у адаптера свой контракт, а не есть ли в нём ветвление. SystemClock.Now — это return time.Now(), чистое делегирование stdlib без единого собственного решения: вот здесь ноль честен. Как только адаптер начинает что-то решать сам — формат, кодирование, статусы, таймауты, ретраи, — у него появляется и что ломать.
Инъекция недетерминизма
Три вещи в коде почти всегда стоит инъецировать, потому что они недетерминированы по природе:
- часы —
time.Now(); - случайность —
rand, генераторы ID, UUID; - внешний мир — сеть, БД, файлы, очереди.
Прямой вызов любой из них внутри доменной логики делает функцию нетестируемой точно — и заодно превращается в источник мигания. Инвариант из стенда, недоказуемый в варианте «до», — прямая иллюстрация:
// ExpiresAt ровно = IssuedAt + TTL, при любом TTL
for _, ttl := range []time.Duration{time.Minute, time.Hour, 24 * time.Hour, 30 * 24 * time.Hour} {
iss := Issuer{Clock: fixedClock{base}, IDs: seqIDGen{"X"}, Sender: &captureSender{}, TTL: ttl}
c, _ := iss.Issue(context.Background(), "u")
if !c.ExpiresAt.Equal(c.IssuedAt.Add(ttl)) {
t.Errorf("TTL %v: ExpiresAt != IssuedAt+TTL", ttl)
}
}В варианте «до» требовать точное равенство тест не вправе: IssuedAt и ExpiresAt берутся двумя разными вызовами time.Now(), и совпадение их значений ничем не гарантировано — случайно совпасть они могут, но полагаться на это нельзя. Инъекция часов не просто упростила тест — она сделала инвариант истинным и проверяемым.
Здесь эта статья смыкается с flaky-тестами: зашитые часы и rand — первый и главный источник мигания. Инъекция — не «ради тестов», а ради детерминизма; тестируемость получается побочно.
Functional core, imperative shell
Швы — не единственный рычаг. Часто ещё дешевле вынести логику туда, где зависимостей нет вовсе. Идея Гэри Бернхардта: всю доменную логику — в чистые функции (детерминированные, без побочных эффектов), а всё общение с миром — в тонкую императивную оболочку вокруг.
// Decide — ЧИСТАЯ функция: одни и те же входы всегда дают один и тот же выход.
func Decide(o Order, now time.Time) Decision {
if now.Sub(o.PlacedAt) > staleAfter {
return Decision{Reason: "заказ просрочен для скидки"}
}
var pct int64
switch {
case o.TotalCents >= tier2Cents:
pct = 10
case o.TotalCents >= tier1Cents:
pct = 5
default:
return Decision{Reason: "сумма ниже порога скидки"}
}
d := o.TotalCents * pct / 100
// ...
}У такой функции нет зависимостей — значит, и подменять нечего: тест не требует ни одного дублёра. Правила закрываются обычной таблицей, включая все границы:
{"ниже порога — без скидки", Order{TotalCents: 9_999, PlacedAt: base}, base, Decision{Reason: "сумма ниже порога скидки"}},
{"ровно 100.00 — 5%", Order{TotalCents: 10_000, PlacedAt: base}, base, Decision{DiscountCents: 500, Notify: true, ...}},
{"на копейку ниже 200 — всё ещё 5% (усечение)", Order{TotalCents: 19_999, ...}, base, Decision{DiscountCents: 999, ...}},
{"просрочен — скидки нет", Order{TotalCents: 50_000, PlacedAt: base}, base.Add(31 * 24 * time.Hour), Decision{Reason: "заказ просрочен для скидки"}},Оболочке остаётся проводка: загрузить, спросить ядро, применить побочки.
func (s Service) Process(ctx context.Context, orderID string) (Decision, error) {
o, err := s.Repo.Load(ctx, orderID) // I/O
if err != nil {
return Decision{}, fmt.Errorf("загрузка заказа: %w", err)
}
d := Decide(o, s.Clock.Now()) // ЧИСТОЕ решение — вся логика здесь
if d.DiscountCents > 0 { // I/O
if err := s.Repo.SaveDiscount(ctx, orderID, d.DiscountCents); err != nil {
return Decision{}, fmt.Errorf("сохранение скидки: %w", err)
}
}
// ...
}Экономика получается такая: 7 табличных случаев на ядро без дублёров — и для оболочки тесты на проводку плюс на все три ветки ошибок границ. Ветки эти не взаимозаменяемы, у них разные последствия, и в примере про тестирование границ их стоит закрыть все:
- ошибка
Load— не сделали вообще ничего, побочек нет; - ошибка
SaveDiscount— скидка не сохранена, значит уведомлять о ней нельзя; - ошибка
Notify— скидка уже сохранена: частично применённый эффект, и тест фиксирует это как осознанное поведение, а не случайность.
Ветки правил в оболочке при этом не повторяются — они закрыты в ядре, быстро и точно. Покрытие пакета — 100.0%, и это дешёвые 100%.
Чистоту ядра стенд тоже проверяет: два вызова с теми же входами дают тот же результат, а входной Order не меняется. Строго говоря, это проверка выбранного свойства на выбранных входах, а не доказательство чистоты в математическом смысле — компилятор Go её не гарантирует. Но как страховка от «кто-то добавил в ядро поход в кеш» — работает.
Зависеть от абстракций на границах
Швы работают, только если они в правильных местах. Три правила, которые удерживают от превращения кода в лес интерфейсов.
Маленькие интерфейсы, объявленные у потребителя. Clock — это один метод Now(), а не «модуль работы со временем». В Go интерфейс объявляют там, где его используют, а не рядом с реализацией: потребитель формулирует, что ему нужно, и любой подходящий тип это удовлетворяет неявно. Подробнее про механику и правило «accept interfaces, return structs» — в статье про интерфейсы Go.
Не мокайте то, чем не владеете. Чужой SDK, HTTP-клиент, драйвер очереди — не подменяйте их напрямую: оберните своим интерфейсом на границе. В стенде это Sender: наш интерфейс из одного метода, за которым прячется HTTPSender со всей возней про транспорт, заголовки и контекст. Тест подменяет Sender — маленький, наш, стабильный, — а не пытается изобразить net/http.
Швы — на границах, а не между внутренними функциями. Интерфейс на каждый чих — это шум и индирекция без выгоды: читать код становится труднее, а тесты начинают повторять реализацию (тот самый перекос в сторону мок-стиля, разобранный в TDD и BDD). Практический критерий простой: шов нужен там, где зависимость недетерминирована или уходит за пределы процесса — часы, случайность, сеть, БД, файлы, чужой SDK. Всё остальное — обычные функции и структуры.
У тестопригодности есть цена, и её честно платят: Issuer с четырьмя полями сложнее, чем функция из трёх строк. Эта цена оправдана ровно там, где нужна подмена, — и не оправдана больше нигде. Про разницу мока, стаба и фейка и когда какой уместен — в статье про тестирование across языков.
Легаси: characterization-тесты
Всё вышесказанное хорошо для нового кода. А что делать с легаси, где швов нет, тестов нет, а рефакторить надо? Ответ Фезерса: сначала зафиксировать текущее поведение как есть — characterization-тестом («золотой мастер»), — и только потом трогать код.
В стенде — легаси-печать чека с накопленными за годы особенностями. Тест прогоняет её на наборе входов и сверяет весь вывод с зафиксированным файлом:
// -update перегенерирует golden. Запускать ОСОЗНАННО: только когда изменение
// поведения намеренное. При рефакторинге golden трогать нельзя — в этом весь смысл.
var update = flag.Bool("update", false, "перезаписать testdata/receipt.golden")
func TestFormatReceipt_GoldenMaster(t *testing.T) {
var b strings.Builder
for _, c := range cases {
b.WriteString("### " + c.name + "\n")
b.WriteString(FormatReceipt(c.items, c.paid))
}
// ... сверка с testdata/receipt.golden либо перезапись при -update
}Снятый с исходной версии golden зафиксировал поведение вместе с багами — и это не оплошность, а суть приёма:
### длинное кириллическое имя
=== ЧЕК ===
1) Шокола x2 60.00 ← «Шоколад молочный с фундуком» обрезано по БАЙТАМ:
ИТОГО: 60.00 12 байт = 6 кириллических символов
### усечение скидки
=== ЧЕК ===
1) Набор x1 199.99
СКИДКА: -9.99 ← 999.95 коп. усечено до 999
ИТОГО: 190.00
### недоплата — отрицательная сдача
=== ЧЕК ===
1) Торт x1 300.00
СКИДКА: -15.00
ИТОГО: 285.00
СДАЧА: -275.00 ← при недоплате печатается отрицательная сдачаДальше — рефакторинг под защитой этого файла: печать разложена на formatLine/truncName/DefaultDiscount, появился шов Printer.Discount (правило скидки стало подменяемым), а прежняя точка входа FormatReceipt сохранена — вызывающие ничего не заметили.
// Прежняя точка входа: сигнатура не тронута.
func FormatReceipt(items []Item, paidCents int64) string {
return Printer{Discount: DefaultDiscount}.Format(items, paidCents)
}
// Printer — то, ради чего затевался рефакторинг: правило скидки стало швом.
type Printer struct {
Discount func(totalCents int64) int64
}Запускаем тесты, не трогая golden — зелёный. Теперь у правила скидки есть шов, и его можно менять и тестировать отдельно: Printer с плоской скидкой печатает -10.00, а исторический FormatReceipt по-прежнему -5.00.
Чего golden НЕ доказывает
А вот здесь легко переоценить собственную защиту. Зелёный golden доказывает меньше, чем кажется: он сверяет текущий код с текущим файлом. Если поведение поехало вместе с файлом — скажем, кто-то машинально прогнал -update, увидев красный, — тест останется зелёным и ничего не заметит. Сам по себе он не «доказательство рефакторинга», а лишь фиксатор диффа.
Настоящая проверка — сравнить старую и новую реализации на одном корпусе. Поэтому в стенде исходная версия сохранена дословно (legacy_before.go), а эквивалентность проверяется исполняемым тестом.
Один нюанс, без которого оракул был бы бутафорией: legacy-версия обязана быть самодостаточной. В первом заходе она звала текущую money() — ту же, что и новая версия. Регрессия внутри money одинаково изменила бы обе стороны, и тест эквивалентности остался бы зелёным, ничего не заметив. Поэтому у legacy теперь свой moneyLegacy: дублирование намеренное, оракул сравнивает две независимые ветки, а не две обёртки над одним кодом.
// 1) тот же корпус, что и у golden
for _, c := range cases {
if old, got := formatReceiptLegacy(c.items, c.paid), FormatReceipt(c.items, c.paid); old != got {
t.Errorf("%s: рефакторинг изменил поведение", c.name)
}
}
// 2) 2000 случайных чеков с ФИКСИРОВАННЫМ seed — детерминированно,
// но куда шире шести ручных случаев
rnd := rand.New(rand.NewPCG(42, 1024))
for i := 0; i < 2000; i++ {
// ... случайные позиции, суммы вокруг порога, многобайтовые имена ...
if old, got := formatReceiptLegacy(items, paid), FormatReceipt(items, paid); old != got {
t.Fatalf("расхождение на случайном входе #%d", i)
}
}Вот это уже утверждение с доказательной силой: на 2006 входах старая и новая версии дают побайтово одинаковый результат. Оговорка остаётся и здесь — 2000 случайных чеков сильнее шести ручных, но это по-прежнему проверка на выборке, а не формальное доказательство эквивалентности. В реальном проекте старая версия живёт в истории Git, и такой тест пишут разово на время рефакторинга, а потом удаляют.
Важная оговорка, которую легко упустить: characterization-тест не утверждает, что поведение правильное. Он утверждает, что оно не изменилось. Обрезка по байтам осталась в коде намеренно — с комментарием, что это баг. Починить её байты→руны — отдельный осознанный шаг, который обязан обновить golden: там смена поведения не побочный эффект рефакторинга, а цель. Не путайте эти два режима, и -update не станет привычкой замазывать неожиданные диффы.
Демо и версии
- Код: живой стенд
digital-cookbook/architecture/testing/testable— три подкаталога:beforeafter/(одна фича до и после введения швов),functionalcore/(чистое ядро + тонкая оболочка),characterization/(легаси под золотым мастером). Код в тексте — выжимки. - Как воспроизвести:
go test ./...(весь модуль зелёный),go test -cover ./...(покрытие по пакетам),go test -coverprofile=c.out ./beforeafter/ && go tool cover -func=c.out(та самая таблица: одинаковые 100% у слабогоIssueBeforeи точногоIssue),go test ./characterization/ -run GoldenMaster -update(перегенерация golden — осознанно). Проверено на go1.26.3; только stdlib. - Числа:
beforeafter— 86.2% покрытия операторов по пакету,functionalcore— 100.0%,characterization— 100.0%. Покрытие машинонезависимо, но зависит от версии Go и конкретного кода — снимайте у себя. Отдельно провереноgo test -count=3 -shuffle=on ./...: глобальная подмена в тесте «до» восстанавливается черезt.Cleanupи не течёт в соседние тесты. - Оговорка про доказательность: тесты стенда утверждают контракт для выбранных свойств и сценариев, а не «для всех». «
Issueпроверен точно» значит «точно для ID, времени и факта отправки на этих входах»; эквивалентность рефакторинга проверена на 2006 входах, а не доказана формально. - Смежные статьи: flaky-тесты (парная: детерминизм и инъекция часов), TDD и BDD на практике (Detroit/London, мокать границы, mutation), интерфейсы Go (маленькие интерфейсы, accept interfaces), тестирование across языков (дублёры: мок, стаб, фейк).
Документация и первоисточники
- Michael Feathers, Working Effectively with Legacy Code — откуда термины «шов» (seam) и characterization-тест; канонический разбор захода в код без тестов.
- Gary Bernhardt, Boundaries — доклад, из которого выросла формула «functional core, imperative shell».
- Martin Fowler, Mocks Aren’t Stubs и Inversion of Control Containers and the Dependency Injection pattern — про дублёров и инъекцию зависимостей без карго-культа.
- Go Code Review Comments — Interfaces и Go Proverbs — «accept interfaces, return structs» и почему интерфейс объявляют у потребителя.
Комментарии