State machines в Rails: как объект проходит жизненный цикл

State machines в Rails

Статья для тех, кто не писал на Ruby, но хочет понять, как в Rails-приложениях описывают жизненный цикл бизнес-объектов и почему это почти всегда конечный автомат.

Примеры упрощены до сути. Домен взят самый понятный - заказ в интернет-магазине.

Начнём с боли

Почти в каждом приложении есть объект, который живёт не мгновение, а неделями. Заказ. Подписка. Заявка на отпуск. Статья в редакции. Такой объект проходит через фазы, и в базе для этого заводят колонку status или state.

Первая версия кода всегда выглядит так:

order.state = 'complete'
order.save

Работает. Проблемы приходят позже, и их три.

Первая: нет запрета на невозможные переходы. Пустая корзина может стать завершённым заказом. Отменённая подписка - снова активной. Приложение не знает, что так нельзя, потому что вы нигде этого не написали. Знание живёт в голове разработчика, а голов со временем становится больше.

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

Третья: непонятно, откуда объект пришёл. Строка 'canceled' в базе не хранит, была отмена до оплаты или после. А бизнес это различает.

Конечный автомат (finite state machine, FSM) - это способ записать все три вещи в одном месте. Он состоит из четырёх понятий:

  • состояния - конечный список фаз;
  • события - действия, запускающие смену фазы;
  • переходы - правила «из состояния A по событию X можно попасть в B»;
  • реакции - код, выполняемый в момент перехода.

Вот как выглядит жизненный цикл заказа, который мы будем разбирать дальше:

stateDiagram-v2
    [*] --> cart
    cart --> payment: checkout
    payment --> complete: pay
    cart --> canceled: cancel
    payment --> canceled: cancel
    complete --> canceled: cancel if cancelable?
    complete --> [*]
    canceled --> [*]

Дальше по тексту - как это выглядит в Rails, что происходит под капотом и где на этом обычно обжигаются.

Три способа хранить состояние в Rails

Прежде чем брать гем, стоит знать альтернативы. Их фактически три.

Строковая колонка и константы. Самый простой вариант, никаких зависимостей.

class Order < ApplicationRecord
  STATES = %w[cart address payment complete canceled].freeze
  validates :state, inclusion: { in: STATES }
end

Даёт список допустимых значений, но не даёт ни правил перехода, ни реакций. Годится, если фаз три и переходы очевидны.

enum из самого Rails. Встроенный механизм, появился в Rails 4.1.

class Order < ApplicationRecord
  enum state: { cart: 0, payment: 1, complete: 2, canceled: 3 }
end

Вы бесплатно получаете order.complete?, order.complete!, скоуп Order.complete и красивый маппинг «символ в коде - число в базе» (документация). Но enum знает только про значения. Он с удовольствием переведёт заказ из cart сразу в complete, потому что понятия «переход» у него нет.

Гем с конечным автоматом. Тут есть из чего выбирать, и выбор влияет на то, как будет выглядеть весь код модели:

Гем Чем интересен
state_machines-activerecord Наследник исторического state_machine, самого первого популярного гема в этой нише. Богатый DSL, состояние в колонке модели. Часто встречается в старых проектах и в Spree/Solidus.
aasm Сегодня самый распространённый выбор для нового кода. Тот же набор идей, чуть более лаконичный синтаксис, живая поддержка.
statesman От платёжной компании GoCardless. Главное отличие: каждый переход пишется отдельной строкой в таблицу переходов, а текущее состояние - это последняя запись. История изменений получается бесплатно, что ценно в финансовых системах.
workflow Ветеран, компактный и с минимумом магии. Встречается редко, но код читается легко.

Дальше я показываю синтаксис state_machines, потому что он самый «разговорчивый» и на нём лучше видно устройство DSL. Всё сказанное переносится на aasm почти дословно - меняются только имена ключевых слов.

Задача enum гем-автомат
Список допустимых значений да да
Методы-вопросы complete? да да
Скоупы Order.complete да да (в state_machines тоже есть)
Правила «откуда куда можно» нет да
Условия на переход нет да
Код на момент перехода нет (только общие callbacks модели) да

