State transition testing полезен там, где система ведёт себя по-разному в зависимости от текущего состояния объекта. Пользователь, заказ, тикет, платёж, подписка, сессия — всё это примеры сущностей, которые проходят через статусы и набор разрешённых переходов.
Когда использовать
- →Есть явные статусы: создан, активен, заблокирован, отменён, завершён.
- →Не все переходы между статусами допустимы.
- →Поведение и доступные действия зависят от текущего состояния.
- →Баги часто связаны не с отдельным полем, а с нарушением жизненного цикла объекта.
Что нужно зафиксировать
- →Список состояний объекта.
- →Допустимые переходы между ними.
- →События, которые инициируют переход.
- →Ограничения: кто может инициировать переход, при каких условиях и что происходит после него.
Пример
Допустим, баг-репорт может быть в статусах Open, In Progress, Ready for Test, Reopened, Closed. State transition testing помогает проверить, можно ли перевести тикет напрямую в Closed, кто имеет право на Reopen и не ломается ли логика при повторном цикле исправления.
На что смотреть QA
- →Недопустимые переходы: система должна их блокировать или корректно объяснять.
- →Побочные эффекты переходов: уведомления, аудит, изменение доступов, запись в лог.
- →Повторные переходы и возвраты назад.
- →Состояния после сбоев: что будет, если операция прервётся посередине.
Если объект имеет жизненный цикл, почти всегда полезно нарисовать хотя бы простую state diagram. Это быстро показывает слепые зоны, которые сложно увидеть в списке тест-кейсов.
Жизненный цикл заказа с явным контрактом
Это вымышленная модель одного заказа, а не правила любого магазина или платёжного API. Draft — неотправленный заказ, Placed — оформленный, Paid — оплаченный, Shipped — отправленный, Cancelled — отменённый и завершённый. Допустимы только пять переходов ниже. Отмена после оплаты, возвраты, права, сетевые сбои и конкурентные запросы не входят в эту небольшую модель и требуют дополнительных правил.
Draft --submit--> Placed --pay--> Paid --ship--> Shipped
Draft --cancel--> Cancelled
Placed --cancel--> CancelledТекстовая версия: submit переводит Draft в Placed; pay — Placed в Paid; ship — Paid в Shipped. Cancel переводит Draft или Placed в Cancelled. У Shipped и Cancelled нет исходящих переходов. Все остальные сочетания состояния и события отклоняются без изменения состояния и бизнес-эффекта.
Полная таблица одиночных событий
Перед каждой строкой восстановите состояние из первого столбца и используйте новый ключ запроса. Число эффектов — учебный счётчик зафиксированных бизнес-переходов, а не универсальное число логов, уведомлений или внешних списаний. Допустимый переход увеличивает его на один; отказ не увеличивает.
Исходное состояние Событие Результат Конечное состояние Новые эффекты
Draft submit accepted Placed 1
Draft pay rejected Draft 0
Draft ship rejected Draft 0
Draft cancel accepted Cancelled 1
Placed submit rejected Placed 0
Placed pay accepted Paid 1
Placed ship rejected Placed 0
Placed cancel accepted Cancelled 1
Paid submit rejected Paid 0
Paid pay rejected Paid 0
Paid ship accepted Shipped 1
Paid cancel rejected Paid 0
Shipped submit rejected Shipped 0
Shipped pay rejected Shipped 0
Shipped ship rejected Shipped 0
Shipped cancel rejected Shipped 0
Cancelled submit rejected Cancelled 0
Cancelled pay rejected Cancelled 0
Cancelled ship rejected Cancelled 0
Cancelled cancel rejected Cancelled 0Повторы — отдельный контракт, а не новое состояние
В модели успешный запрос сохраняет событие и результат по ключу в пределах одного заказа. Повтор того же ключа и события возвращает сохранённый результат без нового эффекта. Тот же успешный ключ с другим событием даёт key-conflict. Отклонённые запросы не сохраняются. Упрощённое правило не утверждает конкретный HTTP-код или поведение реального платёжного провайдера.
Последовательность Ответ Текущее состояние Всего эффектов
submit / s1 accepted, Placed Placed 1
pay / p1 accepted, Paid Paid 2
повтор pay / p1 accepted, Paid (сохранённый ответ) Paid 2
ship / p1 key-conflict, Paid Paid 2
pay / p2 rejected, Paid Paid 2
ship / s2 accepted, Shipped Shipped 3Сохранённый ответ отражает исходный запрос, а не обязательно текущее состояние заказа после следующих переходов. Реальному API нужны правила области ключа, срока хранения, сравнения payload, порядка событий и атомарного сохранения. Учебная модель в памяти не доказывает защиту от конкурентных повторных списаний.
Локальный запуск эталонной модели
Сохраните код как order_state_model.py и запустите python3 order_state_model.py. Проверяются все 20 сочетаний состояния и события, затем последовательность повторов. Ожидается: Transitions: 5 accepted, 15 rejected; replay checks: passed. Это проверка учебной модели; само приложение нужно отдельно сверить с теми же объявленными правилами.
STATES = ("Draft", "Placed", "Paid", "Shipped", "Cancelled")
EVENTS = ("submit", "pay", "ship", "cancel")
TRANSITIONS = {
("Draft", "submit"): "Placed", ("Draft", "cancel"): "Cancelled",
("Placed", "pay"): "Paid", ("Placed", "cancel"): "Cancelled",
("Paid", "ship"): "Shipped",
}
class Order:
def __init__(self, state="Draft"):
self.state, self.effects, self.saved = state, 0, {}
def apply(self, event, key):
if key in self.saved:
old_event, old_result = self.saved[key]
return old_result if event == old_event else ("key-conflict", self.state)
target = TRANSITIONS.get((self.state, event))
if target is None:
return ("rejected", self.state)
self.state = target
self.effects += 1
result = ("accepted", target)
self.saved[key] = (event, result)
return result
accepted = 0
for state in STATES:
for event in EVENTS:
order = Order(state)
target = TRANSITIONS.get((state, event))
expected = ("accepted", target) if target else ("rejected", state)
assert order.apply(event, "unique") == expected
assert order.state == (target or state)
assert order.effects == int(target is not None)
accepted += target is not None
order = Order()
assert order.apply("submit", "s1") == ("accepted", "Placed")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.effects == 2
assert order.apply("ship", "p1") == ("key-conflict", "Paid")
assert order.apply("pay", "p2") == ("rejected", "Paid")
assert order.effects == 2
assert order.apply("ship", "s2") == ("accepted", "Shipped")
assert order.effects == 3
print(f"Transitions: {accepted} accepted, {20-accepted} rejected; replay checks: passed")В реальной системе расширьте последовательности: таймаут между оплатой и сохранением, повтор callback, устаревшая версия и гонка отмены с оплатой. Сначала определите ожидаемое поведение. Покрытие пяти переходов не доказывает корректность всех цепочек событий и восстановлений после сбоев.
Продолжить изучение
Таблицы решений для условий перехода