Тестирование в разных языках: пирамида, Testcontainers, property-based

Одна дисциплина — пирамида тестов, дублёры, property-based и реальные зависимости через Testcontainers — в Go, Java и Python на одном домене. Что идиоматично, чем различаются экосистемы и почему на настройках по умолчанию property-тест находит реальный дефект ненадёжно во всех трёх сразу

Тестирование везде решает одну задачу: набрать свидетельства, что код делает обещанное и не разваливается при изменениях. Дисциплина тоже одна — пирамида, дублёры, property-based для инвариантов, реальные зависимости в контейнере. Меняются имена: go test и JUnit, pytest и ScalaTest, GoogleTest и Vitest. А вопросы «что подменять, а что поднимать по-настоящему», «сколько e2e достаточно», «почему тест зелёный, хотя баг есть» — одинаковы.

Это статья серии «Поперёк языков»: одна задача, разные языки. Она синтезирует по-языковые статьи о тестировании, поэтому местами будет плотно — за деталями идите по ссылкам.

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

Схема «Полдень»: три измерительных прибора в ряд — бирюзовый, охряный, кирпичный (rapid, jqwik, Hypothesis) — стоят под общей шкалой калибровки и своими стрелками показывают на одну и ту же зону; сверху технический чертёж ступенчатой функции скидки по тирам, и красным кружком обведён край ступени — тот самый скачок, на котором цена перестаёт быть монотонной: заплатить больше выходит дешевле

В статье

Стенд: один домен на три языка

Домен нарочно скучный — объёмная скидка по тирам:

сумма заказа скидка
до 100.00 нет
от 100.00 5%
от 200.00 10%

Всё в копейках, целыми числами. На Go это выглядит так:

const (
	Tier1Cents = 10_000
	Tier2Cents = 20_000
)

func Discount(totalCents int64) int64 {
	switch {
	case totalCents >= Tier2Cents:
		return totalCents * 10 / 100
	case totalCents >= Tier1Cents:
		return totalCents * 5 / 100
	default:
		return 0
	}
}

func Price(totalCents int64) int64 {
	return totalCents - Discount(totalCents)
}

Тот же код — один в один по смыслу — повторён в Pricing.java и pricing.py (там, где в Go switch, в Java и Python пара if). Сверху — тонкий сервис: посчитать цену, сохранить заказ в хранилище, отправить уведомление. Две границы (хранилище и уведомления) дают место для дублёров, Postgres — для интеграционного теста.

Четыре приёма, одинаковые во всех трёх языках: табличный юнит-тест, дублёры, property-based, Testcontainers. Весь код — в стенде architecture/testing/across-languages.

Оговорка про листинги ниже: кое-где они сокращены — опущена обработка ошибок и часть проверок, чтобы не заслонять суть. В стенде всё на месте, и все команды из статьи запускаются как есть.

Пирамида — не закон, а следствие цены

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

Всё так, но формулировка «пирамида — правильная форма» вводит в заблуждение, потому что прячет причину. Пирамида — не эстетический идеал и не закон природы. Это следствие цены обратной связи.

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

Точных секунд я тут намеренно не привожу, и вот почему. Пытаясь свести их в аккуратную таблицу «Go/Java/Python», я обнаружил, что мерил не языки, а окружение: один и тот же Python-набор показал 0.33 с на нативной файловой системе и 4.13 с при запуске с примонтированного диска — разница в двенадцать раз, и к Python она отношения не имеет. У интеграционных та же история: 4.0 с против 7.8 с. Публиковать после этого «Python быстрее Java в столько-то раз» было бы враньём с точностью до второго знака.