Практический совет: если у объекта есть слово «нельзя» в описании («нельзя отменить после отправки») - берите автомат. Если состояние это просто метка для фильтра в списке - хватит enum.

Где физически живёт состояние

Здесь никакой магии, и это важно понять сразу. Состояние - обычный столбец:

CREATE TABLE orders (
  id bigserial PRIMARY KEY,
  state character varying(255),
  ...
);

Гем читает и пишет ту же самую колонку, что и вы. Ни отдельной таблицы, ни хитрого формата. Разница только в дисциплине: писать напрямую вы больше не должны. Вместо order.state = 'complete' вы вызываете событие, а гем сначала проверяет, разрешён ли переход, и только потом меняет значение.

Отсюда сразу два вывода, которые пригодятся:

  1. Order.where(state: 'complete') продолжает работать как обычный SQL-запрос. Автомат ничего не ломает в запросах.
  2. order.update_column(:state, 'complete') обойдёт автомат целиком. Это иногда нужно (миграции данных, фикстуры), но об этом отдельно в разделе про грабли.

Исключение - statesman: там текущее состояние вычисляется из таблицы переходов. Для остальных гемов из списка выше картина ровно как на схеме выше - одна колонка, одно значение.

Ruby DSL: что это вообще такое

Вот минимальный автомат целиком:

class Order < ApplicationRecord
  state_machine :state, initial: :cart do
    event :checkout do
      transition cart: :payment
    end

    event :pay do
      transition payment: :complete
    end

    event :cancel do
      transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
    end

    after_transition to: :complete, do: :send_confirmation
  end
end

Читается почти как техническое задание: «заказ начинается в корзине; событие checkout переводит его из корзины в оплату; pay - из оплаты в завершение; cancel переводит в отменённый из любой из трёх фаз, если объект отвечает, что отмена разрешена; после попадания в complete отправляем подтверждение».

Для человека, пришедшего из Java, C#, Go или TypeScript, здесь надо понять одну вещь, и она снимает 80% недоумения при чтении Ruby-кода:

Это не специальный синтаксис языка и не конфигурационный файл. Это обычные вызовы методов.

Разберём построчно, что происходит на самом деле:

Строка в DSL Что это на самом деле
state_machine :state, initial: :cart do ... end вызов метода state_machine с двумя аргументами (символ и хеш {initial: :cart}) и блоком кода
:cart символ (Symbol) - неизменяемая строка-идентификатор, аналог enum-константы; дешевле обычной строки
event :checkout do ... end вызов метода event, блок исполняется в контексте объекта-строителя события
transition cart: :payment вызов метода transition с хешем; ключ - исходное состояние, значение - целевое
[:cart, :payment] => :canceled тот же хеш, но ключ - массив: «из любого из этих»
if: :cancelable? символ вместо функции: «когда придёт время, вызови метод с таким именем на объекте»
after_transition to: :complete, do: :send_confirmation ещё один вызов метода, регистрирующий обработчик

Работает это благодаря двум свойствам Ruby, которых нет в большинстве привычных языков:

Скобки при вызове метода необязательны. puts "hi" и puts("hi") - одно и то же. Поэтому event :checkout выглядит как ключевое слово, а на деле это event(:checkout).

Любому методу можно передать блок кода. Блок - это кусок кода в do ... end или { ... }, который метод может выполнить когда захочет и в каком захочет контексте. Гем принимает ваш блок и исполняет его на своём объекте-строителе, поэтому внутри блока внезапно доступны методы event и transition, которых у модели нет.

Из этих двух свойств и вырастают DSL - «языки внутри языка». Вы их уже видели, даже если не знали названия: has_many :comments, validates :email, presence: true, before_action :authenticate - всё это ровно такие же обычные вызовы методов.

Практический вывод для чтения незнакомого Ruby-кода: если строка непонятна, спрашивайте не «что это за синтаксис», а «какой метод здесь вызывается и с какими аргументами». Ответ почти всегда находится в документации гема, а не в справочнике по языку.

Что гем пишет за вас

