Тест-дизайн: эквивалентные классы

Equivalence Partitioning (разбиение на классы эквивалентности) — техника тест-дизайна, при которой входные данные делятся на группы, где все значения группы должны обрабатываться одинаково — достаточно протестировать одно значение из каждой группы.

Зачем нужно

Нельзя протестировать все возможные входные данные. Если функция одинаково обрабатывает любое значение из диапазона 1-100, нет смысла проверять 100 вариантов — достаточно одного. Это сокращает количество тестов без потери эффективности.

Где используется

  • Валидация форм: email, возраст, сумма
  • Категоризация: группы пользователей, уровни доступа, тарифы
  • Обработка типов данных: числа, строки, объекты, null

Основной контент

Принцип

Весь диапазон входных данных делится на классы:
- Валидные классы — система обрабатывает успешно
- Невалидные классы — система должна вернуть ошибку

Из каждого класса выбирается одно представительное значение.

Пример: классификация BMI

function getBMICategory(bmi) {
  if (bmi < 18.5) return 'underweight';
  if (bmi < 25)   return 'normal';
  if (bmi < 30)   return 'overweight';
  return 'obese';
}

// Классы эквивалентности:
// 1. bmi < 18.5    → 'underweight'  (представитель: 16)
// 2. 18.5 ≤ bmi < 25 → 'normal'    (представитель: 22)
// 3. 25 ≤ bmi < 30   → 'overweight' (представитель: 27)
// 4. bmi ≥ 30        → 'obese'      (представитель: 35)

test.each([
  [16, 'underweight'],
  [22, 'normal'],
  [27, 'overweight'],
  [35, 'obese'],
])('BMI %d → %s', (bmi, expected) => {
  expect(getBMICategory(bmi)).toBe(expected);
});

Пример: валидация email

function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// Классы эквивалентности:
// Валидные:
//   1. Стандартный email: user@example.com
//   2. С субдоменом: user@mail.example.com
//   3. С точкой в имени: first.last@example.com

// Невалидные:
//   4. Нет @: userexample.com
//   5. Нет домена: user@
//   6. Нет расширения: user@example
//   7. Пустая строка: ''

test.each([
  // Валидные классы
  ['user@example.com', true],
  ['user@mail.example.com', true],
  ['first.last@example.com', true],
  // Невалидные классы
  ['userexample.com', false],
  ['user@', false],
  ['user@example', false],
  ['', false],
])('isValidEmail("%s") → %s', (email, expected) => {
  expect(isValidEmail(email)).toBe(expected);
});

Пример: скидка по возрасту

function getDiscount(age: number): number {
  if (age < 0 || !Number.isInteger(age)) throw new Error('Invalid age');
  if (age < 12)  return 100; // дети бесплатно
  if (age < 18)  return 50;  // скидка детям
  if (age < 65)  return 0;   // без скидки
  return 30;                  // пенсионерам
}

// 5 валидных классов + 1 невалидный (отрицательный возраст)
test.each([
  [5, 100],   // ребёнок
  [15, 50],   // подросток
  [30, 0],    // взрослый
  [70, 30],   // пенсионер
  [-1, null], // невалидный — ожидаем ошибку
])('getDiscount(%d)', (age, expected) => {
  if (expected === null) {
    expect( => getDiscount(age)).toThrow;
  } else {
    expect(getDiscount(age)).toBe(expected);
  }
});

ЭК + граничные значения вместе

Оба метода часто используются совместно: ЭК определяет классы, граничные значения уточняют тесты на стыках классов.

Частые ошибки

  • Один тест на весь класс — нужно именно одно репрезентативное значение из каждого класса, не несколько похожих
  • Пропуск невалидных классов — тестируют только "happy path"; система должна правильно обрабатывать неверные входные данные
  • Слишком крупные классы — если внутри класса есть поведенческие различия, его нужно разделить

Связанные темы

Ресурсы