Testing
Test workflows by running them inline in the test process. Durable.Testing
runs a workflow, and everything it spawns, until nothing is left to do, then
gives you the run to assert on.
Setup
Turn queue processing off in config/test.exs. Queue workers would otherwise
race your tests for the same runs:
config :durable, disable_queue_processing: trueDurable still starts from your supervision tree as usual. Then import the
helpers, for example in your DataCase:
defmodule MyApp.DataCase do use ExUnit.CaseTemplate
using do quote do import Durable.Testing # ... end endendRuns execute in the test process, so they share the test’s Ecto sandbox and everything is rolled back when the test ends.
Running a Workflow
run_workflow/3 starts a workflow and drains it. It returns the run as a
%Durable.Storage.Schemas.WorkflowExecution{}:
test "fulfils an order" do run = run_workflow(MyApp.OrderWorkflow, %{"order_id" => 42})
assert_status(run, :completed) assert %{"order_id" => 42, "shipped" => true} = run.contextendIt takes the same options as Durable.start/3, such as workflow: to pick
a workflow in a module that defines several.
Context and input are stored as JSON, so read them with string keys.
assert_status/2 checks :completed, :failed, :waiting, :cancelled,
:compensated or any other status. When it fails, it prints the run’s
current step and error:
Expected workflow run 0f3c… to be :completed, got :failedcurrent step: "charge"error: %{"message" => "card declined", "type" => "error"}Steps
assert_step/3 checks how a step’s latest attempt ended (:completed by
default) and returns its %StepExecution{}:
assert_step(run, :reserve)assert_step(run, :charge, :failed)
assert %{"invoice_id" => _} = assert_step(run, :invoice).outputSteps inside parallel and branch blocks are matched by their own name, so
assert_step/3 also tells you which branch ran:
run = run_workflow(MyApp.FulfilmentWorkflow, %{"type" => "digital"})
assert_step(run, :send_download_link)steps/2 lists every step execution, oldest first, with one row per attempt.
Parallel Blocks
drain/2 runs every branch of a parallel block, then the steps after it.
Unless the block has an into: callback, the branch results are stored under
__results__:
run = run_workflow(MyApp.OnboardingWorkflow, %{})
assert %{"__results__" => %{"send_email" => ["ok", _]}} = run.contextWaits
A run that waits for input or an event stops at :waiting. Resume it with the
regular API, then drain it again:
test "a manager approves the expense" do run = run_workflow(MyApp.ExpenseWorkflow, %{"amount" => 500})
assert_status(run, :waiting) assert_waiting_for_input(run, "manager_approval")
:ok = Durable.provide_input(run.id, "manager_approval", %{"approved" => true})
run |> drain() |> assert_status(:completed)endEvents work the same way, with assert_waiting_for_event/2 and
Durable.send_event/3.
sleep/1 and schedule_at/1 don’t hold a test up: drain ends them right
away, so a run that sleeps for a day finishes in milliseconds.
Retries
Retries skip their backoff during a drain. attempt on the step’s latest row
says how many attempts it took:
test "retries a flaky gateway" do expect(MyApp.GatewayMock, :charge, 2, fn _ -> {:error, :timeout} end) expect(MyApp.GatewayMock, :charge, fn _ -> {:ok, "ch_1"} end)
run = run_workflow(MyApp.PaymentWorkflow, %{"amount" => 100})
assert_status(run, :completed) assert assert_step(run, :charge).attempt == 3endSteps run in the test process, so Mox expectations apply without
allow/3.
Compensations
A failure that’s rolled back ends as :compensated, or as
:compensation_failed if a compensation failed too. Compensation steps are
recorded as compensate_<name>:
run = run_workflow(MyApp.BookTripWorkflow, %{})
assert_status(run, :compensated)assert_step(run, :book_hotel, :failed)assert_step(run, :compensate_cancel_flight)Child Workflows
Drain also runs the child workflows that a run starts, with call_workflow/3
or start_workflow/3, and resumes the parent once a child is done. Use
Durable.list_children/2 to inspect them.
Scheduled Workflows
A scheduled workflow is an ordinary workflow, so test it with
run_workflow/3. To test the schedule itself, create it and trigger it:
{:ok, _} = Durable.schedule(MyApp.ReportWorkflow, "0 9 * * *", name: "daily_report"){:ok, id} = Durable.trigger_schedule("daily_report")
id|> drain()|> assert_status(:completed)Limits
- Wait timeouts (
timeout:onwait_for_input/2and the other waits) don’t fire during a drain, because the timeout worker only runs with the queue enabled. - A workflow’s
timeout:uses the real clock, so drain doesn’t skip ahead to it. drain/2raises if the Durable instance has queue processing enabled. To test with real queue workers, start Durable with the queue on and pollDurable.get_execution/2until the run finishes.
Multiple Instances
Every helper takes a durable: option naming the Durable instance, which
defaults to Durable:
run_workflow(MyApp.OrderWorkflow, %{}, durable: MyApp.Durable)assert_status(run, :completed, durable: MyApp.Durable)