Объявив автомат, вы получаете набор методов, которых нет в исходниках. Они создаются во время загрузки класса - это называется метапрограммированием, и в Rails оно повсюду.

Для события pay и состояния complete появятся:

Метод Что делает
order.complete? предикат - проверка текущего состояния, true или false
order.pay пытается выполнить переход; при неудаче возвращает false и ничего не меняет
order.pay! то же, но при неудаче бросает исключение
order.can_pay? можно ли выполнить событие прямо сейчас, не выполняя его
order.state_transitions список доступных отсюда переходов
Order.with_state(:complete) скоуп для запроса

Три замечания, каждое из которых экономит час недоумения.

Про восклицательный знак. pay! и pay - два разных метода, ! это часть имени, а не оператор. В Ruby есть соглашение: версия с ! опаснее версии без него. Что именно значит «опаснее», зависит от библиотеки. Здесь - «бросит исключение вместо тихого false». В ActiveRecord save! против save - ровно та же пара. Выбор простой: pay! там, где невозможность перехода это баг и надо падать; pay там, где это нормальный сценарий и вы проверяете результат.

Про grep. Поиск по проекту def pay! не найдёт ничего. Метод не написан, он сгенерирован. Для новичка в Ruby это самая частая точка растерянности: метод вызывается, работает, а определения нет. Лечится привычкой: не нашли определение - ищите декларацию (state_machine, has_many, enum, delegate), которая его породила. Помогает и консоль: order.method(:pay!).source_location покажет, из какого файла гема метод пришёл.

Про can_pay?. Самый недооценённый метод из списка. Он отвечает на вопрос «получится ли», не делая попытки, и потому идеален для двух вещей: показать или спрятать кнопку в интерфейсе, и проверить актуальность в фоновой задаче.

class OrderPaymentJob
  def perform(order_id)
    order = Order.find(order_id)
    # между постановкой в очередь и исполнением прошло время,
    # заказ могли отменить руками в админке
    return unless order.can_pay?

    order.pay!
  end
end

Это классическая проблема одной фоновой задачи: между постановкой в очередь и исполнением проходит время, за которое мир меняется. Автомат даёт дешёвый способ проверить, что задача всё ещё имеет смысл.

Ловушка с похожими именами

Автомат генерирует предикат по имени состояния. Разработчики параллельно пишут свои предикаты руками. Имена получаются похожими, а смысл разный:

# сгенерирован автоматом: state == 'complete'
order.complete?

# написан руками где-то в модели: заполнена ли колонка с датой
def completed?
  completed_at.present?
end

Обычно они совпадают, но это два разных источника правды, и в момент рассинхронизации вы будете долго смотреть на код. Правило чтения: увидели предикат - сначала выясните, рукописный он или сгенерированный. Если grep не нашёл def - это состояние.

Guard-условия: где живёт слово «нельзя»

Guard (сторожевое условие) - это проверка, привязанная к переходу. Если она вернула ложь, перехода не будет.

event :cancel do
  transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
end

def cancelable?
  return false if canceled?
  shipment.nil? || shipment.pending?
end

Бизнес-правило «нельзя отменить заказ, который уже уехал» записано ровно один раз. Дальше оно действует везде: в контроллере, в админке, в API, в rake-задаче. Никто не может случайно обойти его, потому что обходить нечего - проверка встроена в сам переход.

Что происходит при неудаче:

order.cancel    # => false, состояние не изменилось
order.cancel!   # => исключение StateMachines::InvalidTransition
order.reload.state # => 'complete', как и было

Guard можно задать двумя способами, и они взаимозаменяемы:

transition complete: :canceled, if: :cancelable?              # символ = имя метода
transition complete: :canceled, if: ->(order) { order.paid? } # лямбда

Лямбда - это анонимная функция как объект: её можно положить в переменную, передать аргументом и вызвать позже. Гем сохраняет её при загрузке класса и дёргает в момент попытки перехода. Символ по сути то же самое, просто короче; используйте лямбду, когда условие одноразовое, и метод, когда его хочется переиспользовать или протестировать отдельно.

