1. Чому важливо визначити, що тестувати спочатку
Якщо почати писати тести «на все підряд», дуже швидко зʼявиться відчуття, ніби ви вʼяжете светр із мокрої локшини: наче щось робите, а результат або не гріє, або розвалюється. У тестуванні особливо важливо обирати перші цілі, бо саме вони задають стиль, рівень дисципліни й навіть архітектуру. Поганий перший набір тестів робить проєкт нервовим: будь-яка зміна ламає половину тестів, і ви перестаєте їм довіряти.
У нашому курсі ми поступово будуємо CLI‑застосунок LibraryCLI: він читає команди користувача, розбирає їх, перетворює на «зрозумілі застосунку» структури (команди/моделі), перевіряє доменні правила й виконує дію. Тому «найвигідніші» перші тести — це ті місця, де логіка детермінована (однаковий вхід → однаковий вихід) і де помилка особливо болісна, бо ламає весь користувацький сценарій.
Нижче буде простий принцип: спочатку фіксуємо «мозок» — розбір вводу й доменні правила, а вже потім торкаємося місць, де є зовнішнє середовище й невизначеність.
2. Карта пріоритетів: максимум користі за мінімум зусиль
Коли ви дивитеся на код і думаєте: «Що б мені протестувати?», корисно мати не натхнення, а коротку карту. Вона не висічена в камені, але для CLI‑застосунку працює напрочуд стабільно: що ближче код до чистої функції — без файлів, мережі й часу — то краще він підходить для перших unit‑тестів.
Накреслимо таблицю пріоритетів саме для LibraryCLI:
| Кандидат на перші тести | Чому це вигідно | Як зазвичай виглядає результат |
|---|---|---|
| Tokenizer (токенізація рядка) | Увесь CLI починається з рядка; помилки токенізації псують усе далі за ланцюжком | String → [Token] або String → [String] |
| Parser (розбір токенів у команду) | Це «переклад» користувацької мови в Command | [Token] → CommandParseResult / Command? |
| Value objects (Year, BookID, NonEmptyString) | Це маленькі правила, які легко зламати й приємно зафіксувати тестом | init? повертає значення або nil |
| Доменна валідація (інваріанти Book, правила Library) | Це бізнес-сенс застосунку: «що вважається коректним» | «Валідно/невалідно», без зовнішніх ефектів |
А щоб зовсім не загубитися, можна уявляти собі ланцюжок CLI так:
flowchart LR
A["Вхід користувача: String"] --> B["Токенізатор: токени"]
B --> C["Парсер: команда"]
C --> D["Домен: перевірка правил"]
D --> E["Дія: оновити стан / репозиторій"]
Ідея проста: якщо ранній етап зламається, усе праворуч почне отримувати «сміття» й поводитися дивно. Тому перші тести ми ставимо як паркан перед входом.
3. Tokenizer: як застосунок бачить рядок
Tokenizer — це та частина коду, яка перетворює «людський ввід» на «цеглинки» для парсера. У CLI дуже часто проблема не в тому, що команда неправильна, а в тому, що пробілів було три, лапки не закрилися, а користувач узагалі хотів додати книгу з назвою The Lord of the Rings. Без лапок це перетворюється на чотири окремі слова. Tokenizer — це ваш перший фільтр реальності.
Добра новина в тому, що tokenizer майже завжди можна зробити максимально детермінованим. Тобто один і той самий рядок завжди має перетворюватися на один і той самий набір токенів. Отже, тести будуть простими, швидкими й корисними. І це ідеальний старт, щоб відчути смак unit‑тестів без болю.
Припустімо (у дусі курсу), що в нас є мінімальний tokenizer:
import Foundation
struct Tokenizer {
func tokenize(_ line: String) -> [String] {
line.split(whereSeparator: \.isWhitespace).map(String.init)
}
}
Тепер пишемо тести не «на реалізацію», а на поведінку. Нам важливі пробіли, порожній ввід і проста команда.
import XCTest
@testable import LibraryCLI
final class TokenizerTests: XCTestCase {
func testTokenize_whenSingleWord_returnsOneToken() {
let sut = Tokenizer()
XCTAssertEqual(sut.tokenize("list"), ["list"])
}
}
Далі додамо тест на «багато пробілів». Це класика: ви бачите одну команду, а комп’ютер — або порожні токени, або взагалі несподіваний набір.
import XCTest
@testable import LibraryCLI
final class TokenizerWhitespaceTests: XCTestCase {
func testTokenize_whenManySpaces_collapsesWhitespace() {
let sut = Tokenizer()
XCTAssertEqual(sut.tokenize(" add Dune "), ["add", "Dune"])
}
}
Тепер граничний випадок: порожній рядок або рядок із пробілів. У CLI це не рідкість, це понеділок.
import XCTest
@testable import LibraryCLI
final class TokenizerEmptyInputTests: XCTestCase {
func testTokenize_whenOnlySpaces_returnsEmptyArray() {
let sut = Tokenizer()
XCTAssertEqual(sut.tokenize(" "), [])
}
}
Зверніть увагу на важливий момент: ми поки не обговорюємо складні правила токенізації — лапки, екранування. Навіть якщо ваш tokenizer уже підтримує лапки, перші тести все одно варто почати з простого. Це як застібати пасок безпеки: спершу вчимося робити базово правильно, потім — красиво.
4. Parser: переклад токенів у команду
Якщо tokenizer відповідає на запитання «з чого складається рядок», то parser відповідає на запитання «що користувач хотів зробити». У нашому LibraryCLI це зазвичай перетворення токенів на Command (enum), а потім виконання цієї команди. Parser — ідеальна ціль для перших тестів, бо він теж детермінований: для одного й того самого набору токенів результат має бути однаковим.
Важливо не потрапити в пастку: новачки іноді намагаються тестувати parser за внутрішніми кроками, ніби parser — це серіал. Але тестам цікавий фінал серії: яка команда вийшла або яка помилка виникла. Тому ми тестуємо вхід і вихід, не зазираючи за лаштунки.
Спрощена модель команди (приклад):
import Foundation
enum Command: Equatable {
case list
case add(title: String)
}
struct CommandParser {
func parse(_ tokens: [String]) -> Command? {
guard let head = tokens.first?.lowercased() else { return nil }
if head == "list", tokens.count == 1 { return .list }
if head == "add", tokens.count == 2 { return .add(title: tokens[1]) }
return nil
}
}
Перші тести — це успішний сценарій, або happy path. Так, тестувати лише happy path — помилка, але як стартова точка він хороший: ви фіксуєте базову працездатність.
import XCTest
@testable import LibraryCLI
final class CommandParserHappyPathTests: XCTestCase {
func testParse_list_returnsListCommand() {
let sut = CommandParser()
XCTAssertEqual(sut.parse(["list"]), .list)
}
}
Тепер додамо команду з аргументом:
import XCTest
@testable import LibraryCLI
final class CommandParserAddTests: XCTestCase {
func testParse_addWithTitle_returnsAddCommand() {
let sut = CommandParser()
XCTAssertEqual(sut.parse(["add", "Dune"]), .add(title: "Dune"))
}
}
І нарешті — найцінніший тест у парсері: «невалідний ввід має бути відхилений». Це фіксує межу поведінки: parser не намагається вгадувати замість користувача.
import XCTest
@testable import LibraryCLI
final class CommandParserInvalidInputTests: XCTestCase {
func testParse_unknownCommand_returnsNil() {
let sut = CommandParser()
XCTAssertNil(sut.parse(["launch", "missiles"]))
}
}
Якщо ваш проєкт уже використовує не Command?, а більш промовисту модель результату (наприклад enum на кшталт CommandParseResult), тести стають іще кориснішими: ви фіксуєте не лише факт помилки, а й її тип. Але навіть без цього nil як «не вдалося розпарсити» — нормальна детермінована база, яку легко тестувати в перші дні.
5. Value objects: маленькі правила, які тримають домен
Value object — це тип, що зберігає значення й гарантує коректність цього значення. Зазвичай це щось на кшталт Year, BookID, «непорожній рядок», «валідний slug». Перевага value objects у тому, що вони маленькі, а користі від них багато: вони переносять перевірку коректності з розкиданих по проєкту if‑ів в одне місце.
І, що особливо приємно для тестів, value objects майже завжди ідеально детерміновані. Жодних файлів, часу, випадковості — лише число або рядок на вході, і або коректний об’єкт, або nil. Тому value objects — це золота жила для перших unit‑тестів: вони дають швидке відчуття контролю над правилами.
Приклад Year із простим діапазоном:
import Foundation
struct Year: Equatable {
let value: Int
init?(_ value: Int) {
guard (1500...2100).contains(value) else { return nil }
self.value = value
}
}
Тест на валідне значення:
import XCTest
@testable import LibraryCLI
final class YearTests: XCTestCase {
func testYear_whenInRange_createsValue() {
XCTAssertEqual(Year(2020), Year(2020))
}
}
Тест на невалідне значення:
import XCTest
@testable import LibraryCLI
final class YearInvalidTests: XCTestCase {
func testYear_whenTooSmall_returnsNil() {
XCTAssertNil(Year(1200))
}
}
Тепер приклад BookID. У реальному проєкті це може бути UUID, рядок певного формату або щось інше. Для перших тестів головне — щоб правило було чітким і перевірним.
import Foundation
struct BookID: Equatable {
let rawValue: String
init?(rawValue: String) {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
self.rawValue = trimmed
}
}
І тести:
import XCTest
@testable import LibraryCLI
final class BookIDTests: XCTestCase {
func testBookID_whenNonEmptyString_createsValue() {
XCTAssertEqual(BookID(rawValue: "B-001")?.rawValue, "B-001")
}
func testBookID_whenEmptyAfterTrimming_returnsNil() {
XCTAssertNil(BookID(rawValue: " "))
}
}
Зверніть увагу на тонку, але важливу думку: тести value objects — це тести ваших правил. Коли за місяць ви змінюватимете формат ID або діапазон року, тести не дадуть «тихо» зламати поведінку. І це не обмеження, а пасок безпеки: можна їхати швидше, бо менше шансів злетіти в кювет.
6. Доменна валідація: інваріанти та сенс даних
Доменна валідація — це місце, де ми відповідаємо на запитання: «Які дані вважаються коректними в нашому застосунку?» Це не про пробіли й не про синтаксис команд. Це про сенс. Наприклад: книга не може мати порожній заголовок, рік не може бути в майбутньому, якщо ви так вирішили, і не можна додати до бібліотеки дві книги з однаковим ID.
З точки зору перших unit‑тестів доменна валідація хороша тим, що вона теж може бути чистою й детермінованою, якщо ви тримаєте правила всередині доменних типів (Book, Library) і не змішуєте їх із файлами, логуванням чи вводом через CLI. А ми якраз увесь курс до цього йшли: розділяти правила й побічні ефекти.
Спрощена модель Book, у якій інваріанти тримаються в init?:
import Foundation
struct Book: Equatable {
let id: BookID
let title: String
let year: Year
init?(id: BookID, title: String, year: Year) {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else { return nil }
self.id = id
self.title = trimmedTitle
self.year = year
}
}
Тестуємо доменне правило «порожній заголовок заборонено»:
import XCTest
@testable import LibraryCLI
final class BookValidationTests: XCTestCase {
func testBook_whenTitleIsEmptyAfterTrimming_returnsNil() {
let id = BookID(rawValue: "B-1")!
let year = Year(2020)!
XCTAssertNil(Book(id: id, title: " ", year: year))
}
}
Тепер правило «коректний об’єкт створюється»:
import XCTest
@testable import LibraryCLI
final class BookHappyPathTests: XCTestCase {
func testBook_whenAllFieldsValid_createsBook() {
let id = BookID(rawValue: "B-2")!
let year = Year(1965)!
let book = Book(id: id, title: "Dune", year: year)
XCTAssertNotNil(book)
}
}
Якщо у вашому проєкті доменні правила живуть не в init?, а в методах — наприклад mutating func rename(to:) → Bool, — то тести виглядатимуть майже так само: Arrange (створили книгу) → Act (викликали метод) → Assert (перевірили, що сталося або не сталося). Головне — тримати правила так, щоб їх можна було перевірити без підняття всієї системи.
7. Склейка шарів і перші тести
Як тестувати ланцюжок без гігантських сценаріїв
Коли з’являється кілька шарів підряд, у новачків часто виникає бажання написати один тест розміром із роман: «користувач увів рядок, він токенізувався, розпарсився, створилася книга, додалася до бібліотеки, записалася у файл, відобразилася в консолі — і все це в одному testEverything()». Такий тест виглядає героїчно, але практичної користі дає мало: якщо він упаде, ви не зрозумієте, де саме проблема.
На старті правильніше триматися принципу: один тест — одна причина падіння. Тому ми робимо окремі тести на tokenizer, окремі на parser, окремі на доменні правила. А «склейку» в єдиний сценарій ми можемо робити дуже точково, буквально в одному-двох smoke‑тестах, і лише після того, як шари стабілізовані.
Мініприклад «склейки» без інфраструктури — лише рядок → команда. Такий тест корисний як перевірка, що ви не переплутали інтерфейси між шарами:
import XCTest
@testable import LibraryCLI
final class ParsePipelineSmokeTests: XCTestCase {
func testPipeline_addLine_producesAddCommand() {
let tokens = Tokenizer().tokenize("add Dune")
let command = CommandParser().parse(tokens)
XCTAssertEqual(command, .add(title: "Dune"))
}
}
Цей тест навмисно простий. Він не замінює тести tokenizer і parser, а лише підтверджує, що разом вони теж працюють на базовому кейсі.
Практичний порядок: як вибрати перші 10 тестів і не вигоріти
Щоб почати тестування в живому проєкті й не втонути, корисно діяти як під час прибирання кімнати: не намагатися вилизати все одразу. Беремо найчастіші й найкритичніші сценарії, але дробимо їх на маленькі правила.
У контексті LibraryCLI розумний перший набір тестів зазвичай виглядає так: кілька тестів на токенізацію (звичайний рядок, багато пробілів, порожній рядок), кілька тестів на парсинг (відома команда, невідома команда, неправильна кількість аргументів), кілька тестів на value objects (Year, BookID) і пара тестів на доменний Book (порожній заголовок заборонено, коректний створюється). Це вже дасть дуже приємний ефект: ви зможете змінювати формат команд і правила валідації, а тести підкажуть, що ви випадково зламали.
І ще один «чесний» момент: перші тести мають бути настільки простими, щоб вам було нудно. Нудьга в тестах — це добре. Це означає, що тест не намагається бути розумнішим за код, а просто фіксує контракт.
8. Типові помилки
Помилка №1: починати з тестів «усього сценарію» замість маленьких правил.
Зазвичай це виглядає як один величезний тест, який перевіряє все підряд: і токенізацію, і парсинг, і доменні правила, і формат виводу. Такий тест важко читати й неможливо швидко діагностувати. Набагато стійкіше спочатку покрити кожен шар окремо, а інтеграційну «склейку» залишити мінімальною — як smoke‑перевірку.
Помилка №2: тестувати реалізацію замість поведінки.
Наприклад, ви написали tokenizer через split, а потім у тестах намагаєтеся перевірити, що він викликає саме split, або що він робить «рівно два проходи по рядку». Це робить тест крихким: ви зміните реалізацію на зручнішу, а тести впадуть, хоча поведінка не змінилася. У unit‑тестах ми фіксуємо: який вхід → який вихід.
Помилка №3: ігнорувати граничні випадки (порожньо, пробіли, неправильна кількість аргументів).
CLI живе на «людському вводі», а людина — істота творча: зайвий пробіл, порожній рядок, три аргументи замість двох. Якщо тести покривають лише ідеальні випадки, вони створюють хибне відчуття безпеки, і застосунок падатиме в найбанальніших ситуаціях.
Помилка №4: писати тест, який повторює алгоритм тестованого коду.
Якщо ви в тесті обчислюєте очікуваний результат тим самим способом, що й код, ви ризикуєте отримати «дві однакові помилки, що збіглися». Особливо небезпечно це в парсері: не треба в тесті заново реалізовувати парсер, щоб перевірити парсер. Очікування мають бути простими й явними.
Помилка №5: перетворювати setUp() на «приховану історію» і втрачати ясність вхідних даних.
setUp() добрий для базової підготовки, але якщо ви створюєте там десяток сутностей і половину сценарію, тест перестає читатися. Для tokenizer/parser/value objects часто взагалі простіше створювати SUT прямо в тесті: це банально, зате максимально прозоро.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