JavaRush /Курси /Swift SELF /Тестованість value objects

Тестованість value objects

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

1. Тестованість і детермінізм value objects

Якщо ви тільки починаєте програмувати, слово «тестованість» може звучати так, ніби воно з корпоративного світу: краватки, мітинги, «у нас QA попросили». Насправді все значно буденніше: ви хочете швидко перевірити, що ваш BookID не приймає сміття, а Year не перетворюється на "рік 100500". І бажано — так, щоб перевірка не залежала від настрою компʼютера.

Тестованість value object зазвичай спирається на дві прості ідеї. Перша: у типу є явні входи (що ми передали) і явні виходи (що отримали: створений обʼєкт або причина відмови), без прихованих умов «під капотом». Друга: за однакового введення ви завжди отримуєте той самий результат. Тобто тип поводиться як чесна функція, а не як кіт, який сьогодні лагідний, а завтра скинув склянку й зробив вигляд, що так і було.

Зафіксуймо це майже як математичну схему:

rawInput  ──>  normalize  ──>  validate  ──>  storeCanonical
   |                |              |              |
   |                |              +--> fail (Issue / nil)
   |                +--> deterministic
   +--> deterministic

Якщо кожен крок у цій ланці детермінований, тоді value object легко перевіряти: ви можете зібрати таблицю прикладів і переконатися, що все працює саме так, як задумано.

Детермінізм: однакове введення → однаковий результат

Слово «детермінізм» звучить так, ніби воно з філософії («свобода волі, рок, доля»), але в програмуванні все набагато простіше. Детермінована функція — це функція, яка за однакових аргументів повертає один і той самий результат. Не «приблизно», не «залежно від того, хто дивиться», а строго однаково. Для value objects це буквально фундамент: якщо BookID("B-12") іноді створюється, а іноді ні — ваш тип перетворюється на лотерею.

Чому детермінізм безпосередньо впливає на тестованість? Тому що тест — це, по суті, договір: ми знаємо вхід і очікуємо конкретний вихід. Якщо результат залежить від Date(), random або налаштувань середовища, тести починають ламатися самі по собі. І це той випадок, коли проблема не в тестах — проблема в дизайні типу.

Давайте зробимо коротку таблицю: що є детермінованим, а що може зламати тестованість.

Що відбувається у value object Детерміновано? Чому
trimmingCharacters(in:), заміна "-" на "" Так Однаковий рядок → однаковий результат
Перевірка діапазону (0...9999).contains(year) Так Чиста математика без зовнішнього контексту
Використання Date() всередині init? Ні «Сьогодні» змінюється щодня
Використання Int.random(in: 0...10) всередині init? Ні Результат за визначенням випадковий
Перевірка «чи дозволений ID за базою даних» Ні (усередині value object) Це зовнішній ресурс, а отже він змінюється

Зверніть увагу на останній рядок: перевірка за базою даних або мережею може бути корисною, але це контекстна перевірка, а не інваріант value object. Якщо ви змішаєте це в одному місці, ваш тип стане важким для перевірки й незручним у використанні.

2. Зрозумілі помилки: причина як дані

Чому рядок — погана «причина помилки»

Дуже хочеться зробити так: «ну якщо невалідно — повернемо nil, а якщо потрібне повідомлення — просто повернемо рядок». Це нормальна перша думка, але рядок — поганий формат причини. Рядок не перевіряється компілятором, його легко випадково змінити, а без костилів на кшталт contains("занадто короткий") на ньому не можна безпечно гілкуватися. Тому для тестованості значно корисніше розділяти дві речі: причину і повідомлення.

Причина — це дані, тобто enum. Повідомлення — це вже форматування для людини: String, яке можна отримати з причини через обчислювальну властивість. Це робить систему передбачуванішою: тести перевіряють case, а не текст. А текст ви можете змінювати хоч щодня, доки не знайдете вдале формулювання, не ламаючи логіку.