Важное различие, которое часто путают:

  • валидация отвечает на вопрос «данные корректны?» (validates :email, presence: true);
  • guard отвечает на вопрос «это действие применимо к объекту в его текущем состоянии?».

Заказ без адреса доставки невалиден. Заказ, который уже уехал, вполне валиден - просто его нельзя отменить. Разные вопросы, разные механизмы.

Отдельно стоит заметить: у события cancel в примере перечислены исходные состояния, но их можно и не указывать.

event :cancel do
  transition to: :canceled, if: :cancelable?
end

Тогда перейти можно из любого состояния, а всю фильтрацию делает guard. Так пишут, когда список исходных состояний длинный и его неудобно поддерживать. Цена - правило перестаёт быть видно в декларации, надо идти читать метод.

Реакции на переход: before и after

Callbacks - это «а что произойдёт при». Их два вида, и разница между ними принципиальная.

state_machine :state, initial: :cart do
  before_transition to: :complete do |order|
    order.charge_payment!   # вернул false -> перехода не будет
  end

  after_transition to: :complete, do: :send_confirmation
  after_transition to: :canceled, do: :restock_items
end

before_transition выполняется до смены состояния и может её отменить: если блок вернул false, переход не состоится, объект останется как был. Сюда кладут то, без чего переход не имеет смысла. Списание денег - каноничный пример: нет оплаты, нет завершённого заказа.

after_transition выполняется после того, как состояние уже изменилось, и отменить переход не может. Сюда кладут последствия: письма, документы, уведомления в очередь.

sequenceDiagram
    participant App as order.pay!
    participant Guard as guard / before
    participant DB as колонка state
    participant After as after_transition

    App->>Guard: can_pay? и before_transition
    alt before вернул false
        Guard-->>App: переход отменён
    else всё ок
        Guard->>DB: payment -> complete
        DB->>After: finalize, invoice, enqueue...
        After-->>App: готово (внутри той же транзакции)
    end

Простое правило выбора: если ответ на вопрос «а если это упадёт, переход должен отмениться?» - да, значит before. Если нет - after.

Порядок и невидимые подписчики

Callbacks выполняются в порядке регистрации. Пока их два, это не важно. Проблема появляется в живом проекте, где на один и тот же переход подписываются из разных файлов - из модели, из подключённых модулей (concerns), из декораторов сторонних гемов:

# order.rb
after_transition to: :complete, do: :finalize!

# order/invoiceable.rb (concern)
after_transition to: :complete, do: :create_invoice

# order/loyalty.rb (concern)
after_transition to: :complete, do: :award_points

Формально всё нормально. Практически - добавляя пятый callback, вы не видите четырёх остальных. Порядок их исполнения зависит от порядка загрузки файлов, а он в Rails не всегда очевиден. Чтобы узнать, что реально произойдёт при завершении заказа, приходится делать grep по after_transition и складывать картину в голове.

Это не повод не использовать callbacks. Это повод держать их количество под контролем и время от времени спрашивать себя: то, что я добавляю, - неотъемлемое следствие перехода или отдельный бизнес-процесс, который просто начинается в этот момент? Первое - callback. Второе лучше вызвать явно из сервисного объекта, где всю последовательность видно глазами.

Сквозной сюжет: оплата, счёт, письмо

Соберём типичный маршрут целиком. Он встречается почти в каждом магазине, и на нём удобно показать главную проблему связки «автомат плюс фоновые задачи».

Клиент вернулся от платёжного шлюза. Контроллер делает две строки:

payment.complete!
order.pay!

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

flowchart TD
    A[Клиент вернулся от платёжного шлюза] --> B["контроллер: order.pay!"]
    B --> TX

    subgraph TX["Транзакция БД"]
      direction TB
      C["before_transition to: complete<br/>charge_payment!"]
      C -->|false| X[переход отменён]
      C -->|ok| D["UPDATE state:<br/>payment -> complete"]
      D --> E["after_transition"]
      E --> E1[finalize!]
      E --> E2[create_invoice]
      E --> E3[reserve_stock]
      E --> E4["enqueue email ← ОПАСНОЕ МЕСТО"]
    end

    TX --> F[COMMIT]
    F --> G[Sidekiq: другой процесс, другое соединение]
    G --> H[ConfirmationEmailJob]
    H --> I["Order.find(id) - увидит ли он заказ?"]
    I --> J[письмо у клиента]