Что уцелело: во всех прогонах интеграционный набор оставался дороже юнитового, а платой была не проверка, а старт контейнера. Но коэффициент гуляет вслед за окружением: у Python это 12.1x на нативной ФС (0.33 с против 4.00 с) и всего 1.9x на примонтированном диске (4.13 с против 7.77 с) — те же тесты, та же машина. Так что «разрыв на порядок» я обещать не стану: у вас он будет свой. Если хотите свои числа — в стенде лежит timing-matrix.sh: медиана пяти прогонов, время по отчёту раннера, прогрев, -count=1 у Go (иначе он отдаст закэшированный результат, и вы намеряете скорость чтения кэша).

Дальше — простая арифметика внимания. Тест, который стоит 20 миллисекунд, можно гонять на каждое сохранение файла; набор, который стартует пять секунд, — уже нет. Форма пирамиды получается сама, если гнаться за быстрой обратной связью, а не рисовать треугольник ради треугольника.

Оговорка, без которой «плата за старт» звучит как приговор: она не неустранима. Testcontainers умеет переиспользовать контейнер между прогонами, а в CI его нередко поднимают один раз на всю сборку. Это цена конкретного сценария — свежий контейнер на пакет, — а не свойство мироздания.

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

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

Дублёры: мок, стаб, фейк

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

  • Дамми — заглушка, которую передают только чтобы заполнить аргумент.
  • Стаб отдаёт заготовленный ответ. Ничего не проверяет — он декорация.
  • Шпион (spy) записывает вызовы, а проверяет их потом сам тест: «хранилище позвали один раз».
  • Мок заранее заряжен ожиданиями и проверяет их сам, падая прямо в момент неверного вызова.
  • Фейк — рабочая реализация, только упрощённая: список в памяти вместо Postgres.

Оговорюсь сразу, потому что дальше это важно. Строго по Месарошу объект ниже — шпион, а не мок: он копит SaveCalls, а утверждение делает тест. Я всё же зову его моком, следуя тому, как слово прижилось: mock() + verify() в Mockito и MagicMock + assert_called_once() в Python устроены ровно так же — это шпионы по механике и «моки» по названию. Фаулер в «Mocks Aren’t Stubs» разбирает и таксономию, и то, почему на практике она размылась. Для нашей истории существенна не буква терминов, а водораздел: проверять вызовы или проверять результат.

Проверим не определениями, а делом. В сервис под флагом подсунут настоящий дефект — скидка теряется, клиент платит полную сумму:

price := pricing.Price(totalCents)
if discountBug {
	price = totalCents // БАГ (только под тегом bug): скидка потеряна
}

Тест на фейке говорит о результате — сохраняет заказ и читает его обратно:

store := &FakeStore{}
svc := OrderService{Store: store, Notifier: StubNotifier{}}
svc.Create(context.Background(), "o-1", "u-1", 10_000)

got, _ := store.ByUser(context.Background(), "u-1")
if got[0].PriceCent != 9_500 {
	t.Errorf("к оплате %d коп., ожидали 9500 (100.00 минус 5%%)", got[0].PriceCent)
}

Тест на вызовах — так, как проверку вызовов пишут чаще всего:

store := &MockStore{}
svc := OrderService{Store: store, Notifier: StubNotifier{}}
svc.Create(context.Background(), "o-1", "u-1", 10_000)

if store.SaveCalls != 1 {
	t.Errorf("Save позвали %d раз, ожидали 1", store.SaveCalls)
}

Включаем баг:

--- FAIL: TestНаФейке_ЗаказСохранёнСоСкидкой
    doubles_test.go:82: к оплате 10000 коп., ожидали 9500 (100.00 минус 5%)
--- PASS: TestНаМоке_СлабаяПроверка_ТолькоЧислоВызовов

В Java (mvn test -Ddiscount.bug=true) и Python (DISCOUNT_BUG=1 pytest) — ровно то же самое.

И вот здесь надо остановиться, потому что напрашивается вывод «фейк умеет, мок не умеет», а он неверен. Достаточно захватить аргумент — и мок ловит тот же дефект:

if store.Saved[0].PriceCent != 9_500 {
	t.Errorf("в хранилище понесли цену %d коп., ожидали 9500 (100.00 минус 5%%)",
		store.Saved[0].PriceCent)
}
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
verify(store).save(captor.capture());
assertEquals(9_500, captor.getValue().priceCent(), "в хранилище понесли цену: 100.00 минус 5%");
store.save.assert_called_once()
(saved,), _ = store.save.call_args
assert saved.price_cent == 9_500, "в хранилище понесли цену: 100.00 минус 5%"

Теперь под тем же флагом падают оба — и фейк, и мок с захватом, а зелёным остаётся только слабый:

--- FAIL: TestНаФейке_ЗаказСохранёнСоСкидкой
--- PASS: TestНаМоке_СлабаяПроверка_ТолькоЧислоВызовов
--- FAIL: TestНаМоке_СильнаяПроверка_ЗахватАргумента

Так что демонстрация доказывает не то, что просилось на язык. Она доказывает, что слабое утверждение пропускает дефект — и неважно, на каком дублёре оно написано. «Save позвали один раз» — осмысленная проверка (так ловят потерянную или задублированную запись), но про цену она не знает ничего, а сломалась именно цена.

Тогда в чём же разница между фейком и моком, если ловят оба? Не в силе, а в предмете. Фейк утверждает про результат: заказ сохранён и читается обратно со скидкой. Мок утверждает про вызов: мы понесли в порт правильные данные.

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

Здесь языки различаются не возможностями, а сопротивлением материала. В Go дублёра надо написать руками — десяток строк, и по дороге успеваешь подумать, что именно проверяешь. В Java есть Mockito, в Python — unittest.mock, и мок пишется в одну строку:

Store store = mock(Store.class);
store = MagicMock()

Это удобно — и поэтому опасно. Соблазн мокнуть всё подряд там, где дублёр бесплатен, заметно сильнее; в Python unittest.mock вдобавок умеет подменять что угодно по имени, без всяких интерфейсов. Чем дешевле мок, тем чаще тест незаметно становится зеркалом реализации. Подробнее про эту болезнь — в статье «Тест-смеллы», а про то, как расставлять границы в коде, — в «Как писать тестируемый код».

Property-based: генератор решает всё

Идея одна во всех языках: вместо примеров, выбранных человеком, — генератор случаев, инвариант, который обязан держаться на любом входе, и шринкинг — ужимание найденного контрпримера до минимального. Шринкинг и отличает property-based от «накидаем рандома»: без него вы получаете падение на семизначном числе и полдня разбирательств.

Инвариант нашего домена: скидка неотрицательна и не больше суммы.

func TestProperty_DiscountВРазумныхГраницах(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		total := rapid.Int64Range(0, 1_000_000_000).Draw(t, "total")

		d := Discount(total)
		if d < 0 {
			t.Fatalf("скидка отрицательна: Discount(%d) = %d", total, d)
		}
		if d > total {
			t.Fatalf("скидка больше суммы заказа: Discount(%d) = %d", total, d)
		}
	})
}

Держится. И тут стоит сразу быть точным — статья дальше про это и будет. Свойство квантифицировано над всем диапазоном: оно заявляет «для любого total от 0 до миллиарда». Но движок не перебирает миллиард входов — он берёт выборку (по умолчанию сто значений) и не находит контрпримера. Табличный тест говорит про шесть точек и знает про них всё; property говорит про весь диапазон, но проверяет выборку из него. Это не мелочь: разница между «утверждаю» и «проверил» — ровно то место, где дальше всё и сломается.

Теперь свойство поинтереснее: заказ на большую сумму не может стоить дешевле. Звучит как очевидная истина. Но у тиров есть скачок: на 99.99 скидки нет — платим 99.99; на 100.00 сразу 5% — платим 95.00. Заплатить больше выходит дешевле.

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

