Стейт-машини. Частина 2: Тестуємо стейт-машини.

Частина 1
Частина 2
Частина 3

Ви знаєте, із тестуванням нє всьо так адназначна.

Ось погана сторона: коли ви збільшуєте пакриття тестами до такої мєри, шо ви починаєте знаходити більше баґів, більше крешів. Тому я кажу: “Люди, будь ласка, сповільніть тестування!”

~ ISO Trump++

У спільноті iOS-інженерів прийнято позитивно ставитись до юніт-тестування, ну чи принаймні я останні кілька років не зустрічав палких противників цієї практики. Однак, на запитання “а як у вас на проєкті з юніт-тестами?”, багато інженерів все ще з сумом відводять очі вбік, мовляв, нам замовник не оплачує юніт-тести, у нас не вистачає на це часу, у нас є якась інша причина це не робити.

Це направду складна проблема: як продати замовнику (внутрішньому чи зовнішньому) юніт-тести? Я сам дуже довго шукав відповідь на це болюче питання, повторюючи його від проєкту до проєкту, допоки не напрацював доволі просту, елегантну і водночас напрочуд глибоку формулу, котра здатна примусити юніт-тести з’явитись на кожному проєкті, де вони потрібні, незалежно від волі замовника. Формула ця дуже проста:

– Як продати юніт-тести? – НІЯК!

Дійсно, ну яка різниця вашому продакт-оунеру, чи є у вас в проєкті автотести, чи нема? Він же не вказує вам, в якій IDE писати код, яким git-клієнтом користуватись, чи користуватись git-flow? Йому потрібен результат. Йому потрібно, щоб із внесенням змін у його продукт, наявна функціональність не розвалювалась. А те, як ви йому це реалізуєте, вашого продакт-оунера чи замовника хвилювати не повинно. Писати чи не писати тести – це ваш вибір, зробити його – це ваша інженерна відповідальність, і не варто перекладати її на плечі ваших колег не інженерів.

Наступною за популярністю проблемою впровадження юніт-тестування є відсутність інтуїції в їх застосуванні. Що я маю на увазі? Наприклад, дуже часто люди кажуть “у нас на проєкті нема 100% покриття коду тестами, однак ми покриваємо найбільш критичну функціональність і тому ми маємо баланс між покриттям коду і затраченим часом”. На словах звучить раціонально, солідно і дуже зріло. На практиці ж мова може йти про проєкт з трьома тестами на весь код. Я знаю про що я кажу, бо й сам певний час цим хворів.

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

До чого тут стейт-машини?

Покриття юніт-тестами стейт-машин – це ідеальний стартовий майданчик, щоб почати набивати руку на написання юніт-тестів. По-перше, добре написані стейт-машини рідко мають якісь залежності, тому для написання тестів нам не потрібно створювати моки. По-друге, стейт-машини містять у собі чисту бізнес-логіку, котру, як правило, слід покривати тестами у першу чергу. По-третє, стейт-машини перетворюють складну бізнес-логіку у лінійну, і тому їх дуже легко покривати тестами.

Іншими словами, з одного боку, покриття стейт-машин тестами як правило має багато сенсу, а з іншого, їх покриття як правило дешеве в плані часу.

Спробуємо покрити тестами стейт-машину з попередньої статті (ту, що моделювала холодильник):

class FridgeStateMachine {
    enum State {
        case cooling
        case waiting
    }
  
    enum Event {
        case minTemperatureReached
        case maxTemperatureReached
    }
  
    fileprivate(set) var state: State = .cooling
  
    func transition(with event: Event) {
        switch (state, event) {
            case (.cooling, .minTemperatureReached):
                state = .waiting
            case (.cooling, .maxTemperatureReached):
                break
          
            case (.waiting, .maxTemperatureReached):
                state = .cooling
            case (.waiting, .minTemperatureReached):
                break
        }
    }
}

Для цього нам знадобиться додатковий ініціалізатор, за допомогою якого ми зможемо створити стейт-машину з будь-яким початковим станом:

extension FridgeStateMachine {
    convenience init(initialState: State) {
        self.init()
        self.state = state
    }
}

Щоб покрити стейт-машину тестами, слід для кожного можливого початкового стану та для кожної можливої вхідної події показати, що вона змінює або не змінює свій стан коректно. Тому для стейт-машини, що описує роботу холодильника, ми маємо наступні тестові випадки:

Початковий стан Вхідна подія Очікуваний стан
waiting maxTemperatureReached cooling
waiting minTemperatureReached waiting
cooling maxTemperatureReached cooling
cooling minTemperatureReached waiting

Ці випадки можна досить просто покрити тестами:

class FridgeStateMachineTests: XCTestCase {
    func testTransitions() {
        // SUT - System Under Test, тобто система, що тестується
        var sut = FridgeStateMachine(initialState: .waiting)
        sut.transition(with: .maxTemperatureReached)
        XCTAssertEqual(sut.state, .cooling)

        sut = FridgeStateMachine(initialState: .waiting)
        sut.transition(with: .minTemperatureReached)
        XCTAssertEqual(sut.state, .waiting)

        sut = FridgeStateMachine(initialState: .cooling)
        sut.transition(with: .maxTemperatureReached)
        XCTAssertEqual(sut.state, .cooling)

        sut = FridgeStateMachine(initialState: .cooling)
        sut.transition(with: .minTemperatureReached)
        XCTAssertEqual(sut.state, .waiting)
    }
}

Питання: як зміниться кількість тестів, якщо ми додамо новий стан, або нову подію? Уявімо собі, наприклад, що ми хочемо вимикати охолодження при відкритті дверцят холодильника. Для цього ми додамо нову подію doorOpened. Компілятор Swift змусить нас подумати про 2 речі: що робити, коли ця подія приходить при стані cooling, і що робити, коли вона приходить при стані waiting:

class DoorFridgeStateMachine {
    enum State {
        case cooling
        case waiting
    }

    enum Event {
        case minTemperatureReached
        case maxTemperatureReached
        case doorOpened
    }

    fileprivate(set) var state: State = .cooling
    init(initialState: State) {
        self.state = initialState
    }

    func transition(with event: Event) {
        switch (state, event) {
        case (.cooling, .minTemperatureReached):
            state = .waiting
        case (.cooling, .doorOpened):
            state = .waiting
        case (.cooling, .maxTemperatureReached):
            break

        case (.waiting, .maxTemperatureReached):
            state = .cooling
        case (.waiting, .minTemperatureReached),
             (.waiting, .doorOpened):
            break
        }
    }
}

Скільки тестових випадків слід покрити тепер? Більше на стільки, скільки у нас станів, тобто на 2:

Початкови стан Вхідна подія Очікуваний стан
waiting maxTemperatureReached cooling
waiting minTemperatureReached waiting
waiting doorOpened waiting
cooling maxTemperatureReached cooling
cooling minTemperatureReached waiting
cooling doorOpened waiting

Додавання кожної нової події в стейт-машину збільшує кількість тестових випадків на кількість наявних подій, а додавання кожного нового стану збільшує кількість випадків на кількість наявних подій. Стейт-машини перетворюють складну логіку на лінійну, але недоліком цього є стрімке зростання кількості тестових випадків. Іншими словами, проти нас починає працювати комбінаторика:

count(Test Cases) = count(Events) * count(States)

Табличне тестування

Є багато способів зменшити кількість коду у тестах стейт-машин, одним із них є табличне тестування. Подивімось уважніше на тести з попередніх прикладів:

...
sut = FridgeStateMachine(initialState: .waiting)
sut.transition(with: .maxTemperatureReached)
XCTAssertEqual(sut.state, .cooling)

sut = FridgeStateMachine(initialState: .waiting)
sut.transition(with: .minTemperatureReached)
XCTAssertEqual(sut.state, .waiting)
...

Велика кількість коду у цих випадках копіюється, і ми можемо це оптимізувати:

class DoorFridgeStateMachineTests: XCTestCase {
    func testTransitions() {
        struct TestCase {
            var stateFrom: DoorFridgeStateMachine.State
            var event: DoorFridgeStateMachine.Event
            var extectedState: DoorFridgeStateMachine.State
        }

        let testCases: [TestCase] = [
            .init(stateFrom: .waiting, event: .maxTemperatureReached, extectedState: .cooling),
            .init(stateFrom: .waiting, event: .minTemperatureReached, extectedState: .waiting),
            .init(stateFrom: .cooling, event: .maxTemperatureReached, extectedState: .cooling),
            .init(stateFrom: .cooling, event: .minTemperatureReached, extectedState: .waiting),
        ]

        for testCase in testCases {
            let sut = DoorFridgeStateMachine(initialState: testCase.stateFrom)
            sut.transition(with: testCase.event)
            XCTAssertEqual(sut.state, testCase.extectedState)
        }
    }
}

Ми створюємо допоміжну структуру TestCase, в котрій описуємо дані для тестового випадку. Потім ми визначаємо масив testCases з усіма тестовими випадками, а далі описуємо узагальнений тестовий сценарій у циклі for-in, що ітерує по елементах масиву testCases. Тепер нові тестові випадки можна додавати одним простим рядком у визначенні масиву testCases, решту коду змінювати не потрібно:

let testCases: [TestCase] = [
    .init(stateFrom: .waiting, event: .maxTemperatureReached, extectedState: .cooling),
    .init(stateFrom: .waiting, event: .minTemperatureReached, extectedState: .waiting),
    .init(stateFrom: .cooling, event: .maxTemperatureReached, extectedState: .cooling),
    .init(stateFrom: .cooling, event: .minTemperatureReached, extectedState: .waiting),
    .init(stateFrom: .waiting, event: .doorOpened,            extectedState: .waiting),
    .init(stateFrom: .cooling, event: .doorOpened,            extectedState: .waiting),
]

Однак, даний підхід має один недолік. Уявімо собі, що ми змінимо реалізацію стейт-машини, і вона більше не відповідатиме даному набору тестів. Яку помилку ми побачимо? Ми побачимо коректну помилку, але нам буде складно зрозуміти, до якого саме тестового випадку вона стосується:

Тест падає. Повідомлення про помилку відображається в місці виклику XCTAssertEqual. Не зрозуміло, до якого тестового випадку стосується повідомлення про помилку

На щастя, XCTest має механізми, які допоможуть нам покращити цю ситуацію. Розглянемо визначення функції XCTAssertEqual:

public func XCTAssertEqual<T>(
    _ expression1: @autoclosure () throws -> T, 
    _ expression2: @autoclosure () throws -> T,
    _ message: @autoclosure () -> String = "", 
    file: StaticString = #filePath, 
    line: UInt = #line
) where T : Equatable

Нашу увагу мають повернути останні два параметри: file та line. Ці параметри визначають, до якого фрагменту коду юніт-тесту відноситься та чи інша перевірка XCTAssert.... Ці параметри мають значення за замовчанням #filePath та #line відповідно. Саме завдяки цим значенням за замовчанням Xcode люб’язно підсвічує порушення перевірок XCTAssert... за місцем їх виклику. Ми ж хочемо підсвічувати помилки по місцю визначення тестових випадків, і це насправді нескладно реалізувати:

struct TestCase {
    var stateFrom: DoorFridgeStateMachine.State
    var event: DoorFridgeStateMachine.Event
    var extectedState: DoorFridgeStateMachine.State
    var file: StaticString
    var line: UInt

    init(
        stateFrom: DoorFridgeStateMachine.State,
        event: DoorFridgeStateMachine.Event,
        extectedState: DoorFridgeStateMachine.State,
        file: StaticString = #filePath,
        line: UInt = #line
    ) {
        self.stateFrom = stateFrom
        self.event = event
        self.extectedState = extectedState
        self.file = file
        self.line = line
    }
}

...

Ми додаємо до нашої структури TestCase властивості file та line, а також почленний ініціалізатор цієї структури із відповідними значеннями за замовчанням. Після чого нам слід передати значення цих властивостей до функції XCTAssertEqual:

...
for testCase in testCases {
    let sut = DoorFridgeStateMachine(initialState: testCase.stateFrom)
    sut.transition(with: testCase.event)
    XCTAssertEqual(sut.state, testCase.extectedState, 
                   file: testCase.file, line: testCase.line)
}

В результаті, ми отримаємо тест, котрий буде падати правильно:

Тест падає. Повідомлення про помилку відображається в місці оголошення відповідного тестового випадку, котрий впав. Результат тесту виглядає зрозуміло

Кодогенерація тестів

Коли тестових випадків стає ще більше, набивати табличку тестами стає сумно. Адже це проста одноманітна робота. Тобто робота, яку можна автоматизувати.

Припустимо, що ми на 100% впевнені у тому, що наша стейт-машина працює коректно. Тоді нескладно згенерувати за допомогою неї всі тестові випадки. Як це зробити? Спершу нам потрібно якось згенерувати всі можливі стани та події. Найпростіший спосіб це зробити – підпорядкувати їх протоколу CaseIterable:

class DoorFridgeStateMachine {
    enum State: Equatable, CaseIterable {
        ...
    }

    enum Event: Equatable, CaseIterable {
        ...
    }
}

Тепер розширимо нашу структуру TestCase статичним методом, котрий згенерує всі можливі тестові випадки:

struct TestCase {
    ...
    static func generateAllCases() -> [TestCase] {
        var result: [TestCase] = []
        for stateFrom in DoorFridgeStateMachine.State.allCases {
            for event in DoorFridgeStateMachine.Event.allCases {
                let stateMachine = DoorFridgeStateMachine(
                    initialState: stateFrom
                )
                stateMachine.transition(with: event)
                result.append(TestCase(
                    stateFrom: stateFrom, 
                    event: event, 
                    extectedState: stateMachine.state
                ))
            }
        }
        return result
    }
}

А далі все дуже просто: слід кожному екземпляру структури TestCase, що повертається методом generateAllCases(), поставити у відповідність рядок з кодом, що ініціалізує цей екземпляр:

struct TestCase {
    ...
    static func generateAllCasesCode() -> String {
        return "[\n    " +
            generateAllCases().map(\.debugDescription).joined(separator: ",\n    ") +
        "\n]"
    }

    var debugDescription: String {
        return """
        TestCase(stateFrom: .\(stateFrom), event: .\(event), extectedState: .\(extectedState))
        """
    }
}

print(TestCase.generateAllCasesCode())

Викликаємо тест – у консолі бачимо код із визначенням всіх тестових випадків. Копіюємо цей код до нашого тесту – вуаля, нам не треба набивати таблицю тестових випадків вручну.

Дуже важливо

Уважний читач міг помітити суттєву помилку у такому підході, котрої ми припустились із самого початку. Ми припустили, що наша щойно написана стейт-машина працює коректно, ще до того, як ми покрили її тестами. Це дуже сильне припущення, яке не завждии працює на практиці: якщо ми припустимось хибодруку в реалізації стейт-машини – ми автоматично зафіксуємо її у тесті, і будемо думати, що раз тести проходять, то й стейт-машина працює коректно, а це не так.

Автоматично згенеровані тестові випадки не є автоматично коректними. Перед використанням у нашому коді їх завжди потрібно вичитувати вручну. Альтернативно, можна генерувати всі тестові випадки, але зі свідомо неправильними значеннями expectedState, так, щоб згенерований тест падав у всіх випадках. Це певною мірою спростить ручну вичитку згенерованих тест-кейсів: так складніше щось пропустити.

Кодогенерація документації

Якщо в нас уже є розкіш автоматичної генерації всіх тестових випадків, ми можемо використати її для автоматичної генерації документації для усіх переходів стейт-машини, приклад, у вигляді діаграми формату Mermaid.

У цьому форматі діаграми представляються у вигляді списку переходів, що розділяються крапкою з комою. Кожен перехід записується у наступному вигляді:

стан1 --подія--> стан2;

Маючи на руках готову функцію generateAllCases(), дуже просто згенерувати схему у mermaid:

  1. Генеруємо всі тестові випадки
  2. Відкидаємо з них ті, де початковий стан збігається зі станом на виході
  3. Кожному випадку, що залишився, ставимо у відповідність опис у вигляді переходу mermaid-діаграми
  4. Об’єднуємо ці описи в єдину діаграму.и
  5. Друкуємо результат у консоль
struct TestCase {
    ...
    var description: String {
        return "\(stateFrom) --\(event)--> \(extectedState);"
    }

    static func generateDocumentation() -> String {
        return "```mermaid\ngraph TB;\n" +
            generateAllCases()
            .filter { $0.stateFrom != $0.extectedState }
            .map(\.description)
            .joined(separator: "\n") +
        "\n```"
    }
}

Даний код надрукує наступний опис роботи DoorFridgeStateMachine:

graph TB;
cooling --minTemperatureReached--> waiting;
cooling --doorOpened--> waiting;
waiting --maxTemperatureReached--> cooling;

Якщо цей опис зберегти у файлі формату markdown, то в редакторі, в Github або Gitlab він матиме наступний вигляд:

graph TB; cooling --minTemperatureReached--> waiting; cooling --doorOpened--> waiting; waiting --maxTemperatureReached--> cooling;

В одному з попередніх дописів ми створювали тести для стейт-машин, розбираючи її документацію у форматі mermaid за допомогою регулярних виразів. Сьогодні ж ми зробили обернену операцію, і тим само замкнули коло.

Пора й нам закруглятись

Написання табличних тестів та кодогенерація тестових випадків – це підходи, котрі можна використовувати не лише в написанні тестів до стейт-машин. Вони допомагають оптимізувати кількість коду і в інших випадках з великою кількістю одноманітних тестів. Разом з тим вони не дозволяють побороти нашу основну проблему: стрімкий ріст кількості тестових випадків у стейт-машинах. Ці способи лише полегшують симптоми цієї проблеми. У ряді випадків це є цілком ефективною стратегією.

Гарним способом обійти проблему росту стейт-машин є використання ієрархічний стейт-машин, про що ми й поговоримо наступного разу.

Все, досить читати статті про тести, йдіть писати свої: тести, чи статті, чи статті про тести =-) З прикладами коду, що згадувались у даному дописі, можна ознайомитись тут. Якщо ви маєте цікавий досвід і прагнете ним поділитись – пишіть мені на пошту: killobatt@gmail.com. Якщо не прагнете ділитись досвідом – то прагніть. А якщо ви ще не маєте досвіду – то здобувайте його разом із нами. Адже ми тут говоримо про Swift. Українською.