Ключевой момент диаграммы - рамка транзакции. Переход состояния и все его callbacks выполняются внутри одной транзакции БД. Пока не случился COMMIT, изменений для внешнего мира не существует.

А Sidekiq - это внешний мир. Отдельный процесс, отдельное соединение с базой, отдельная очередь в Redis. Из этого следует главная ловушка всей темы, ей посвящён следующий раздел.

Что не стоит копировать бездумно

Дальше - три вещи, которые в реальных проектах встречаются регулярно, работают годами и при этом являются компромиссами, а не образцами. Если вы пишете новый код, смотрите на них внимательно.

1. Побочные эффекты внутри транзакции перехода

Это ошибка номер один, и она коварна тем, что на машине разработчика не воспроизводится.

after_transition to: :complete do |order|
  ConfirmationEmailJob.perform_async(order.id)   # плохо
end

Что происходит: perform_async кладёт задачу в Redis мгновенно. Транзакция БД ещё открыта. Sidekiq-воркер в другом процессе просыпается через миллисекунды, делает Order.find(id) со своим соединением и не видит незакоммиченных изменений. В лучшем случае он прочитает старое состояние и отправит письмо «ваш заказ оплачен» про заказ, который в базе ещё в статусе payment. В худшем - если запись создавалась в этой же транзакции - получит ActiveRecord::RecordNotFound.

sequenceDiagram
    participant C as Controller
    participant DB as Транзакция БД
    participant R as Redis
    participant W as Sidekiq worker

    C->>DB: order.pay!
    Note over DB: state уже complete в этой транзакции
    DB->>R: perform_async(order.id)
    R->>W: job стартует слишком рано
    W->>DB: Order.find(id)
    Note over W,DB: worker не видит uncommitted данные
    DB-->>C: COMMIT

Локально этого не видно: на пустой базе транзакция коммитится за микросекунды, а часто Sidekiq в разработке вообще работает синхронно. Ошибка вылезает в production под нагрузкой, плавает и выглядит необъяснимой.

Первое, что придумывают, столкнувшись с проблемой, - задержка:

after_transition to: :complete do |order|
  ConfirmationEmailJob.perform_at(30.seconds.from_now, order.id)  # тоже плохо
end

Иногда такое живёт в проектах годами. 30 секунд здесь не бизнес-требование, а запас времени на коммит. Это гонка с таймером: под нагрузкой длинная транзакция может не уложиться, а в нормальном случае клиент лишние полминуты ждёт письмо без всякой причины. Если видите в чужом коде необъяснимую задержку перед постановкой задачи - вы почти наверняка смотрите на этот обход.

Правильных решений два.

after_commit - хук ActiveRecord, срабатывающий гарантированно после успешного коммита. В callback перехода ставим только флаг в памяти объекта, а в очередь отправляем уже снаружи транзакции:

class Order < ApplicationRecord
  state_machine :state, initial: :cart do
    after_transition to: :complete do |order|
      # не ставим задачу здесь: транзакция ещё не закоммичена
      order.instance_variable_set(:@send_confirmation_after_commit, true)
    end
  end

  after_commit :enqueue_confirmation

  private

  def enqueue_confirmation
    return unless @send_confirmation_after_commit

    @send_confirmation_after_commit = false
    ConfirmationEmailJob.perform_async(id)
  end
end

Выглядит многословнее, чем одна строка с perform_async, зато работает детерминированно.

Транзакционная очередь. Если очередь хранится в той же базе, что и данные (solid_queue в Rails 8, good_job, delayed_job), задача записывается той же транзакцией и проблема исчезает по построению: не закоммитилось - значит и задачи нет. Это одна из главных причин, по которым проекты уходят с Sidekiq на очередь в Postgres.

Правило: из callback перехода задача в Redis-очередь ставится только через after_commit.

У той же транзакции есть вторая грань: исключение внутри after_transition откатит и сам переход.

