Классы эквивалентности и граничные значения
Как из бесконечного числа проверок оставить шесть — и поймать при этом больше багов, чем сотней случайных тыканий.
Класс эквивалентности — группа входных данных, которые программа обрабатывает одинаково. Если один представитель группы прошёл проверку, остальные почти наверняка пройдут тоже — значит, достаточно взять одного.
Задача, у которой нет конца
Форма регистрации. Поле «Возраст». Требование одно: принимаем пользователей от 18 до 65 лет включительно.
Сколько тестов нужно написать? Новичок обычно отвечает «ну, штук десять» и начинает вбивать что придётся: 25, 30, 42, 7, 100. Это тест-набор из категории «на удачу»: тесты 25, 30 и 42 проверяют ровно одно и то же — что где-то в середине разрешённого диапазона всё хорошо. Три теста, а информации от них — как от одного.
Формально проверок здесь бесконечно много. Возраст можно ввести любым целым числом, дробным, отрицательным, буквами, эмодзи, пробелом, строкой на 10 000 символов, HTML-тегом. Полный перебор невозможен физически. И вот тут вступают в игру две техники, которые лежат в фундаменте профессии.
Классы эквивалентности
Идея простая до неприличия: посмотри на данные глазами программы. Программе всё равно, 25 тебе или 42, — она выполнит один и тот же код и вернёт один и тот же ответ. А вот 17 и 25 для неё принципиально разные: это разные ветки условия.
Значит, всё множество входных данных можно разбить на группы, внутри которых поведение одинаково. Такая группа и есть класс эквивалентности. Из каждого класса берём ровно одного представителя — этого достаточно.
Классы бывают двух сортов:
- Валидные — данные, которые система должна принять.
- Невалидные — данные, которые она должна отвергнуть, причём с внятным сообщением об ошибке. Невалидных классов почти всегда больше, и именно их чаще всего забывают.
Разложим наше поле по полочкам:
| Класс | Описание | Представитель | Ожидаемый результат |
| ВК1 | Возраст в допустимом диапазоне | 30 | Регистрация проходит |
| НК1 | Целое число меньше 18 | 10 | «Регистрация с 18 лет» |
| НК2 | Целое число больше 65 | 80 | «Максимальный возраст — 65» |
| НК3 | Отрицательное число | -5 | Ошибка валидации |
| НК4 | Ноль | 0 | Ошибка валидации |
| НК5 | Дробное число | 25.5 | Ошибка валидации |
| НК6 | Не число (буквы, символы) | тридцать | Ошибка валидации |
| НК7 | Пустое поле | (пусто) | «Поле обязательно» |
Обрати внимание на важную вещь: один тест = один невалидный класс. Не надо в одном тесте вводить и букву, и отрицательное число — если система отвергнет ввод, ты не поймёшь, из-за чего именно. А вот валидные классы, наоборот, можно смело комбинировать в одном тесте: если всё в порядке, программа примет всё сразу.
Граничные значения: где живут баги
Классы эквивалентности сократили нам работу. Но у них есть слепое пятно: они говорят «возьми любого представителя», а баги сидят не «где-то в середине». Баги сидят ровно на краю.
Причина сугубо человеческая. Программист пишет условие и в этот момент решает микрозадачу: < или <=? > или >=? Именно здесь рождается классическая ошибка «на единицу» (off-by-one) — самый массовый дефект в валидации на планете. И заметить его можно, только ткнув пальцем точно в границу.
Техника граничных значений (boundary value analysis) говорит: для каждой границы класса проверь три точки — перед границей, на границе, сразу за границей. У нас две границы, 18 и 65, значит шесть точек:
| Значение | Что это | Ожидаемый результат |
17 | перед нижней границей | Отказ |
18 | нижняя граница | Принято |
19 | сразу после границы | Принято |
64 | перед верхней границей | Принято |
65 | верхняя граница | Принято |
66 | сразу за верхней границей | Отказ |
Такой подход называют трёхточечным. Есть и упрощённый, двухточечный: берут только само граничное значение и ближайшее за ним (18 и 17, 65 и 66) — четыре теста вместо шести. Он экономнее и в реальных проектах используется чаще; трёхточечный берут там, где цена ошибки высока: деньги, лимиты, доступ к данным.
Ключевой момент, который новички упускают: граница должна быть определена в тех же единицах, что и данные. Если поле принимает не целые годы, а дату рождения, то «граница 18 лет» — это конкретная дата с точностью до дня, и тестировать надо «день до восемнадцатилетия», «день рождения», «день после». Если поле — сумма в рублях с копейками, соседнее к 3000 значение не 2999, а 2999,99.
Собираем итоговый тест-набор
Две техники всегда работают в паре: классы эквивалентности отвечают на вопрос «какие бывают ситуации», граничные значения — «какие конкретно числа подставить». Итог по нашему полю:
17,18,65,66— границы диапазона (двухточечный вариант);30— типичный валидный представитель;-5,0,25.5,тридцать, пустое поле — невалидные классы.
Девять тестов. Не «десять на глазок», а девять, каждый из которых проверяет свою уникальную гипотезу и ни один не дублирует другой. Вот это и есть тест-дизайн.
Как это работает
Под капотом обе техники опираются на устройство кода. Валидация возраста в реальном сервисе выглядит примерно так — можешь запустить и посмотреть на поведение эталонной версии:
def validate_age(age):
if age < 18:
return 'Отказ: регистрация с 18 лет'
if age > 65:
return 'Отказ: максимальный возраст 65'
return 'Принято'
for age in [17, 18, 19, 64, 65, 66]:
print(age, '-', validate_age(age))
Результат:
17 - Отказ: регистрация с 18 лет
18 - Принято
19 - Принято
64 - Принято
65 - Принято
66 - Отказ: максимальный возраст 65
А теперь версия с одним-единственным лишним символом — программист написал <= вместо <. Запусти и сравни вывод:
def validate_age(age):
if age <= 18: # опечатка: лишний знак равенства
return 'Отказ: регистрация с 18 лет'
if age > 65:
return 'Отказ: максимальный возраст 65'
return 'Принято'
for age in [17, 18, 19, 64, 65, 66]:
print(age, '-', validate_age(age))
Результат:
17 - Отказ: регистрация с 18 лет
18 - Отказ: регистрация с 18 лет
19 - Принято
64 - Принято
65 - Принято
66 - Отказ: максимальный возраст 65
Восемнадцатилетние больше не могут зарегистрироваться. Продукт теряет живых пользователей, и никто не понимает почему. Теперь смотри: тесты со значениями 25, 30 и 42 этот баг не увидели бы вообще — все три вернули бы «Принято». Единственный тест, который его ловит, — тот, что стоит ровно на границе. Именно поэтому граничные значения — не «одна из техник», а обязательная гигиена.
Частые ошибки
- Проверять только валидные данные. Классический настрой новичка: «работает же». Половина реальных дефектов — это кривая обработка мусора на входе. Невалидных классов всегда больше, и они важнее.
- Смешивать несколько невалидных классов в одном тесте. Ввёл сразу и минус, и букву — получил ошибку — и не знаешь, какая проверка сработала, а какая просто не дошла до дела.
- Забывать про границы «снаружи» типа данных. Поле возраста — это ещё и максимальная длина строки, и переполнение целого числа. Попробуй ввести 2147483648 (это
int32+ 1) — во многих системах это приводит к 500-й ошибке. - Считать границей то, что границей не является. Если требование звучит «от 18 до 65 включительно», то 18 и 65 — валидные значения. Половина багов в самих тест-кейсах — из-за того, что тестировщик неверно прочитал «включительно».
- Тестировать только поле, а не сценарий. Поле может валидироваться на фронтенде, но не на бэкенде. Отправь запрос мимо формы (через API или DevTools) — и увидишь настоящую границу.
- Игнорировать «пустоту». Пустая строка, пробел,
null— три разных класса, и ведут себя они по-разному.
Итоги
- Классы эквивалентности сокращают бесконечность до горстки групп: программа обрабатывает данные внутри группы одинаково, значит хватит одного представителя.
- Граничные значения указывают, каких именно представителей брать: край диапазона и соседей за ним. Там живёт off-by-one — самый частый дефект валидации.
- Двухточечный подход (граница + сосед) — рабочая норма; трёхточечный — для критичных мест вроде денег и доступов.
- Один тест = один невалидный класс. Валидные классы можно комбинировать, невалидные — нет.
- Обе техники применимы не только к числам: к длине строки, к датам, к размеру файла, к количеству элементов в корзине.
- Хороший тест-набор — не тот, где много тестов, а тот, где каждый тест проверяет уникальную гипотезу.