Стейт-машини. Частина 2: Тестуємо стейт-машини.
Ви знаєте, із тестуванням нє всьо так адназначна.
Ось погана сторона: коли ви збільшуєте пакриття тестами до такої мєри, шо ви починаєте знаходити більше баґів, більше крешів. Тому я кажу: “Люди, будь ласка, сповільніть тестування!”
У спільноті 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),
]
Однак, даний підхід має один недолік. Уявімо собі, що ми змінимо реалізацію стейт-машини, і вона більше не відповідатиме даному набору тестів. Яку помилку ми побачимо? Ми побачимо коректну помилку, але нам буде складно зрозуміти, до якого саме тестового випадку вона стосується:
На щастя, 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:
- Генеруємо всі тестові випадки
- Відкидаємо з них ті, де початковий стан збігається зі станом на виході
- Кожному випадку, що залишився, ставимо у відповідність опис у вигляді переходу mermaid-діаграми
- Об’єднуємо ці описи в єдину діаграму.и
- Друкуємо результат у консоль
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 він матиме наступний вигляд:
В одному з попередніх дописів ми створювали тести для стейт-машин, розбираючи її документацію у форматі mermaid за допомогою регулярних виразів. Сьогодні ж ми зробили обернену операцію, і тим само замкнули коло.
Пора й нам закруглятись
Написання табличних тестів та кодогенерація тестових випадків – це підходи, котрі можна використовувати не лише в написанні тестів до стейт-машин. Вони допомагають оптимізувати кількість коду і в інших випадках з великою кількістю одноманітних тестів. Разом з тим вони не дозволяють побороти нашу основну проблему: стрімкий ріст кількості тестових випадків у стейт-машинах. Ці способи лише полегшують симптоми цієї проблеми. У ряді випадків це є цілком ефективною стратегією.
Гарним способом обійти проблему росту стейт-машин є використання ієрархічний стейт-машин, про що ми й поговоримо наступного разу.
Все, досить читати статті про тести, йдіть писати свої: тести, чи статті, чи статті про тести =-) З прикладами коду, що згадувались у даному дописі, можна ознайомитись тут. Якщо ви маєте цікавий досвід і прагнете ним поділитись – пишіть мені на пошту: killobatt@gmail.com. Якщо не прагнете ділитись досвідом – то прагніть. А якщо ви ще не маєте досвіду – то здобувайте його разом із нами. Адже ми тут говоримо про Swift. Українською.