Классы эквивалентности и граничные значения

Как из бесконечного числа проверок оставить шесть — и поймать при этом больше багов, чем сотней случайных тыканий.

Класс эквивалентности — группа входных данных, которые программа обрабатывает одинаково. Если один представитель группы прошёл проверку, остальные почти наверняка пройдут тоже — значит, достаточно взять одного.

Задача, у которой нет конца

Форма регистрации. Поле «Возраст». Требование одно: принимаем пользователей от 18 до 65 лет включительно.

Сколько тестов нужно написать? Новичок обычно отвечает «ну, штук десять» и начинает вбивать что придётся: 25, 30, 42, 7, 100. Это тест-набор из категории «на удачу»: тесты 25, 30 и 42 проверяют ровно одно и то же — что где-то в середине разрешённого диапазона всё хорошо. Три теста, а информации от них — как от одного.

Формально проверок здесь бесконечно много. Возраст можно ввести любым целым числом, дробным, отрицательным, буквами, эмодзи, пробелом, строкой на 10 000 символов, HTML-тегом. Полный перебор невозможен физически. И вот тут вступают в игру две техники, которые лежат в фундаменте профессии.

Классы эквивалентности

Идея простая до неприличия: посмотри на данные глазами программы. Программе всё равно, 25 тебе или 42, — она выполнит один и тот же код и вернёт один и тот же ответ. А вот 17 и 25 для неё принципиально разные: это разные ветки условия.

Значит, всё множество входных данных можно разбить на группы, внутри которых поведение одинаково. Такая группа и есть класс эквивалентности. Из каждого класса берём ровно одного представителя — этого достаточно.

Классы бывают двух сортов:

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

Разложим наше поле по полочкам:

КлассОписаниеПредставительОжидаемый результат
ВК1Возраст в допустимом диапазоне30Регистрация проходит
НК1Целое число меньше 1810«Регистрация с 18 лет»
НК2Целое число больше 6580«Максимальный возраст — 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 — самый частый дефект валидации.
  • Двухточечный подход (граница + сосед) — рабочая норма; трёхточечный — для критичных мест вроде денег и доступов.
  • Один тест = один невалидный класс. Валидные классы можно комбинировать, невалидные — нет.
  • Обе техники применимы не только к числам: к длине строки, к датам, к размеру файла, к количеству элементов в корзине.
  • Хороший тест-набор — не тот, где много тестов, а тот, где каждый тест проверяет уникальную гипотезу.
Проверьте себя
1. Поле принимает целое число от 1 до 100 включительно. Какой набор значений корректно покрывает границы двухточечным методом?
A1, 50, 100
B0, 1, 100, 101
C1, 2, 99, 100
D0, 101
2. Почему тесты со значениями 25, 30 и 42 для поля «возраст 18–65» считаются избыточными?
AОни слишком долго выполняются
BВсе три принадлежат одному классу эквивалентности, программа обработает их одинаково
CОни не проходят валидацию на фронтенде
DОни проверяют невалидный класс, а его надо проверять отдельно