Приклад BookIDIssue

Почнемо з маленького, але дуже практичного enum для BookID. Він описуватиме проблеми, а не абстрактне «invalid».

import Foundation

enum BookIDIssue: CustomStringConvertible {
    case empty
    case badPrefix(expected: String, actual: String)
    case missingDigits
    case nonDigitFound(actualSuffix: String)

    var description: String {
        switch self {
        case .empty:
            return "BookID порожній."
        case .badPrefix(let expected, let actual):
            return "BookID має починатися з \(expected), але отримано: \(actual)"
        case .missingDigits:
            return "Після префікса мають бути цифри, але їх немає."
        case .nonDigitFound(let suffix):
            return "Після префікса мають бути лише цифри, але отримано: \(suffix)"
        }
    }
}

Тут важливо не те, що ми використали CustomStringConvertible (це просто зручний формат виведення), а те, що ми можемо перевіряти причину як case. Тобто ми можемо перевірити: «для рядка "" повернеться .empty», а не «рядок повідомлення дорівнює ось такому тексту». Текст — це шар представлення, і йому можна бути гнучким.

3. Тестований BookID: validate і init?

Єдине джерело правил: validate(_)

Поки у вас немає єдиного джерела правил, перевірки виходять незручними: ви перевіряєте одне, а init? усередині робить інше. Тому типовий дизайн для тестованого value object такий: ви робите validate(_) -> Issue?, і вже на її основі будуєте init?. Тоді ті самі правила гарантовано застосовуються і в тестах, і під час створення обʼєкта.

Пишемо BookID так, щоб він зберігав канонічне значення й залишався детермінованим:

import Foundation

struct BookID {
    let value: String

    static func validate(_ raw: String) -> BookIDIssue? {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty { return .empty }

        let prefix = "B-"
        if !trimmed.hasPrefix(prefix) {
            return .badPrefix(expected: prefix, actual: trimmed)
        }

        let suffix = trimmed.dropFirst(prefix.count)
        if suffix.isEmpty { return .missingDigits }

        for ch in suffix {
            if !ch.isNumber {
                return .nonDigitFound(actualSuffix: String(suffix))
            }
        }

        return nil
    }

    init?(_ raw: String) {
        guard BookID.validate(raw) == nil else { return nil }
        self.value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
    }
}

Зверніть увагу на маленьку, але важливу деталь: всередині init? ми не повторюємо перевірки вручну. Ми спираємося на validate. Це зменшує кількість місць, де можна помилитися, і значно покращує тестованість: перевіряючи validate, ви фактично перевіряєте весь інваріант типу.

Де потрібен Issue, а де достатньо init?

Тепер ми можемо використовувати «причину як дані» там, де потрібне пояснення:

let raw = "B-12A"
if let issue = BookID.validate(raw) {
    print(issue.description) // Після префікса мають бути лише цифри, але отримано: 12A
}

А там, де пояснення не потрібне, залишаємо простий контракт init?:

let id = BookID("B-123") // BookID?
print(id?.value ?? "nil") // B-123

Такий дизайн одночасно зручний і для API, і для перевірок.

4. Нормалізація і канонічний вигляд

Іноді студенти сприймають нормалізацію як «косметику»: мовляв, «прибрали пробіли — і досить». Але нормалізація — це прямий підсилювач тестованості. Чому? Тому що вона прибирає багато варіантів одного й того самого змісту. Якщо ви дозволили користувачу вводити ISBN із дефісами, пробілами та різними форматами, то всередині системи хочете зберігати один стандарт, інакше порівняння та пошук перетворюються на хаос.

Давайте зробимо value object «у стилі ISBN-13», який зберігає лише цифри. На вході можуть бути дефіси й пробіли, а всередині буде строго 13 цифр. Це дуже зручно для перевірок: ми заздалегідь знаємо, що саме там опиниться.

import Foundation

struct ISBN13Like {
    let digits: String