after_transition to: :complete, do: :generate_pdf_invoice   # рискованно

Упала генерация PDF - заказ не завершился. Клиент заплатил, а в базе состояние payment. Сбой второстепенной функции уронил основную. Вывод тот же самый: чем дальше побочный эффект от транзакции, тем меньше он может ей навредить. Вынесенная через after_commit фоновая задача решает обе проблемы разом.

Два смежных правила про сами задачи, раз уж мы здесь:

  • Передавайте id, а не объект. Во-первых, в очередь сериализуется только JSON (числа, строки, массивы, хеши). Во-вторых, объект, пролежавший минуту в очереди, всё равно устарел - воркер должен читать свежие данные.
  • Задача должна быть готова выполниться дважды. Очереди гарантируют «хотя бы один раз», а не «ровно один раз», и вдобавок Sidekiq по умолчанию повторяет упавшую задачу до 25 раз. Простейшая защита от дубля письма - колонка с датой отправки и проверка перед отправкой.

2. Прямая запись в колонку состояния

order.update_column(:state, 'complete')   # мимо автомата

Это не всегда ошибка. В миграции данных или в фабрике для теста расчёта итогов - ровно то, что нужно: быстро получить объект в нужном состоянии без побочных эффектов.

Но два следствия надо держать в голове.

Ни один callback не сработает. Заказ станет завершённым, а счёта и письма не будет. В тестах это иногда создаёт ложное ощущение, что переход протестирован: состояние в базе правильное, а перехода не было.

Проверка допустимости не сработает тоже. Так можно получить объект в комбинации, которую процесс не предусматривал, и обнаружить это через полгода в отчёте.

Практическое правило: прямая запись допустима там, где вы готовите данные, и недопустима там, где вы обрабатываете бизнес-событие.

3. Автомат как замена сервисному слою

Последнее - не про конкретный код, а про меру. Автомат хорош, пока описывает жизненный цикл. Он становится плох, когда в него въезжает весь бизнес-процесс.

Симптомы, что пора остановиться: у одного перехода десяток подписчиков; порядок callbacks приходится задавать вручную; в guard одного callback появляется проверка на результат другого; чтобы понять, что делает order.pay!, надо открыть шесть файлов.

Лечение обычно одно: оставить в автомате смену состояния и то, что от неё неотделимо, а остальное собрать в явный сервисный объект, где последовательность шагов видно сверху вниз:

class CompleteOrder
  def call(order)
    order.pay!              # автомат отвечает только за состояние
    InvoiceCreator.new.call(order)
    StockReserver.new.call(order)
    ConfirmationEmailJob.perform_async(order.id)
  end
end

Читается за десять секунд, отлаживается пошагово, тестируется по частям. Скучно - и в этом достоинство.

Как это проверить тестом

Тесты на автомат делятся на три группы, и все три проверяют наблюдаемое поведение, а не внутреннее устройство.

Первое: переход разрешён из правильных состояний. Самый дешёвый тест, побочных эффектов нет вообще:

RSpec.describe Order do
  it 'allows cancelling a fresh order' do
    order = create(:order, state: 'payment')
    expect(order.can_cancel?).to be true
  end
end

Второе: guard действительно блокирует. Проверяем и возвращаемое значение, и то, что состояние не поехало:

it 'refuses to cancel a shipped order' do
  order = create(:order, :shipped)

  expect(order.cancel).to be false
  expect(order.reload.state).to eq('complete')
end

reload здесь не формальность: он перечитывает объект из базы и доказывает, что в базе тоже ничего не изменилось, а не только в памяти.

Третье: побочный эффект перехода произошёл. Главный тест сюжета этой статьи:

RSpec.describe Order do
  describe 'transition to complete' do
    let(:order) { create(:order, :ready_for_payment) }

    it 'creates an invoice' do
      expect { order.pay! }.to change { order.invoices.count }.by(1)
    end

    it 'enqueues the confirmation email' do
      expect { order.pay! }
        .to change(ConfirmationEmailJob.jobs, :size).by(1)
    end
  end
end

