How to Test Reactors and Steps
View SourceProblem
You need comprehensive testing strategies for your reactors, including unit tests for individual steps, integration tests for complete workflows, and proper testing of error handling and compensation logic.
Solution Overview
This guide shows you different approaches to testing reactors, from testing individual step modules to full workflow integration tests. We'll cover unit testing, integration testing, error scenario testing, and concurrent execution testing.
Prerequisites
- Understanding of Reactor basics
- Familiarity with ExUnit testing
- Basic knowledge of mocking with Mimic (for advanced scenarios)
Testing Strategies
1. Unit Testing Individual Steps
The most granular level is testing individual step modules directly.
Basic Step Testing
defmodule MyApp.Steps.ValidateEmailTest do
use ExUnit.Case, async: true
alias MyApp.Steps.ValidateEmail
test "validates correct email format" do
arguments = %{email: "user@example.com"}
context = %{}
options = []
assert {:ok, "user@example.com"} = ValidateEmail.run(arguments, context, options)
end
test "returns error for invalid email" do
arguments = %{email: "invalid-email"}
context = %{}
options = []
assert {:error, %ArgumentError{}} = ValidateEmail.run(arguments, context, options)
end
end
Testing Anonymous Function Steps
Instead of testing anonymous functions inline, extract them to public functions that can be unit tested:
defmodule MyApp.UserReactor do
use Reactor
input :name
step :greet do
argument :name, input(:name)
run &greet_user/2
end
def greet_user(%{name: name}, _context) do
{:ok, "Hello, #{name}!"}
end
end
defmodule MyApp.UserReactorTest do
use ExUnit.Case, async: true
test "greet_user formats greeting correctly" do
assert {:ok, "Hello, Marty!"} =
MyApp.UserReactor.greet_user(%{name: "Marty"}, %{})
end
test "greet_user handles edge cases" do
assert {:ok, "Hello, !"} =
MyApp.UserReactor.greet_user(%{name: ""}, %{})
end
end
2. Integration Testing Complete Reactors
Test entire workflows by running complete reactors.
Module-Based Reactor Testing
Test your actual reactor modules directly. When testing reactors that interact with databases or other shared resources, you'll typically want to disable async execution to ensure proper isolation with test sandboxes:
defmodule MyApp.UserRegistrationReactor do
use Reactor
input :email
input :password
step :validate_email, MyApp.Steps.ValidateEmail do
argument :email, input(:email)
end
step :hash_password, MyApp.Steps.HashPassword do
argument :password, input(:password)
end
step :create_user, MyApp.Steps.CreateUser do
argument :email, result(:validate_email)
argument :password_hash, result(:hash_password)
end
end
defmodule MyApp.UserRegistrationReactorTest do
use ExUnit.Case, async: false
alias MyApp.UserRegistrationReactor
test "successful user registration flow" do
inputs = %{
email: "user@example.com",
password: "secure_password"
}
assert {:ok, %{id: user_id}} = Reactor.run(UserRegistrationReactor, inputs, async?: false)
assert is_binary(user_id)
end
test "handles invalid email gracefully" do
inputs = %{
email: "invalid-email",
password: "secure_password"
}
assert {:error, _reason} = Reactor.run(UserRegistrationReactor, inputs, async?: false)
end
end
3. Testing Error Handling and Compensation
Test how your reactors handle failures and compensation using Mimic to control step behavior.
Setting Up Mimic for Step Mocking
We recommend using Mimic as your mocking library for testing Reactor steps. Mimic allows you to stub function calls without modifying your production code.
First, set up your test helper to copy the step modules you want to mock:
# test/test_helper.exs
Mimic.copy(MyApp.Steps.ProcessPayment)
Mimic.copy(MyApp.Steps.ReserveInventory)
Mimic.copy(MyApp.Steps.SendConfirmation)
ExUnit.start()
Testing Error Scenarios
defmodule MyApp.PaymentReactor do
use Reactor
input :payment_data
input :items
step :process_payment, MyApp.Steps.ProcessPayment do
argument :payment_data, input(:payment_data)
end
step :reserve_inventory, MyApp.Steps.ReserveInventory do
argument :items, input(:items)
argument :payment_id, result(:process_payment, [:id])
end
step :send_confirmation, MyApp.Steps.SendConfirmation do
argument :payment_id, result(:process_payment, [:id])
end
end
defmodule MyApp.PaymentReactorTest do
use ExUnit.Case, async: false
use Mimic
alias MyApp.PaymentReactor
test "successful payment flow" do
MyApp.Steps.ProcessPayment
|> stub(:run, fn _args, _context, _opts ->
{:ok, %{id: "payment_123", status: :completed}}
end)
MyApp.Steps.ReserveInventory
|> stub(:run, fn _args, _context, _opts ->
{:ok, %{reservation_id: "res_456"}}
end)
MyApp.Steps.SendConfirmation
|> stub(:run, fn _args, _context, _opts ->
{:ok, :sent}
end)
inputs = %{payment_data: %{amount: 100}, items: [%{id: 1}]}
assert {:ok, :sent} = Reactor.run(PaymentReactor, inputs, async?: false)
end
test "payment failure with no compensation needed" do
MyApp.Steps.ProcessPayment
|> stub(:run, fn _args, _context, _opts ->
{:error, :insufficient_funds}
end)
inputs = %{payment_data: %{amount: 100}, items: [%{id: 1}]}
assert {:error, _reason} = Reactor.run(PaymentReactor, inputs, async?: false)
end
test "inventory failure triggers payment compensation" do
MyApp.Steps.ProcessPayment
|> stub(:run, fn _args, _context, _opts ->
{:ok, %{id: "payment_123", status: :completed}}
end)
|> expect(:compensate, fn _reason, _args, _context, _opts ->
:ok
end)
MyApp.Steps.ReserveInventory
|> stub(:run, fn _args, _context, _opts ->
{:error, :out_of_stock}
end)
inputs = %{payment_data: %{amount: 100}, items: [%{id: 1}]}
assert {:error, _reason} = Reactor.run(PaymentReactor, inputs, async?: false)
end
test "compensation returns continue value" do
MyApp.Steps.ProcessPayment
|> stub(:run, fn _args, _context, _opts ->
{:error, :temporary_failure}
end)
|> stub(:compensate, fn _reason, _args, _context, _opts ->
{:continue, %{id: "fallback_payment", status: :manual_review}}
end)
MyApp.Steps.ReserveInventory
|> stub(:run, fn _args, _context, _opts ->
{:ok, %{reservation_id: "res_456"}}
end)
MyApp.Steps.SendConfirmation
|> stub(:run, fn _args, _context, _opts ->
{:ok, :sent}
end)
inputs = %{payment_data: %{amount: 100}, items: [%{id: 1}]}
assert {:ok, :sent} = Reactor.run(PaymentReactor, inputs, async?: false)
end
end
Testing Undo Logic
test "later step failure triggers undo of earlier steps" do
MyApp.Steps.ProcessPayment
|> stub(:run, fn _args, _context, _opts ->
{:ok, %{id: "payment_123"}}
end)
|> expect(:undo, fn _result, _args, _context, _opts ->
:ok
end)
MyApp.Steps.ReserveInventory
|> stub(:run, fn _args, _context, _opts ->
{:ok, %{reservation_id: "res_456"}}
end)
MyApp.Steps.SendConfirmation
|> stub(:run, fn _args, _context, _opts ->
{:error, :email_service_down}
end)
inputs = %{payment_data: %{amount: 100}, items: [%{id: 1}]}
assert {:error, _reason} = Reactor.run(PaymentReactor, inputs, async?: false)
end
Best Practices
Test Organization
- Unit Tests: Create one test file per step module
- Integration Tests: Group related workflow tests together
- Use async: true: For pure unit tests that don't depend on external state
- Use async: false: For integration tests that need deterministic execution
Related Guides
- Debugging Workflows - Troubleshooting techniques
- Error Handling Tutorial - Learn compensation patterns
- Async Workflows Tutorial - Understanding concurrency
This comprehensive testing approach ensures your reactors are reliable, maintainable, and perform well under various conditions.