func monotonic(t *rapid.T, gen *rapid.Generator[int64]) {
	a := gen.Draw(t, "a")
	b := gen.Draw(t, "b")
	if a > b {
		a, b = b, a
	}
	if Price(a) > Price(b) {
		t.Fatalf("заказ на %d стоит %d, а на %d — %d: заплатить больше оказалось дешевле",
			a, Price(a), b, Price(b))
	}
}

Запускаем на широком диапазоне Int64Range(0, 30_000). И property-тест — тот самый, который «проверяет сотни случаев вместо ваших шести» — проходит. Нарушение есть, тест зелёный.

Не случайность и не особенность Go. Вот вся таблица — её воспроизводит скрипт property-matrix.sh из стенда, чистя кэш находок перед каждым прогоном:

движок наивный, бюджет по умолчанию наивный, 20 000 примеров
rapid 1.3.0, Go (дефолт 100) 0 из 20 5 из 5
jqwik 1.9.3, Java (дефолт 1000) 5 из 40 4 из 5
Hypothesis 6.156.6, Python (дефолт 100) 0 из 20 5 из 5

Строка jqwik выбивается, и объяснение напрашивается: у него дефолтный бюджет вдесятеро больше — тысяча попыток против сотни. Чтобы проверить догадку, а не поверить в неё, я прогнал контроль на равных бюджетах:

движок @100 @1000 @20 000
rapid 0 из 20 0 из 20 5 из 5
jqwik 0 из 20 5 из 40 4 из 5
Hypothesis 0 из 20 0 из 20 5 из 5

На сотне примеров все три дали ноль из двадцати. Соблазн велик — объявить, что разницу дефолтов объясняет бюджет, и закрыть вопрос. Но так рассуждать нельзя: нулевая выборка не доказывает равенства. «Ноль из двадцати» у трёх движков означает лишь, что у каждого верхняя граница двустороннего 95% интервала — около 17% (по Клопперу–Пирсону), а не то, что они равны между собой.

На тысяче тоже не легче. jqwik даёт 5 из 40, rapid и Hypothesis — 0 из 20, и напрашивается «значит, генератор jqwik лучше добирается до нужной полосы». Проверим арифметикой: 5 из 40 — это 12.5% с интервалом [4.2%, 26.9%], а 0 из 20 — [0%, 16.9%]. Интервалы перекрываются. Серия из двадцати прогонов слишком коротка, чтобы различить движки.

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

И главный, скучный, зато прочный итог: на настройках по умолчанию наивное свойство находит реальный дефект ненадёжно во всех трёх экосистемах. Лучший результат — примерно один раз из восьми.

«0 из 20» при этом — не «никогда», а «редко»: тот же интервал [0%, 16.9%] и означает, что вероятность находки за прогон может доходить до одной шестой. Правильная формулировка — не «property-тест не находит», а «находит ненадёжно». Разница существенная: тест, срабатывающий раз в восемь прогонов, не бесполезен — на дистанции, всей командой, он однажды выстрелит и укажет на настоящий дефект. Но как страж в CI он не годится: пропускает чаще, чем ловит, а когда ловит — выглядит миганием, и первым желанием будет его заглушить.

Причина не в том, что движки плохи, а в том, что нарушение занимает узкую полосу входов: нужна пара «a чуть ниже порога, b сразу за ним». Разумный движок не сыплет равномерным шумом — он смещает выдачу к малым числам и краям, и в узкую полосу попадает редко. Инструментировал я, впрочем, только rapid: в стенде есть отдельный замер на 200 000 черновиков из rapid.Int64Range(0, 30_000), безо всяких утверждений — просто посмотреть, что выпадает.

черновиков=200000
в полосе [9500..10500]: 1706 (0.85%) — при равномерном было бы ~3.3%
>= 10000 (порог 1-го тира): 34178 (17.1%) — при равномерном было бы ~66.7%

Семнадцать процентов вместо шестидесяти семи. В интересную полосу попадает меньше процента черновиков — а нужно, чтобы туда попали оба числа пары, да ещё правильным образом. (Это выборка, от прогона к прогону проценты гуляют в пределах десятых.)

