1. Зачем table-driven тесты
Когда тестов становится больше пяти, мозг начинает предлагать “простое решение”: скопировать прошлый тест, поменять пару значений и идти дальше. На короткой дистанции это кажется быстрым, но на длинной вы получаете 20 почти одинаковых тестов, которые тяжело читать, больно править и неприятно дебажить. Table‑driven подход решает это: мы описываем сценарии данными и прогоняем их через одну проверку.
Представьте, что тест — это не “история”, а “проверяющая машина”. Машине всё равно, сколько у вас кейсов: 3, 20 или 200. Главное, чтобы каждый кейс был описан понятно и чтобы при падении теста вы сразу видели, какой именно кейс сломался.
Небольшая схема идеи:
flowchart TD
A[Таблица кейсов] --> B[Один тест-метод]
B --> C{Цикл по кейсам}
C --> D[Act: вызываем код]
D --> E[Assert: сравниваем ожидание]
E --> C
Что такое table-driven тест
Когда говорят “table-driven tests”, некоторые представляют себе табличку с рамочками и грустью бухгалтера. На самом деле “таблица” тут означает просто коллекцию кейсов: массив структур, массив кортежей, иногда даже словарь. Главное — чтобы кейсы были данными, а не 20 раз повторённым кодом. Мы как бы говорим: “Вот список входов и ожиданий. Проверяй по очереди”.
С точки зрения новичка это похоже на список покупок: вместо того чтобы 20 раз ходить в магазин за одним продуктом (копипастить тесты), вы один раз пишете список (таблица), а потом выполняете одинаковое действие для каждого пункта (цикл).
Полезно зафиксировать минимальную модель кейса (это не правило языка, а удобный шаблон):
| Тип сценария | Что хранит кейс | Что делает тест |
|---|---|---|
| Успех | вход + ожидаемый результат | |
| Ошибка | вход + ожидаемая ошибка | |
2. Успешные кейсы: цикл вместо десяти тестов
Начать лучше с самого приятного случая — когда функция должна успешно вернуть результат. Здесь table-driven подход особенно удобный, потому что тест можно сделать throws и писать “почти как обычный код”: XCTAssertEqual(try …, …). Важно помнить, что мы не делаем один тест на всё подряд: мы объединяем только сценарии, где проверка одинаковая по смыслу.
Давайте продолжим наше учебное CLI‑приложение LibraryCLI. Предположим, у нас уже есть value object BookID. Он хранит идентификатор книги в формате из 6 цифр.
Пример (производственный код, упрощённо):
public enum BookIDError: Error, Equatable {
case invalidFormat(input: String)
}
public struct BookID: Equatable {
public let rawValue: String
public init(_ rawValue: String) throws {
guard rawValue.count == 6, rawValue.allSatisfy({ $0.isNumber }) else {
throw BookIDError.invalidFormat(input: rawValue)
}
self.rawValue = rawValue
}
}
Теперь пишем table-driven тест на “валидные ID создаются корректно”.
Пример (тест):
import XCTest
final class BookIDSuccessTableTests: XCTestCase {
func testBookID_validInputs_createsID() throws {
let cases = ["000001", "123456", "999999"]
for input in cases {
let id = try BookID(input)
XCTAssertEqual(id.rawValue, input, "input: \(input)")
}
}
}
Обратите внимание на сообщение в XCTAssertEqual: "input: \(input)". Это маленькая деталь, которая экономит время. Если кейс "123456" вдруг начнёт падать, вы сразу увидите в выводе тестов, какой именно вход был проблемным.
3. Ошибки: table-driven тесты без ветвлений
Когда мы тестируем ошибки, table-driven подход становится ещё полезнее: он помогает фиксировать контракт “вот эти входы считаются невалидными, и для каждого — такая-то ошибка”. Но здесь есть важный момент: если вы смешаете успехи и ошибки в одной таблице, у вас почти неизбежно появится if внутри теста (“если ожидаем ошибку — проверяем так, иначе — по-другому”). Такой тест быстро превращается в мини‑программу со своей логикой, и доверие к нему падает.
Поэтому держим простое правило: “успехи” — в одном тесте, “ошибки” — в другом. Тогда внутри цикла у нас один сценарий проверки и минимум условий.
Пример (тест ошибок):
import XCTest
final class BookIDErrorTableTests: XCTestCase {
func testBookID_invalidInputs_throwInvalidFormat() {
let cases = ["", "123", "ABCDEF", "12_456", "1234567"]
for input in cases {
XCTAssertThrowsError(try BookID(input), "input: \(input)") { error in
XCTAssertEqual(error as? BookIDError, .invalidFormat(input: input))
}
}
}
}
Здесь происходит важная вещь: ошибка внутри XCTAssertThrowsError приходит как Error, и мы проверяем её точный тип/кейс. Мы не сравниваем строки описания и не ограничиваемся “ну оно упало, и ладно”.
4. Когда таблица становится структурой Case
Когда кейсов становится много, и у каждого есть несколько параметров (например, входное значение, ожидаемое значение и подпись), массив строк перестаёт быть удобным. В этот момент “таблица” перестаёт быть просто [String] и превращается в массив структур. Это не усложнение ради усложнения: это способ сделать тест читаемым, как документ требований, а не как загадку.
Пусть у нас есть доменный тип Year, который допускает только диапазон 1450...2100 (условно). Нам важно проверить много границ: “внутри”, “на границе”, “чуть ниже”, “чуть выше”.
Пример (производственный код, коротко):
public enum YearError: Error, Equatable {
case outOfRange(value: Int)
}
public struct Year: Equatable {
public let value: Int
public init(_ value: Int) throws {
guard (1450...2100).contains(value) else { throw YearError.outOfRange(value: value) }
self.value = value
}
}
Теперь тест для успехов через структуру кейса:
Пример (тест):
import XCTest
final class YearSuccessTableTests: XCTestCase {
struct Case { let input: Int; let expected: Int }
func testYear_successCases() throws {
let cases = [
Case(input: 1450, expected: 1450),
Case(input: 2026, expected: 2026)
]
for c in cases {
XCTAssertEqual(try Year(c.input).value, c.expected, "input: \(c.input)")
}
}
}
Структура Case может выглядеть “как будто лишняя”. Но как только вы добавите ещё 15 кейсов или захотите добавить поле comment, она станет самым простым способом удержать тест читабельным.
5. Диагностика падений в цикле
Одна из частых претензий к table-driven тестам у новичков: “Я прогнал 20 кейсов в цикле. Один упал. Но где именно?” Если не добавлять контекст, падение будет выглядеть как “expected 10, got 11” — и всё. А вы сидите и гадаете, какой из 20 входов это сделал.
Самый простой уровень диагностики — сообщение в ассёрте "input: \(…)". Но иногда хочется, чтобы IDE/лог тестов показывали отдельные “под‑шаги” внутри одного теста. Для этого в XCTest есть XCTContext.runActivity. Это не обязательная магия, а удобный способ группировать вывод.
Пример (тот же тест, но с activity):
import XCTest
final class ClampTableTests: XCTestCase {
struct Case { let x: Int; let min: Int; let max: Int; let expected: Int }
func testClamp_table() {
let cases = [
Case(x: -1, min: 0, max: 10, expected: 0),
Case(x: 11, min: 0, max: 10, expected: 10)
]
for c in cases {
XCTContext.runActivity(named: "x=\(c.x), range=\(c.min)...\(c.max)") { _ in
XCTAssertEqual(clamp(c.x, min: c.min, max: c.max), c.expected)
}
}
}
}
Пример (функция clamp, чтобы пример был честным):
func clamp(_ x: Int, min: Int, max: Int) -> Int {
if x < min { return min }
if x > max { return max }
return x
}
Если тест падает, вы видите, на какой activity он упал. Это сильно снижает уровень “угадайки”.
6. Пример для LibraryCLI: парсер команд
Теперь применим подход не к учебным функциям, а к тому, что реально живёт в CLI‑приложении. В LibraryCLI часто встречается парсинг текстовых команд: пользователь вводит строку, мы превращаем её в команду, либо отдаём ошибку формата. Это идеальная зона для table-driven тестов, потому что входов много, а проверка однотипная.
Предположим, у нас есть очень упрощённый парсер команды:
Пример (упрощённый production‑код):
enum Command: Equatable {
case help
case list
}
enum CommandParseError: Error, Equatable {
case empty
case unknownCommand(String)
}
func parseCommand(_ line: String) throws -> Command {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw CommandParseError.empty }
if trimmed == "help" { return .help }
if trimmed == "list" { return .list }
throw CommandParseError.unknownCommand(trimmed)
}
Теперь table-driven тест для успешных сценариев:
Пример (тест):
import XCTest
final class CommandParserSuccessTableTests: XCTestCase {
struct Case { let input: String; let expected: Command }
func testParseCommand_successCases() throws {
let cases = [
Case(input: "help", expected: .help),
Case(input: "list", expected: .list)
]
for c in cases {
XCTAssertEqual(try parseCommand(c.input), c.expected, "input: \(c.input)")
}
}
}
А теперь отдельно table-driven тесты для ошибок:
Пример (тест):
import XCTest
final class CommandParserErrorTableTests: XCTestCase {
func testParseCommand_errorCases() {
let cases = ["", " ", "HELp", "add"]
for input in cases {
XCTAssertThrowsError(try parseCommand(input), "input: \(input)") { error in
XCTAssertTrue(error is CommandParseError)
}
}
}
}
Этот тест нарочно чуть мягче: мы проверяем хотя бы тип ошибки. Если нужен строгий контракт, сделайте разные таблицы под .empty и под .unknownCommand(...), и сравнивайте конкретные значения ошибок через Equatable (как мы делали с BookIDError).
7. Типичные ошибки при table-driven тестах
Ошибка №1: тест превращается в мини‑программу с кучей условий.
Иногда хочется сделать одну таблицу, где часть кейсов ожидает успех, а часть — ошибку, и внутри цикла написать if. На практике это быстро превращается в тест, в котором есть собственная логика ветвления, а значит и риск ошибки уже в самом тесте. Лучше разделять: один тест‑метод на успехи, другой — на ошибки, чтобы внутри цикла была одна понятная проверка.
Ошибка №2: нет контекста, и падение трудно диагностировать.
Если в XCTAssertEqual не передать сообщение с входными параметрами, падение внутри цикла выглядит одинаково для всех 20 кейсов. Добавляйте "input: \(…)" или используйте XCTContext.runActivity, чтобы каждый кейс был “подписан”.
Ошибка №3: в таблицу складывают лишние поля.
Таблица кейсов — это не место для хранения половины приложения. Когда в кейсе появляются поля, которые не участвуют в проверке, тест становится шумным: читать тяжело, а смысл размывается. Хороший кейс хранит только то, что нужно для Arrange и Assert: вход и ожидание (результат или ошибка).
Ошибка №4: копипасту заменили на огромную таблицу без структуры.
Иногда вместо 20 тестов получается одна таблица на 80 строк, где трудно глазами отличить одно от другого. Если кейсов много, лучше использовать структуру Case с понятными именами полей, а иногда ещё и добавлять поле name/comment, чтобы у кейса было человеческое название.
Ошибка №5: смешивают проверку “не бросает” и проверку результата.
Фраза “не должно бросать” — это отдельное ожидание. Иногда полезно явно зафиксировать его XCTAssertNoThrow, а уже потом отдельным ассёртом проверить значение. Когда вы всё пишете одним выражением, диагностика становится беднее: непонятно, упало потому что бросило, или потому что вернуло не то.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