1. Навіщо table-driven тести
Коли тестів стає понад п’ять, мозок починає підказувати «просте рішення»: скопіювати попередній тест, підставити кілька значень і рухатися далі. На короткій дистанції це здається швидким, але з часом ви отримуєте 20 майже однакових тестів, які важко читати, боляче змінювати й незручно налагоджувати. Table-driven підхід розв’язує цю проблему: ми описуємо сценарії даними й проганяємо їх через одну перевірку.
Уявіть, що тест — це не «історія», а «машина для перевірки». Такій машині байдуже, скільки у вас кейсів: 3, 20 чи 200. Головне — щоб кожен кейс був описаний зрозуміло, а якщо тест упаде, ви одразу бачитимете, який саме кейс зламався.
Ось невелика схема ідеї:
flowchart TD
A[Таблиця кейсів] --> B[Один тест-метод]
B --> C{Цикл за кейсами}
C --> D[Дія: викликаємо код]
D --> E[Перевірка: порівнюємо очікування]
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)")
}
}
}
Зверніть увагу на повідомлення в XCTAssertEqual: "вхід: \(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)") { 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, "вхід: \(c.input)")
}
}
}
Структура Case може здаватися зайвою. Але щойно ви додасте ще 15 кейсів або захочете додати поле comment, вона стане найпростішим способом зберегти читабельність тесту.
5. Діагностика падінь у циклі
Одна з частих претензій до table-driven тестів у новачків: «Я прогнав 20 кейсів у циклі. Один упав. Але де саме?» Якщо не додавати контекст, падіння виглядатиме як «expected 10, got 11» — і все. А ви сидите й гадаєте, який із 20 входів це спричинив.
Найпростіший рівень діагностики — повідомлення в XCTAssertEqual: "вхід: \(…)". Але іноді хочеться, щоб 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), діапазон=\(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 тестів, тому що входів багато, а перевірка однотипна.
Припустімо, у нас є дуже спрощений парсер команди:
Приклад (спрощений виробничий код):
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, "вхід: \(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)") { error in
XCTAssertTrue(error is CommandParseError)
}
}
}
}
Цей тест навмисно трохи мʼякший: ми перевіряємо хоча б тип помилки. Якщо потрібен жорсткіший контракт, зробіть окремі таблиці для .empty і для .unknownCommand(...), а конкретні значення помилок порівнюйте через Equatable (як ми робили з BookIDError).
7. Типові помилки під час table-driven тестів
Помилка № 1: тест перетворюється на мініпрограму з купою умов.
Іноді хочеться зробити одну таблицю, де частина кейсів очікує успіх, а частина — помилку, і всередині циклу написати if. На практиці це швидко перетворюється на тест, у якому є власна логіка розгалуження, а отже й ризик помилки вже в самому тесті. Краще розділяти: один тест-метод на успіхи, інший — на помилки, щоб у циклі була одна зрозуміла перевірка.
Помилка № 2: немає контексту, і падіння важко діагностувати.
Якщо в XCTAssertEqual не передати повідомлення з вхідними параметрами, падіння всередині циклу виглядає однаково для всіх 20 кейсів. Додавайте "вхід: \(…)" або використовуйте XCTContext.runActivity, щоб кожен кейс був «підписаний».
Помилка № 3: у таблицю складають зайві поля.
Таблиця кейсів — це не місце для зберігання половини застосунку. Коли в кейсі зʼявляються поля, які не беруть участі в перевірці, тест стає шумним: читати важко, а зміст розмивається. Хороший кейс зберігає лише те, що потрібно для Arrange і Assert: вхід і очікування (результат або помилка).
Помилка № 4: копіювання й вставлення замінили величезною таблицею без структури.
Іноді замість 20 тестів виходить одна таблиця на 80 рядків, де важко оком відрізнити одне від іншого. Якщо кейсів багато, краще використовувати структуру Case зі зрозумілими назвами полів, а іноді ще й додавати поле name/comment, щоб у кейса була людська назва.
Помилка № 5: змішують перевірку «не викидає виняток» і перевірку результату.
Фраза «не має викидати виняток» — це окреме очікування. Іноді корисно явно зафіксувати його XCTAssertNoThrow, а вже потім окремим асертом перевірити значення. Коли ви все пишете одним виразом, діагностика стає біднішою: незрозуміло, впало тому, що виник виняток, чи тому, що повернуло не те.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