Честная граница этого объяснения: распределение я мерил у rapid, а у jqwik и Hypothesis — только частоту находок. Что они смещают выдачу так же, я не проверял; совпадение результатов на это намекает, но намёк — не замер.

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

nearTiers := rapid.OneOf(
	rapid.Int64Range(0, 30_000),
	rapid.Int64Range(Tier1Cents-500, Tier1Cents+500),
	rapid.Int64Range(Tier2Cents-500, Tier2Cents+500),
)

С ним находят все три, на бюджете по умолчанию: rapid — 5 из 5, падая на 2–31-м примере; jqwik — 5 из 5 (счётчик попыток я снял в двух прогонах: 7 и 26); Hypothesis — 5 из 5. Из «пропустили все три» получилось «нашли все три» — сменой генератора, не сменой библиотеки.

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

rapid:      заказ на 9501 стоит 9501, а на 10000 — 9500      (9501 ×2, 19500 ×3)
jqwik:      Shrunk Sample (5 steps)  arg0: 19500  arg1: 20000  (19500, 9501, 18948 — вразнобой)
Hypothesis: Falsifying example: a=9501, b=10000               (9501 ×5)

Истинный минимум здесь один — 9501 → 10000: платим 95.01 — отдаём 95.01; платим 100.00 — отдаём 95.00. До него в каждом прогоне доходил только Hypothesis; rapid добирался в двух из пяти, jqwik — вразнобой.

Откуда же 19500, если 9501 движку вполне доступно — полный диапазон 0..30000 входит в наш OneOf? Механизм я объяснять не возьмусь: чтобы утверждать, почему шринкер остановился здесь, нужно трассировать сам шринкер, а я этого не делал. Соблазн был — написать красиво про «застрял в своей ветке генератора», — но это была бы догадка, выданная за причину, и для Hypothesis она заведомо неверна: его one_of тяготеет к ранним стратегиям, а переходы между ветками при шринкинге в нём отдельно дорабатывали.

Что можно утверждать — это наблюдение: 19500 заметно больше 9501, а значит минимальность ужатого контрпримера локальна. Ужатый пример короткий, читаемый и попадает в нужную окрестность — но глобально наименьшим он не обязан быть, и в наших прогонах чаще всего им и не был. Практический вывод от механизма не зависит: шринкинг превращает случайный семизначный мусор в число, на которое можно смотреть, — но не выдавайте его за «самый маленький из возможных».

Табличный тест такую пару не поймал бы — и не потому, что человек не думает про пороги: в нашей же таблице стоят и 9_999, и 19_999. Про точки у порога человек подумал. А про пару, которая эти точки связывает, — нет. Это и есть водораздел: таблица проверяет значения, свойство — отношения между ними.

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

Отдельная деталь, на которой легко обмануть самого себя при замерах: все три движка кэшируют найденные падения и переигрывают их первыми — rapid в testdata/rapid, jqwik в .jqwik-database, Hypothesis в .hypothesis. Это отличная штука в работе (упавший пример не потеряется) и ловушка при измерении: не почистив кэш, вы намеряете не «нашёл заново», а «помнил с прошлого раза». Меня она поймала — первые числа пришлось выбросить.

Testcontainers — общий знаменатель

Дублёр не знает того, чего не знает его автор. Фейк-хранилище на списке в памяти радостно примет второй заказ с тем же ID — а настоящий Postgres ответит нарушением первичного ключа. Поэтому в какой-то момент нужен не дублёр, а зависимость.

Testcontainers — общая идея на все экосистемы: поднять настоящий Postgres, Kafka, Redis в контейнере на время теста. Все три части стенда поднимают один и тот же postgres:18.1-alpine и проверяют одно и то же.

