JavaRush /Курси /Swift SELF /Що тестувати першим: токенізатор/парсер, value objects

Що тестувати першим: токенізатор/парсер, value objects

Swift SELF
Рівень 52 , Лекція 2
Відкрита

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 прямо в тесті: це банально, зате максимально прозоро.

1
Задача
Swift SELF, 52 рівень, 2 лекція
Недоступна
Токени команди
Токени команди
1
Задача
Swift SELF, 52 рівень, 2 лекція
Недоступна
Парсер токенів
Парсер токенів
1
Задача
Swift SELF, 52 рівень, 2 лекція
Недоступна
Рік із домену
Рік із домену
1
Задача
Swift SELF, 52 рівень, 2 лекція
Недоступна
Пайплайн команди
Пайплайн команди
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