    static func normalize(_ raw: String) -> String {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        let noDashes = trimmed.replacingOccurrences(of: "-", with: "")
        let noSpaces = noDashes.replacingOccurrences(of: " ", with: "")
        return noSpaces
    }

    init?(_ raw: String) {
        let cleaned = ISBN13Like.normalize(raw)
        guard cleaned.count == 13 else { return nil }

        for ch in cleaned {
            guard ch.isNumber else { return nil }
        }

        self.digits = cleaned
    }
}

Тут normalize — чиста функція, і це подарунок для перевірок. Ми можемо окремо перевірити: «введення з дефісами перетворюється на рядок цифр». І можемо окремо перевірити init?: «після нормалізації довжина має бути 13, і лише цифри». Важливо, що обидва кроки детерміновані.

5. Самоперевірки без XCTest

Поки що в курсі ми ще не використовуємо фреймворк тестування (ми не беремо XCTest на цьому етапі), але перевіряти себе все одно можна і потрібно. У навчальному проєкті LibraryCLI ми можемо зробити просту самоперевірку у вигляді функції, яка проганяє набір кейсів і друкує результат. Так, це не промисловий тест-ранер, але це вже дисципліна: вхід → очікуваний вихід → перевірка.

Зробімо два маленькі помічники: expectTrue і expectEqual. Вони друкуватимуть «ГАРАЗД» / «ПОМИЛКА». Це звучить смішно просто — і в цьому вся привабливість.

func expectTrue(_ condition: Bool, _ name: String) {
    if condition {
        print("ГАРАЗД: \(name)")
    } else {
        print("ПОМИЛКА: \(name)")
    }
}

func expectEqual(_ a: String, _ b: String, _ name: String) {
    expectTrue(a == b, name)
}

Тепер — табличні кейси для BookID. Зверніть увагу: ми перевіряємо не рядок помилки, а case — це стабільніше.

func runBookIDChecks() {
    expectTrue(BookID("B-1") != nil, "BookID створюється для B-1")
    expectTrue(BookID("") == nil, "BookID не створюється для порожнього рядка")

    let issue = BookID.validate("A-12")
    expectTrue(issue != nil, "Є причина відмови для A-12")
}

Це базово. Але давайте зробимо трохи доросліше: таблицю з входом і очікуваним результатом. Без узагальнень, без магії, просто чесно.

struct BookIDCase {
    let raw: String
    let shouldCreate: Bool
}

func runBookIDTable() {
    let cases = [
        BookIDCase(raw: "B-123", shouldCreate: true),
        BookIDCase(raw: " B-007 ", shouldCreate: true),
        BookIDCase(raw: "B-", shouldCreate: false),
        BookIDCase(raw: "B-12A", shouldCreate: false)
    ]

    for c in cases {
        let created = BookID(c.raw) != nil
        expectTrue(created == c.shouldCreate, "Випадок BookID: \(c.raw)")
    }
}

Важливий момент: такі перевірки добре працюють лише тоді, коли ваш код детермінований. Якщо ви раптом вирішите: «а давайте в init? перевіряти поточний рік», — половина кейсів почне залежати від календаря, а ви — від валеріани.

6. Контекстні правила і час

Дуже типова помилка валідації — використовувати «поточний час» усередині value object. Наприклад, ви хочете заборонити рік публікації, який більший за поточний. Логіка звучить розумно, але з погляду тестованості це мінне поле: сьогодні тест проходить, завтра (1 січня) — падає. І це не жарт, а буквально щорічна традиція.

Правильна ідея: value object має відповідати за інваріант, а «поточний рік» — це контекст. Контекст потрібно передавати ззовні, параметром. Тоді ви можете зафіксувати його в перевірці.

Зробимо Year як базовий тип: «взагалі рік».

struct Year {
    let value: Int

    init?(_ value: Int) {
        guard (0...9999).contains(value) else { return nil }
        self.value = value
    }
}