Три замечания к этому набору.

Про фабрику. :ready_for_payment должна собрать заказ, который реально может перейти в complete: с позициями, адресом, доставкой и платежом. Соблазн сократить до create(:order, state: 'payment') велик, но тогда guard упрётся в отсутствующие данные, и тест будет проверять не то, что вы думаете. Заказ, состояние которому проставили напрямую, для теста перехода бесполезен: перехода не было.

Про change. Матчер expect { ... }.to change { ... }.by(1) измеряет значение до и после блока. Он проверяет результат, а не способ его достижения, поэтому переживёт рефакторинг внутренностей.

Чего делать не надо. Не проверяйте, что был вызван внутренний метод, и не мокайте воркер:

# не надо: тест привязан к текущей реализации
expect(order).to receive(:create_invoice)
expect(InvoiceJob).to receive(:perform_async)

Через год цепочка изменится - счёт будет создавать другой класс, задача поедет через after_commit - и такой тест сломается, хотя поведение осталось правильным. Проверяйте то, что видит внешний мир: строку в базе, размер очереди, тело ответа.

Глоссарий

Термин Значение
State machine (конечный автомат) Модель процесса: конечный набор состояний плюс правила перехода между ними. В Rails реализуется гемами state_machines-activerecord или aasm.
State (состояние) Именованная фаза жизни объекта: cart, complete, canceled. Обычно хранится строкой в колонке БД.
Event (событие) Именованное действие, запускающее переход: pay, cancel. Порождает методы pay, pay!, can_pay?.
Transition (переход) Правило «из состояний A, B можно попасть в C», объявляется внутри события. Не описанный правилом переход невозможен.
Guard (сторожевое условие) Условие if: / unless: на переходе. Ложно - перехода не будет. Отвечает на вопрос «применимо ли действие», в отличие от валидации, которая проверяет корректность данных.
Predicate (предикат) Метод-вопрос, возвращающий true/false: complete?. Сгенерированные автоматом предикаты не найти поиском по def.
Bang-метод (pay!) Соглашение Ruby: версия метода, которая при неудаче бросает исключение вместо возврата false. Восклицательный знак - часть имени, а не оператор.
Callback Код, привязанный к моменту перехода. before_transition выполняется до смены состояния и может её отменить; after_transition - после, отменить не может.
DSL (domain-specific language) «Язык внутри языка». В Ruby - обычные вызовы методов с блоками, которые за счёт необязательных скобок читаются как декларативное описание.
Symbol (:complete) Неизменяемый идентификатор-строка. Дешевле обычной строки, используется как имя состояния, события или метода.
Lambda / Proc Анонимная функция как объект: можно сохранить в переменную, передать аргументом и вызвать позже. На этом держатся guard-условия.
Метапрограммирование Создание методов во время выполнения. Причина, по которой pay! работает, но его определения нет в коде.
Worker / Job (Sidekiq) Класс с методом perform, исполняемый в отдельном процессе через очередь в Redis. Принимает только JSON-совместимые аргументы, поэтому передают id, а не объект.
after_commit Хук ActiveRecord, срабатывающий после успешного коммита транзакции. Единственное безопасное место для постановки задачи в Redis-очередь.

Что читать дальше

  • state_machines и aasm - синтаксис отличается, идеи одинаковые; полезно пролистать оба README подряд, чтобы отделить концепцию от конкретного API.
  • statesman - посмотрите, даже если не собираетесь использовать: хранение переходов отдельной таблицей меняет взгляд на задачу. Состояние перестаёт быть значением и становится следствием истории.
  • ActiveRecord::Enum - если после статьи кажется, что автомат вам не нужен, это честный и правильный вывод; начните отсюда.
  • after_commit и остальные callbacks ActiveRecord - раздел про транзакционные хуки стоит прочитать целиком.
  • Рекомендуемое продолжение темы: у одного объекта автоматов обычно несколько (Order, Payment, Shipment), и они не синхронизированы между собой. «Заказ завершён» не значит «деньги получены», а «деньги получены» не значит «посылка уехала». Именно на стыке этих автоматов живут самые дорогие баги.