Тест-дизайн: классы эквивалентности, границы, попарное тестирование, таблицы решений
Тест-дизайн — это не «придумывание проверок». Это инженерная задача сжатия: у функции бесконечное (или практически бесконечное) множество входов, а бюджет — двадцать проверок, которые кто-то реально прогонит руками или будет поддерживать в CI пять лет. Тест-дизайн отвечает на вопрос «какие двадцать?» и — что важнее — «почему именно эти, а не другие».
Посчитаем. Форма регистрации: имя (строка до 50 символов), возраст (целое), email, страна (200 вариантов), тариф (3 варианта), чекбокс согласия, промокод (строка или пусто). Даже если ограничить строки алфавитом из 26 букв и длиной 50, число комбинаций больше числа атомов в наблюдаемой Вселенной. Исчерпывающее тестирование невозможно — это не лень, это первый принцип из https://courses.digitable.life/post/testing/01-principles-and-terminology/. Значит, любое тестирование — это выборка. Тест-дизайн отличается от случайного тыканья тем, что выборка строится по модели, и вы можете объяснить, какой класс дефектов она ловит, а какой пропускает.
Модель входного пространства
Все техники тест-дизайна работают одинаково: строится модель системы, из модели механически выводятся тест-кейсы, и качество тестов измеряется покрытием модели, а не кода. Модель — это упрощение, и все ошибки техники — это ошибки модели.
тест-дизайна)) Чёрный ящик Эквивалентные классы Граничные значения Таблицы решений Состояния и переходы Комбинаторные (pairwise, t-way) Доменный анализ Use case / сценарные Белый ящик Покрытие операторов Покрытие ветвей Покрытие условий MC/DC Покрытие путей Основанные на опыте Предугадывание ошибок Исследовательское тестирование Чек-листы и таксономии дефектов Генеративные Property-based Фаззинг Метаморфное тестирование
Классификация из ISTQB Foundation Level (v4.0, syllabus) кажется бюрократической, но она полезна прагматически: техники чёрного ящика применимы, когда есть спецификация; белого — когда есть код и нужно доказать, что вы дошли до всех веток; опытные — когда спецификации нет, а сроки вчера. В реальном проекте вы комбинируете все три.
Тестировщик vs разработчик. Разработчик почти всегда начинает с белого ящика: «вот три ветки в
if, напишу три теста». Тестировщик начинает с чёрного: «вот требование, вот классы поведения». Эти взгляды дают разные дефекты. Белый ящик не находит пропущенную функциональность — если ветки для отрицательного баланса в коде нет, покрытие веток будет 100%, а дефект останется. Чёрный ящик не находит внутренние особые случаи — например, ветку кэша, о которой в требованиях ни слова. Отсюда практическое правило: если вы разработчик, спроектируйте тесты по требованию, до чтения собственного кода, а потом проверьте покрытие. Если вы тестировщик — попросите показать код в местах, где вы не понимаете, где границы.
Эквивалентное разбиение
Идея. Разбиваем множество входов на классы так, что система обрабатывает все значения внутри класса одинаково. Тогда достаточно одного представителя класса: если баг есть — он проявится на любом значении из класса; если нет — не проявится ни на одном.
Это гипотеза однородности, и она ровно гипотеза, а не теорема. Вы предполагаете, что
age = 35 и age = 42 идут одним путём. Если внутри спрятана ветка «для 40+ показываем
другую страховку», ваша модель неверна, и разбиение её не поймает. Отсюда правило:
классы строятся и по входу, и по выходу, и по внутренним правилам, о которых вы знаете.
Пример. Сервис рассчитывает скидку по сумме заказа:
| Класс | Диапазон | Ожидаемая скидка | Представитель |
|---|---|---|---|
| E1 (невалидный) | сумма < 0 | ошибка валидации | −100 |
| E2 | 0 ≤ сумма < 1000 | 0% | 500 |
| E3 | 1000 ≤ сумма < 5000 | 5% | 3000 |
| E4 | сумма ≥ 5000 | 10% | 7000 |
| E5 (невалидный) | не число / пусто / null |
ошибка валидации | "abc" |
Пять классов — пять тестов вместо бесконечности. Обратите внимание: классы вырастают не из типа данных, а из правил бизнес-логики. Границы 1000 и 5000 никак не следуют из «сумма — это число», они следуют из требований.
import pytest
from decimal import Decimal
from billing import calculate_discount, ValidationError
# Один представитель на класс эквивалентности.
# id-шники обязательны: в отчёте CI должно быть видно, какой класс упал.
@pytest.mark.parametrize(
"amount, expected",
[
pytest.param(Decimal("500"), Decimal("0.00"), id="E2-без-скидки"),
pytest.param(Decimal("3000"), Decimal("150.00"), id="E3-5-процентов"),
pytest.param(Decimal("7000"), Decimal("700.00"), id="E4-10-процентов"),
],
)
def test_discount_valid_classes(amount, expected):
assert calculate_discount(amount) == expected
# Невалидные классы проверяем ПО ОДНОМУ на тест: если подать сразу
# отрицательную сумму и мусорный тип, первая же проверка в коде
# замаскирует вторую, и вы не узнаете, работает ли она вообще.
@pytest.mark.parametrize(
"amount",
[pytest.param(Decimal("-100"), id="E1-отрицательная"),
pytest.param("abc", id="E5-не-число"),
pytest.param(None, id="E5-null")],
)
def test_discount_invalid_classes(amount):
with pytest.raises(ValidationError):
calculate_discount(amount)
Типичные ошибки разбиения:
- Классы только по входу. Правильно: если два входа дают разный выход — это разные классы, даже если по типу они «одинаковые числа».
- Забытый «пустой» класс: пустая строка, пустой список,
null, отсутствующее поле, поле с одними пробелами. Это четыре разных класса, а не один, и продакшн это регулярно доказывает. - Комбинирование невалидных значений в одном тесте (см. комментарий в коде выше).
- Классы, которые пересекаются или не покрывают всё множество. Проверка на здравость: сумма классов = всё множество входов, пересечение любых двух пусто.
Граничные значения
Дефекты кучкуются на границах. < вместо <=, i <= n вместо i < n, «включительно» в
требовании и «исключительно» в коде. Boundary Value Analysis — это эквивалентное разбиение
плюс осознанный обстрел стыков между классами.
Есть две школы:
- 2-value BVA (ISTQB, современная редакция): для каждой границы берём саму границу и ближайшее значение с другой стороны. Для «допустимо 18..65»: 17, 18, 65, 66.
- 3-value BVA (классика Майерса): граница и по соседу с обеих сторон — 17, 18, 19, 64, 65, 66.
2-value дешевле и ловит почти всё, что ловит 3-value: перепутанный оператор сравнения виден
уже на паре (18, 17). 3-value дополнительно страхует от «странных» реализаций вроде
if age == 18: special_case(). Практический компромисс: 2-value по умолчанию,
3-value — для критичной логики (деньги, доступ, лимиты) и для мест, где код писали давно
и никто не помнит почему.
Границы бывают не только числовые, и именно неочевидные дают самые дорогие баги:
| Тип границы | Что брать |
|---|---|
| Числовой диапазон | min−1, min, max, max+1, а также 0, −1, MAX_INT, MAX_INT+1 |
| Длина строки | 0, 1, max−1, max, max+1; плюс многобайтовые символы (эмодзи «весит» больше одного символа) |
| Коллекция | пустая, из одного элемента, ровно на границе пагинации (page_size, page_size+1) |
| Время | полночь, 23:59:59, 29 февраля, переход на летнее время, граница таймзоны, конец месяца/квартала |
| Деньги | 0.00, 0.01, точность округления (0.005), отрицательный ноль, переполнение суммы |
| Файлы | 0 байт, ровно лимит, лимит+1 байт, имя максимальной длины |
Про эмодзи стоит развернуть: «имя до 50 символов» — это 50 code points, 50 UTF-16 code units
или 50 байт? Флаг-эмодзи вроде 🇷🇺 — это два code point, семья 👨👩👧 — до семи. Поле, которое
считает len() в одном месте и VARCHAR(50) в базе в другом, гарантированно даст расхождение
на границе. Это классический пограничный дефект, который не найдёт ни один тест с ASCII-именами.
Таблицы решений
Когда результат зависит от комбинации условий, а не от одного значения, разбиение бессильно — нужна таблица решений. Она делает две вещи: систематизирует комбинации и, что ценнее, вскрывает дырки в требованиях (комбинации, про которые аналитик просто не подумал).
Пример: бесплатная доставка. Условия — есть подписка, сумма ≥ 3000, вес ≤ 5 кг.
| Правило | Подписка | Сумма ≥ 3000 | Вес ≤ 5 кг | Доставка |
|---|---|---|---|---|
| R1 | да | — | да | бесплатно |
| R2 | да | — | нет | 300 ₽ (габаритный тариф) |
| R3 | нет | да | да | бесплатно |
| R4 | нет | да | нет | 300 ₽ |
| R5 | нет | нет | да | 250 ₽ |
| R6 | нет | нет | нет | 450 ₽ |
Полная таблица для трёх бинарных условий — 2³ = 8 строк. Здесь их шесть, потому что при активной подписке сумма не важна: правила R1 и R2 схлопнуты (в них стоит «—», don’t care). Схлопывание — не косметика: оно ровно и есть тест-дизайн, оно превращает 8 тестов в 6 без потери покрытия.
Порядок работы:
- Выписать все условия и все действия.
- Построить полную таблицу 2ⁿ (или произведение значений, если условия не бинарные).
- Заполнить действия по требованиям. Клетки, для которых требования молчат, — это найденные дефекты требований. Идёте к аналитику до написания кода.
- Схлопнуть эквивалентные столбцы через don’t care.
- Каждое оставшееся правило = один тест-кейс.
import pytest
from shipping import shipping_cost
# Таблица решений один в один переносится в параметризацию.
# id-шник = номер правила: упавший тест сразу указывает на строку в спецификации.
DECISION_TABLE = [
# (подписка, сумма, вес_кг, ожидаемая стоимость, id)
(True, 1000, 3.0, 0, "R1"),
(True, 1000, 9.0, 300, "R2"),
(False, 5000, 3.0, 0, "R3"),
(False, 5000, 9.0, 300, "R4"),
(False, 1000, 3.0, 250, "R5"),
(False, 1000, 9.0, 450, "R6"),
]
@pytest.mark.parametrize(
"has_sub, amount, weight, expected",
[pytest.param(*row[:4], id=row[4]) for row in DECISION_TABLE],
)
def test_shipping_decision_table(has_sub, amount, weight, expected):
assert shipping_cost(has_sub, amount, weight) == expected
Сложность таблицы решений — O(2ⁿ) по числу условий: 3 условия → 8 строк, 10 условий → 1024. Если таблица разрослась, это сигнал не «нужен генератор», а «логика перегружена» — разбейте её на несколько таблиц по подсистемам. Растущая таблица решений в тестах почти всегда указывает на god-функцию в коде; см. https://courses.digitable.life/post/principles/00-overview/.
Состояния и переходы
Если поведение зависит от истории, а не только от текущего входа, нужна модель конечного автомата. Классика: заказ, платёж, подписка, сессия, корзина.
Из диаграммы механически выводятся уровни покрытия:
- 0-switch (покрытие переходов) — каждый переход хотя бы раз. Здесь это 11 тестов (или несколько сценариев, проходящих по разным дугам). Базовый минимум.
- 1-switch — каждая пара последовательных переходов. Ловит дефекты вида «после retry платёж проходит, но статус не обновляется». Дороже примерно на порядок.
- Покрытие состояний — слабее 0-switch, самостоятельной ценности почти не имеет.
Отдельно и обязательно — невалидные переходы. Что произойдёт, если вызвать ship
для заказа в статусе «Черновик»? Или refund дважды подряд? В таблице переходов это пустые
клетки, и именно там живут дефекты, которые в проде выглядят как «двойной возврат средств».
Постройте матрицу «состояние × событие» и для каждой пустой клетки задайте вопрос:
что система должна ответить и что она отвечает на самом деле.
import pytest
from orders import Order, IllegalTransition
# Все запрещённые пары "состояние × событие". Пустые клетки таблицы переходов
# — самая доходная часть тест-дизайна для автоматов.
FORBIDDEN = [
("draft", "ship"), ("draft", "refund"), ("draft", "deliver"),
("awaiting_payment", "ship"), ("awaiting_payment", "refund"),
("delivered", "refund"), ("cancelled", "payment_ok"),
("refunded", "refund"),
]
@pytest.mark.parametrize("state, event", FORBIDDEN, ids=lambda v: str(v))
def test_illegal_transitions_are_rejected(state, event):
order = Order.in_state(state) # тестовая фабрика, не прогон всего пути
with pytest.raises(IllegalTransition):
order.handle(event)
assert order.state == state # состояние не должно измениться
Обратите внимание на последнюю строку: мало проверить, что исключение брошено — надо убедиться, что объект не остался в полуизменённом состоянии. Это тот случай, когда тестировщик и разработчик смотрят одинаково, но разработчику проще: он видит, что между валидацией и мутацией нет транзакции.
Попарное тестирование
Теперь про комбинаторный взрыв. Матрица конфигураций: ОС (5) × браузер (4) × язык (6) × роль (3) × тариф (4) = 1440 комбинаций. Прогнать все — нереально. Прогнать «по одному значению каждого параметра» — 6 тестов, но вы проверите ровно ноль взаимодействий.
Спасает эмпирика. Исследования NIST (Kuhn, Wallace, Gallo, “Software Fault Interactions and Implications for Software Testing”, IEEE TSE 2004) на нескольких доменах — медицинские устройства, браузер, серверное ПО, NASA — показали: от 20 до 70% дефектов вызываются одним параметром, а комбинация двух параметров покрывает порядка 70–95% дефектов. Взаимодействий шести параметров одновременно в изученных базах не нашлось вовсе. Отсюда pairwise: покрыть все пары значений, а не все комбинации.
Для примера выше все пары покрываются примерно 30 тестами вместо 1440 — сокращение в 48 раз при потере, по эмпирике, единиц процентов дефектообнаружения. Это лучший размен в тест-дизайне.
5 × 4 × 6 × 3 × 4"] --> B{"Есть запрещённые
комбинации?"} B -- да --> C["Описать constraints
iOS + Internet Explorer = нет"] B -- нет --> D["Генератор t-way
t = 2 по умолчанию"] C --> D D --> E["≈30 наборов
вместо 1440"] E --> F{"Есть критичные
тройки?"} F -- да --> G["Добавить вручную
seed-комбинации"] F -- нет --> H["Готовый набор
для прогона в CI"] G --> H
from allpairspy import AllPairs
# Порядок параметров имеет значение: генератор жадный, первые параметры
# перебираются плотнее. Ставьте первыми самые рискованные.
PARAMETERS = [
["Windows", "macOS", "Ubuntu", "iOS", "Android"],
["Chrome", "Firefox", "Safari", "Edge"],
["ru", "en", "de", "ar", "he", "zh"],
["guest", "user", "admin"],
["free", "pro", "team", "enterprise"],
]
def is_valid(row):
"""Ограничения предметной области: несуществующие комбинации отбрасываем."""
if len(row) >= 2:
os_, browser = row[0], row[1]
if os_ == "iOS" and browser in ("Firefox", "Edge"):
return False
if os_ == "Ubuntu" and browser in ("Safari", "Edge"):
return False
if os_ == "Android" and browser == "Safari":
return False
if len(row) >= 4 and row[0] == "iOS" and row[3] == "admin":
return False # админка на мобильном не поддерживается
return True
cases = list(AllPairs(PARAMETERS, filter_func=is_valid))
print(f"Наборов: {len(cases)} вместо {5*4*6*3*4}")
for i, case in enumerate(cases, 1):
print(i, case)
Альтернативы allpairspy: PICT от Microsoft —
поддерживает t-way любого порядка, веса, подмодели и вложенные ограничения; NIST ACTS —
академический эталон; для JVM — jcunit.
Честно о недостатках pairwise:
- Он не гарантирует обнаружение дефекта тройного взаимодействия. Если в вашем домене
такие есть (типично для конфигураций сборки, флагов компилятора, feature-флагов),
поднимайте
tдо 3 — цена растёт примерно линейно по числу тестов, но заметно. - Он молча предполагает, что все значения равнозначны. На практике «Chrome на Windows» — 90% трафика, а «Safari на Ubuntu» не существует. Комбинаторика без весов и ограничений выдаёт красивый набор нерелевантных тестов.
- Он ничего не знает про порядок и историю — для автоматов используйте state-transition, а не pairwise.
- Набор нестабилен: добавили одно значение — генератор перетасовал всё. Для регрессии фиксируйте сгенерированный набор в файл и коммитьте его, иначе упавший тест невозможно сравнить со вчерашним прогоном. Это частая причина «флаки» в матричных прогонах, подробнее в https://courses.digitable.life/post/testing/13-tests-in-ci/.
Property-based тестирование: тест-дизайн, отданный машине
Все техники выше — ручной отбор конкретных значений. Property-based переворачивает подход: вы описываете свойство, которое должно выполняться для всего класса входов, а генератор сам ищет контрпример и минимизирует его (shrinking). Это прямое продолжение эквивалентного разбиения: вместо «возьму представителя класса» — «пусть машина возьмёт тысячу представителей».
import fc from 'fast-check';
import { describe, it } from 'vitest';
import { calculateDiscount, applyDiscount } from '../src/billing';
describe('свойства расчёта скидки', () => {
// Свойство 1: скидка никогда не превышает саму сумму и не отрицательна.
it('скидка лежит в диапазоне [0, amount]', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 10_000_000 }), (amount) => {
const discount = calculateDiscount(amount);
return discount >= 0 && discount <= amount;
}),
{ numRuns: 1000 },
);
});
// Свойство 2: монотонность — больший заказ не может стоить дешевле после скидки.
// Именно здесь генератор обычно и находит дыру на стыке тарифных порогов.
it('итоговая цена монотонна по сумме заказа', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 100_000 }),
fc.integer({ min: 0, max: 100_000 }),
(a, b) => {
const [lo, hi] = a <= b ? [a, b] : [b, a];
return applyDiscount(lo) <= applyDiscount(hi);
},
),
);
});
});
Свойство «монотонность» — типичный пример того, что ручной тест-дизайн упускает. Если на границе 5000 скидка 10% включается на всю сумму, то заказ на 4999 стоит 4999, а заказ на 5000 — 4500. Клиенту выгодно докидывать товар в корзину, чтобы платить меньше. Ни один тест из таблицы эквивалентности этого не покажет: каждое отдельное значение рассчитано «правильно». Дефект живёт в отношении между значениями.
Инструменты: Hypothesis для Python,
fast-check для TS/JS, PropEr
и StreamData для Elixir (см. https://courses.digitable.life/post/elixir/00-overview/), testing/quick
и gopter для Go. Ограничение честное: свойства
придумывать труднее, чем примеры, и не для всякой логики они существуют. Начинайте
с трёх шаблонов — инварианты (что-то всегда истинно), round-trip (decode(encode(x)) == x),
сравнение с эталонной наивной реализацией.
Как решать, что тестировать: риск-ориентированный отбор
Все техники выше отвечают на «как спроектировать тесты для X». Остаётся главный вопрос менеджмента качества: какой X вообще брать. Ответ — риск, где риск = вероятность отказа × ущерб.
Практический алгоритм на старте фичи (30–60 минут с командой):
- Разбить фичу на 10–20 областей (не «весь чекаут», а «применение промокода»).
- Для каждой оценить вероятность дефекта: сложность логики, новизна кода, опыт команды, число интеграций, история багов в этом файле. Шкала 1–3, без иллюзии точности.
- Оценить ущерб: деньги, данные, безопасность, репутация, число затронутых пользователей. Тоже 1–3.
- Произведение даёт приоритет. Верхняя треть — глубокий тест-дизайн (границы, таблицы, property-based, негативные сценарии). Средняя — по одному представителю класса. Нижняя — смоук или явное решение «не тестируем», записанное в тест-план.
- Пересматривать после каждого инцидента в проде: пропущенный баг — это ошибка оценки риска, и её надо разобрать, а не просто починить код.
Стоимость. У теста три цены: написание, прогон (время CI × число прогонов в день) и поддержка (правки при каждом рефакторинге). Третья — самая большая и та, которую никто не считает. Тест на границе 5000 ₽ переживёт десять рефакторингов. E2E-сценарий, кликающий по восьми экранам ради проверки той же границы, сломается трижды за квартал по причинам, не связанным со скидками. Одна и та же проверка, спроектированная одинаково, может быть выгодной или разорительной — в зависимости от уровня, на который вы её положили. Об этом подробнее в https://courses.digitable.life/post/testing/12-automation-strategy/.
Тестировщик vs разработчик, часть 2. Разработчик склонен считать областью риска то, что было сложно писать. Тестировщик — то, что сложно объяснить. Оба правы наполовину: самые дорогие дефекты обычно сидят там, где логика проста, но её понимают по-разному два человека. Если во время риск-сессии вы слышите «а разве не наоборот?» — вот она, область номер один, независимо от оценок.
Покрытие модели и покрытие кода: где самообман
Техники чёрного ящика измеряются покрытием модели: «покрыли 6 из 6 правил таблицы», «покрыли 11 из 11 переходов», «покрыли все пары». Это осмысленные метрики: знаменатель известен и конечен.
Покрытие кода — другое. 100% покрытия строк означает лишь, что каждая строка была
исполнена, но ничего не говорит о том, что результат был проверен. Тест, который
дёргает функцию и не делает ни одного assert, даёт полное покрытие. Отсюда классическая
ловушка: команда с KPI «80% покрытия» получает 80% покрытия и тот же поток багов.
Быстрая иллюстрация разницы уровней:
def classify(age: int, has_license: bool) -> str:
if age >= 18 and has_license:
return "driver"
return "passenger"
- Покрытие операторов: достаточно одного теста
(25, True)? Нет, нужно два, чтобы дойти до обеих строкreturn. - Покрытие ветвей: два теста —
(25, True),(25, False). Уже 100%. - Покрытие условий/MC/DC: нужно показать, что каждое условие независимо влияет на исход:
(25, True)→ driver,(25, False)→ passenger,(16, True)→ passenger. Только третий тест поймает подменуage >= 18наage >= 16.
То есть 100% покрытия ветвей не ловит off-by-one в возрасте, а BVA из первой половины
статьи — ловит. Хорошая проверка качества набора — мутационное тестирование: инструмент
вносит мелкие правки в код (>= → >, + → -, удаление строки) и смотрит, упадёт ли
хоть один тест. Выживший мутант — это конкретный класс дефектов, который вы не поймаете.
Инструменты: mutmut и Cosmic Ray
для Python, Stryker для JS/TS/C#, PIT для JVM.
Метрика «mutation score» гораздо честнее покрытия, платите вы за неё временем прогона:
мутационный прогон в десятки раз дольше обычного, поэтому его гоняют ночью и только
по критичным модулям. Тема раскрывается в https://courses.digitable.life/post/testing/13-tests-in-ci/.
От техники к артефакту: рабочий пример
Соберём всё на одной фиче: «применение промокода в корзине». Требование: промокод даёт 15%, действует при сумме от 1000 ₽, не суммируется с подпиской, имеет срок действия и лимит применений на пользователя.
Порядок применения техник:
код, сумма, дата, подписка, счётчик"] A --> B["2. Эквивалентные классы
по каждому параметру"] B --> C["3. Границы
999 / 1000, последний день / первый просроченный"] C --> D["4. Таблица решений
подписка × сумма × срок × лимит"] D --> E["5. Состояния
не применён → применён → снят → применён повторно"] E --> F["6. Pairwise
только если добавились платформы и валюты"] F --> G["7. Риск-фильтр
что автоматизировать, что оставить ручным"] G --> H["Набор тест-кейсов
и чек-лист"]
Фрагмент получившегося набора (полный формат тест-кейса — в https://courses.digitable.life/post/testing/03-documentation/):
| ID | Техника | Вход | Ожидаемый результат |
|---|---|---|---|
| PR-01 | классы | сумма 3000, код валиден, без подписки | скидка 450 ₽ |
| PR-02 | граница | сумма 999 | «промокод действует от 1000 ₽», скидка 0 |
| PR-03 | граница | сумма 1000 | скидка 150 ₽ |
| PR-04 | граница (время) | последний день действия, 23:59:59 в таймзоне пользователя | скидка применена |
| PR-05 | граница (время) | +1 секунда, следующие сутки | «срок действия истёк» |
| PR-06 | таблица решений | активная подписка + валидный код | код отклонён, приоритет подписки, явное сообщение |
| PR-07 | граница (лимит) | N-е применение при лимите N | применён |
| PR-08 | граница (лимит) | N+1-е применение | «лимит исчерпан» |
| PR-09 | состояния | применить → снять → применить снова | счётчик применений не вырос дважды |
| PR-10 | негативный класс | код в другом регистре / с пробелами по краям | применён (нормализация) либо явная ошибка — уточнить у аналитика |
| PR-11 | негативный класс | несуществующий код, 50 попыток подряд | rate limit, без утечки «такой код существует» |
Строка PR-10 — самая ценная: она не тест, а найденный пробел в требованиях. Хороший тест-дизайн окупается ещё до первого прогона, потому что задаёт вопросы, пока код не написан. Именно поэтому его делают на этапе груминга, а не после того, как разработчик сказал «готово».
Типичные ошибки тест-дизайна
- Тесты пишутся по коду, а не по требованию. Тогда они фиксируют текущее поведение, включая баги, и на рефакторинге ломаются все разом.
- Только позитивные сценарии. Негативные классы и невалидные переходы дают больше дефектов на единицу усилий, чем счастливые пути.
- Одна проверка на тест-кейс отсутствует. Тест, который проверяет пять вещей и падает на первой, скрывает четыре других результата.
- Разбиение по типам данных вместо поведения. «Проверим int, float, string» — это тестирование парсера, а не бизнес-логики.
- Нет анализа выходов. Классы строят по входам, а забывают, что «ошибка 400» и «ошибка 422» — это разные наблюдаемые результаты, и требуют разных кейсов.
- Модель, которая нигде не записана. Если таблица решений жила в голове, через полгода никто не докажет, что набор тестов полон.
- Игнорирование стоимости. 300 pairwise-конфигураций в E2E-прогоне на каждый пуш — это не покрытие, это очередь в CI и деградация доверия к тестам.
Мини-итог
- Исчерпывающего тестирования не бывает — есть выборка, и тест-дизайн делает её объяснимой.
- Эквивалентные классы сжимают вход по гипотезе однородности; границы обстреливают места, где эта гипотеза чаще всего ломается.
- Таблицы решений — для комбинаций условий; их главный побочный эффект — обнаружение дыр в требованиях до написания кода.
- Состояния и переходы — для поведения, зависящего от истории; больше всего дефектов дают запрещённые переходы, а не счастливый путь.
- Pairwise превращает тысячи конфигураций в десятки при потере единиц процентов дефектообнаружения, но слеп к тройным взаимодействиям и к порядку.
- Property-based автоматизирует выбор представителей и ловит дефекты в отношениях между значениями, которые ручной дизайн не видит.
- Что тестировать, решает риск, а не полнота; покрытие кода не измеряет качество проверок — мутационное тестирование измеряет.
Источники и что читать дальше: Glenford Myers, «The Art of Software Testing» (главы про эквивалентные классы и BVA — до сих пор эталон); Lee Copeland, «A Practitioner’s Guide to Software Test Design»; Rick Copeland и D. Richard Kuhn et al., «Practical Combinatorial Testing», NIST SP 800-142; ISTQB Foundation Level Syllabus v4.0; документация Hypothesis — раздел «What you can generate and how» полезен как каталог классов входных данных.
Что дальше
Тест-кейсы, чек-листы, тест-план и как писать баг-репорт, который починят — превращаем спроектированные проверки в артефакты, которые переживут вас в проекте: форматы тест-кейсов и когда они не нужны, чек-листы вместо кейсов, структура тест-плана и анатомия баг-репорта, который разработчик воспроизведёт с первого раза.