А тепер — контекстна перевірка: «рік публікації не більший за maxYear». Це вже не інваріант «року», а правило сценарію. І воно тестоване, тому що maxYear — вхідний параметр.

func isAllowedPublicationYear(_ year: Year, maxYear: Int) -> Bool {
    year.value <= maxYear
}

Тоді перевірка виглядає стабільно:

func runYearChecks() {
    let y = Year(2024)
    expectTrue(y != nil, "Year створюється для 2024")

    if let y {
        expectTrue(isAllowedPublicationYear(y, maxYear: 2025), "2024 <= 2025")
        expectTrue(!isAllowedPublicationYear(y, maxYear: 2020), "2024 > 2020")
    }
}

І так, ви все ще можете отримати поточний рік десь зовні — наприклад, у UI або в CLI-шарі, але value object від цього не залежить. Він як хороший охоронець: перевіряє паспорт на вході, а не цікавиться прогнозом погоди.

7. Self-check у LibraryCLI

Зараз наш застосунок LibraryCLI розвивається поступово: ми додаємо доменні типи, команди, парсинг, сховище тощо — усе невеликими кроками. На цьому етапі зручно мати тимчасову можливість прогнати самоперевірку вручну, щоб не гадати, чому знову не створюється BookID.

Найпростіший і найчесніший спосіб — зробити функцію runSelfChecks() і викликати її з коду верхнього рівня в режимі розробки. Важливо: це не «фіча» для користувача, це ремінь безпеки для вас.

func runSelfChecks() {
    print("=== Самоперевірки ===")
    runBookIDChecks()
    runBookIDTable()
    runYearChecks()
    print("=== Готово ===")
}

А виклик — тимчасово напряму:

runSelfChecks()

Коли ви будете впевнені в типах, цей виклик можна прибрати. Сенс не в тому, щоб назавжди тримати «самотести» в main, а в тому, щоб навчитися мислити як інженер: «якщо правило важливе, я можу швидко перевірити його на наборі прикладів».

8. Типові помилки

Помилка №1: «Перевірю помилку за рядком, бо так простіше».
Коли причина відмови перетворюється на String, ви втрачаєте гарантію компілятора й передбачуваність. Сьогодні ви написали "BookID порожній", завтра змінили на "Порожній BookID", і раптом десь у коді перестала працювати гілка if message.contains("порожній"). Правильніше тримати причину в enum, а рядок отримувати лише для виведення.

Помилка №2: недетерміновані залежності всередині init?.
Якщо всередині value object зʼявляються Date(), random(), читання файла або будь-які інші зовнішні фактори, перевірки стають нестабільними. У такі моменти тестованість «помирає мовчки»: ви починаєте думати, що «тести погані», хоча насправді тип робить занадто багато й залежить від середовища.

Помилка №3: дублювання правил у двох місцях — у validate і в init?.
Дуже легко випадково зробити так, що validate вважає рядок коректним, а init? за іншою логікою його відкидає (або навпаки). Зовні це виглядає як «магія», а по суті — як два джерела правди. Найстабільніший дизайн: init? спирається на validate, а не повторює її.

Помилка №4: відсутність нормалізації або нормалізація абияк.
Якщо ви не фіксуєте канонічний вигляд, у вас зʼявляються «еквівалентні, але різні» значення: "B-007" і " B-007 " перестають бути однаковими, "978-1-23..." і "978123..." живуть окремо. Це робить перевірки та порівняння мутними. Нормалізація має бути частиною контракту: однаковий зміст → однакове зберігання.

Помилка №5: спроба втиснути контекстні правила всередину базового типу.
Рік як Year — це інваріант «це взагалі рік». А «рік публікації не більший за поточний» — це правило конкретного сценарію. Якщо ви змішаєте це, тип стане занадто суворим і прив’язаним до «сьогодні», а перевірки залежатимуть від часу. Контекст краще передавати параметром в окрему функцію або в окремий, більш вузький тип.

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