pg, err := tcpg.Run(ctx, "postgres:18.1-alpine",
	tcpg.WithDatabase("orders"),
	tcpg.WithUsername("test"),
	tcpg.WithPassword("test"),
	testcontainers.WithWaitStrategy(
		wait.ForLog("database system is ready to accept connections").
			WithOccurrence(2).WithStartupTimeout(60*time.Second)),
)
@Container
static final PostgreSQLContainer<?> PG = new PostgreSQLContainer<>("postgres:18.1-alpine")
        .withDatabaseName("orders")
        .withUsername("test")
        .withPassword("test");
@pytest.fixture(scope="module")
def dsn():
    with PostgresContainer("postgres:18.1-alpine", driver=None) as pg:
        ...

Тест, который фейком не написать вовсе:

if err := st.Save(ctx, o); err != nil {
	t.Fatalf("первый Save: %v", err)
}
if err := st.Save(ctx, o); err == nil {
	t.Fatal("повторный Save с тем же ID прошёл — первичный ключ не работает")
}

Идея общая — а вот умолчания у клиентов разные, и на этом стенде шов разошёлся ровно посередине. Testcontainers Java 1.21.3 на dockerd 29.6.1 из коробки не стартует вовсе:

Could not find a valid Docker environment.
  UnixSocketClientProviderStrategy: failed with exception BadRequestException
  (Status 400: client version 1.32 is too old.
   Minimum supported API version is 1.40, please upgrade your client)

Docker 29 убрал поддержку API ниже 1.40, а docker-java по умолчанию говорит на 1.32. Клиенты Go (testcontainers-go 0.43.0) и Python (testcontainers 4.14.2) на том же демоне работают молча и сразу.

Лечится это одной строкой — надо сказать docker-java, на какой версии API говорить. В стенде она стоит прямо в pom.xml, поэтому Java-часть заводится на Docker 29 без всяких плясок:

<argLine>-javaagent:${org.mockito:mockito-core:jar} -Dapi.version=1.40</argLine>

(Свойство api.version — из docker-java; его же можно положить в ~/.docker-java.properties. А вот переменная DOCKER_API_VERSION, которую подсказывает интуиция, — не тот рычаг: docker-java её так не читает, и с ней конфигурация не собирается вовсе. Версию 1.40 понимают и старые демоны, начиная с Docker 19.03, так что флаг безопасно оставить насовсем.)

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

Ещё одна деталь оттуда же, и она стоила мне получаса: без биндинга SLF4J Testcontainers молча проглатывает собственную диагностику. Сообщение «Please see logs» есть, логов нет. Стоило добавить slf4j-simple — и причина назвалась сама, первой же строкой. Если Testcontainers на Java «не видит Docker» — первым делом включите логи, а не переставляйте демон.

Цена интеграционного теста, по замерам стенда. Все три — на одном демоне (Docker 29.6.1) и в одинаковой манере: один контейнер на пакет, изоляция через TRUNCATE:

клиент 2 теста
Go testcontainers-go 0.43.0 6.4 с
Java Testcontainers 1.21.3 4.5 с
Python testcontainers 4.14.2 3.9 с

Секунды похожи, и это ожидаемо: почти всё в них — старт контейнера, то есть работа демона, а не клиента. Разброс между языками тут — шум окружения (Go ходит к Docker Desktop с Windows-хоста, Java и Python — из WSL), а не свойство библиотек.

Важнее другое число. Пока контейнер поднимался на каждый тест, Go-часть занимала 25.5 секунды вместо 6.4 — вчетверо больше на тех же двух проверках. Старт контейнера дороже самой проверки на порядки, поэтому его поднимают один на пакет, а тесты изолируют TRUNCATE-ом или транзакцией с откатом. Это ровно тот случай, когда правильная практика видна не в совете, а в секундомере: я сам сначала написал стенд «по контейнеру на тест» — и заплатил четырёхкратной ценой.

