Переходы состояний

Как проверять системы, где поведение зависит от текущего статуса объекта, и почему state transition testing отлично ловит дефекты workflow-логики.

Обновлено:

Содержание

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 — отменённый и завершённый. Допустимы только пять переходов ниже. Отмена после оплаты, возвраты, права, сетевые сбои и конкурентные запросы не входят в эту небольшую модель и требуют дополнительных правил.

plain text
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 нет исходящих переходов. Все остальные сочетания состояния и события отклоняются без изменения состояния и бизнес-эффекта.

Полная таблица одиночных событий

Перед каждой строкой восстановите состояние из первого столбца и используйте новый ключ запроса. Число эффектов — учебный счётчик зафиксированных бизнес-переходов, а не универсальное число логов, уведомлений или внешних списаний. Допустимый переход увеличивает его на один; отказ не увеличивает.

plain text
Исходное состояние	Событие	Результат	Конечное состояние	Новые эффекты
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-код или поведение реального платёжного провайдера.

plain text
Последовательность	Ответ	Текущее состояние	Всего эффектов
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. Это проверка учебной модели; само приложение нужно отдельно сверить с теми же объявленными правилами.

plain text
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, устаревшая версия и гонка отмены с оплатой. Сначала определите ожидаемое поведение. Покрытие пяти переходов не доказывает корректность всех цепочек событий и восстановлений после сбоев.

Продолжить изучение

Таблицы решений для условий перехода

Отказ API без побочных эффектов

ISTQB CTFL 4.0.1: section 4.2.4, state transition testing