Когда интеграционный тест оправдан? Когда проверяемое поведение живёт не в вашем коде, а на стыке: SQL, схема, ограничения, миграции, транзакции, сериализация. Проверять фейком, что «INSERT вызвался», — самообман: вы проверили свой дублёр. Подробнее про подготовку данных для таких тестов — в «Тестовые данные».

По языкам

Живьём на этом стенде проверены три языка — Go, Java, Python. Остальные строки таблицы — карта инструментов, а не результат замеров; по каждому есть (или будет) отдельная статья, где всё вживую. Реализации Testcontainers названы по проектам, а не по ярлыку «официальная»: они живут в одной организации, но степень поддержки у них разная, и делить их на официальные и community по памяти я не берусь.

язык раннер property-based Testcontainers дублёры
Go testing, table-driven rapid, gopter testcontainers-go руками, go.uber.org/mock
Java JUnit 5 jqwik testcontainers-java (самый старый) Mockito
Python pytest Hypothesis testcontainers-python unittest.mock
Rust cargo test proptest, quickcheck testcontainers-rs mockall
C# xUnit, NUnit FsCheck, CsCheck testcontainers-dotnet Moq, NSubstitute
Node Vitest, Jest fast-check testcontainers-node встроено в раннер
Scala ScalaTest, MUnit, Weaver ScalaCheck testcontainers-scala (обёртка над Java) mockito-scala
C++ GoogleTest, Catch2 RapidCheck testcontainers-native (C/C++/Swift, обёртка над Go-клиентом) GoogleMock

Что общего у всех языков:

  • Раннер везде плюс-минус одинаков. Разница в идиоме подачи примеров: в Go это литерал слайса структур, в Java — @ParameterizedTest с @CsvSource, в Python — @pytest.mark.parametrize. Суть одна, спорить не о чем.
  • Property-based есть везде и работает одинаково — включая одинаковые ловушки с генератором, которые мы видели выше. Родословная общая: жанр начался с QuickCheck на Haskell, ScalaCheck — его прямой потомок, остальные наследуют ту же модель «генератор + инвариант + шринкинг». У Hypothesis, пожалуй, лучшая в жанре документация — её стоит читать, даже если пишете не на Python.
  • Testcontainers родом из Java и там же наиболее оброс модулями — что не помешало именно java-клиенту сломаться о Docker 29, пока Go и Python работали. Возраст и богатство модулей не равны свежести транспорта. Для C/C++ отдельного клиента долго не было; сейчас в той же организации живёт testcontainers-native — C-обёртка поверх Go-клиента, пока уровня MVP.
  • Моки — самое заметное на этом стенде место, где язык влияет на дизайн теста: чем дешевле мок, тем больше моков. Go со своим «напиши дублёра руками» получает меньше моков не из-за культуры, а из-за трения. Влияет, конечно, не только это — система типов, видимость, модель модулей, жизненный цикл раннера тоже, — но остальное на нашей задаче в глаза не бросалось.

За деталями — в по-языковые статьи: «Тестирование в Go» (там же про -race и table-driven), и по мере выхода — остальные из серий deep-dive.

Как выбирать глубину

Ни один из четырёх приёмов не обязателен — обязателен осознанный выбор. Ориентир простой: глубина по цене ошибки, а не по моде.

  • Табличный юнит — по умолчанию для всего, что считает. Дёшево, быстро, читаемо. Начинать здесь.
  • Property-based — там, где есть инвариант: деньги, округления, сериализация-десериализация (parse(render(x)) == x), сортировки, кэши, конвертеры. Если инвариант сформулировать не выходит — приём не ваш, и это нормально. Помните про генератор: свойство без досягаемого генератора — украшение.
  • Фейк вместо мока — по умолчанию для своих границ. Мок — там, где вызов и есть поведение.
  • Testcontainers — там, где поведение живёт на стыке с внешней системой. Не «для галочки про интеграционные», а под конкретный вопрос: работает ли SQL, держит ли ограничение, переживает ли миграция.
  • e2e — по числу сценариев, которые нельзя не проверить (деньги, регистрация, оплата), а не по проценту покрытия.

Три оговорки, без которых картинка получается лживой:

Покрытие врёт. Строку можно исполнить, ничего про неё не утверждая: 100% достигается тестом без единого содержательного ассерта. Наш мок-тест даже честнее — он утверждает, просто не то, что важно: save позвали один раз, а цена сломана. И в том, и в другом случае счётчик покрытия зелёный. Про это подробно в «Как писать тестируемый код».

Больше моков ≠ больше изоляции. Мок изолирует тест от соседей и одновременно — от реальности. Изоляция сама по себе не ценность.

Сравнение языков — не рейтинг. Ни один язык здесь не «лучше тестируется». Различается трение: где дублёр бесплатен, там их будет больше; где у движка удобнее комбинаторы, там доменный генератор пишется быстрее. Наш замер это и показал: на дефолте наивное свойство подводит во всех трёх (лучший результат — раз из восьми), а с доменным генератором находят все три. Инженерные решения от трения меняются, а качество тестов — нет: оно зависит от того, что вы утверждаете, а не на чём пишете.

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

Демо и версии

Стенд: architecture/testing/across-languages — один домен, три языка, четыре приёма; каждое демо запускается одной командой.

Go 1.26.3, rapid 1.3.0, pgx 5.10.0, testcontainers-go 0.43.0
Java JDK 21.0.11, Maven 3.9.16, JUnit 5.12.2, jqwik 1.9.3, Mockito 5.18.0, Testcontainers 1.21.3, JDBC 42.7.7
Python 3.12.3, pytest 9.1.1, Hypothesis 6.156.6, testcontainers 4.14.2, psycopg 3.3.4
Postgres 18.1-alpine (во всех трёх)

Замеры сняты на Windows 11 + WSL2, демон Docker 29.6.1 для всех трёх языков. Точной межъязыковой таблицы времени тут нет намеренно — она мерила окружение, а не язык (подробности в разделе про пирамиду); свои числа снимает timing-matrix.sh. Числа по property-движкам: 20 прогонов на ячейку (у jqwik на дефолте — 40: два захода по 20, давшие 4 и 1 находку), 5 прогонов на ячейку с бюджетом 20 000; кэш найденных примеров чистится перед каждым прогоном. Это одно свойство на одном домене, а не бенчмарк библиотек. Всю таблицу воспроизводит скрипт property-matrix.sh из стенда — он же чистит кэши и считает частоту находок.

Смежное на сайте: «Как писать тестируемый код», «TDD и BDD на практике», «Flaky-тесты», «Тестовые данные», «Тест-смеллы», «Тестирование в Go», «Тестирование распределённых систем».

Документация и первоисточники

  • Testcontainers — модули по языкам и гайды.
  • Hypothesis — документация, лучшая в жанре; разделы про стратегии и шринкинг полезны независимо от языка.
  • jqwik — property-based для JVM.
  • rapid — property-based для Go со шринкингом «из коробки».
  • Martin Fowler, Mocks Aren’t Stubs — разбор терминологии Джерарда Месароша (мок/стаб/фейк) и разницы между проверкой состояния и проверкой взаимодействий. Сама таксономия — из его «xUnit Test Patterns».
  • Martin Fowler, Test Pyramid — и его же оговорки про то, что пирамида про скорость.
  • John Hughes, QuickCheck — откуда пошло всё property-based.
  • docker-java: getting started — где описан api.version и прочая конфигурация клиента; на нём держится половина практических выводов раздела про Testcontainers.
  • Mockito: конфигурация агента — почему на JDK 21+ агент подключают явно, а не self-attach’ем.
  • Docker Engine API — таблица версий API и минимума, поддерживаемого демоном.
  • testcontainers-native — C/C++/Swift поверх Go-клиента.

Обсуждение в Telegram

Присоединиться →

Комментарии