Skip to content

Testing

ALPHA

  • TODO: document testing patterns

Mocking Axn calls

Say you're writing unit specs for PrimaryAction that calls Subaction, and you want to mock out the Subaction call.

To generate a successful Axn::Result:

  • Base case: Axn::Result.ok
  • [Optional] Custom message: Axn::Result.ok("It went awesome")
  • [Optional] Custom exposures: Axn::Result.ok("It went awesome", some_var: 123)

To generate a failed Axn::Result:

  • Base case: Axn::Result.error
  • [Optional] Custom message: Axn::Result.error("It went poorly")
  • [Optional] Custom exposures: Axn::Result.error("It went poorly", some_var: 123)
  • [Optional] Custom exception: Axn::Result.error(some_var: 123) { raise FooBarException.new("bad thing") }

Either way, using those to mock an actual call would look something like this in your rspec:

ruby
let(:subaction_response) { Axn::Result.ok("custom message", foo: 1) }

before do
  expect(Subaction).to receive(:call).and_return(subaction_response)
end

call!

The semantics of call-bang are a little different -- if Subaction is called via call!, you'll need slightly different code to handle success vs failure:

Success

ruby
let(:subaction_response) { Axn::Result.ok("custom message", foo: 1) }

before do
  expect(Subaction).to receive(:call!).and_return(subaction_response)
end

Failure

Because call! will raise, we need to use and_raise rather than and_return:

ruby
let(:subaction_exception) { SomeValidErrorClass.new("whatever you expect subclass to raise") }

before do
  expect(Subaction).to receive(:call!).and_raise(subaction_exception)
end

NOTE: to mock subaction failing via explicit fail! call, you'd use an Axn::Failure exception class.

Mocking Axn arguments

Be aware that in order to improve testing ergonomics, the type validation will return true for any RSpec::Mocks:: subclass as long as Axn.config.env.test? is true.

This makes it much easier to test Axns, as you can pass in mocks without immediately failing the inbound validation.

ruby
subject(:result) { action.call!(sym:) }

let(:action) { build_axn { expects :sym, type: Symbol } }

context "with a symbol" do
  let(:sym) { :hello }
  it { is_expected.to be_ok }
end

context "with an RSpec double" do
  let(:sym) { double(to_s: "hello") }  
  it { is_expected.to be_ok }
end

Ambient context

To drive expects :x, on: :ambient_context inputs from a spec, wrap the call in with_ambient_context:

ruby
with_ambient_context(user: admin_user) do
  result = SomeAction.call(...)  # SomeAction AND any nested actions it calls see user
end

The helper swaps Axn.config.ambient_context_provider for the block and restores it afterwards (even if the block raises). Unlike passing ambient_context: at a single call site, the provider feeds the whole call chain — so nested .call!/.calls see the injected values too. It never touches Current / any ActiveSupport::CurrentAttributes. An explicit ambient_context: kwarg on a specific call still wins over the injected values.

WARNING

Stubbing a Current reader — allow(Current).to receive(:user).and_return(u) — does not feed on: :ambient_context inputs. The default source reads each CurrentAttributes descendant's attribute hash (instance.attributes), not its reader methods, so the stub is silently ignored. Use with_ambient_context instead.

with_ambient_context swaps a process-global provider, so it is isolated under process-based parallel test runners (e.g. parallel_tests) but not under thread-based ones.

Resetting derived state between examples

Axn::Testing.reset! clears axn's process-global state that gets derived from what already ran, so one example's auto-detection can't decide the next example's behavior. It's opt-in — require "axn" does not define Axn::Testing:

ruby
require "axn/testing"

RSpec.configure do |config|
  config.before { Axn::Testing.reset! }
end

It's safe and idempotent to call in a suite-wide before, even on an example with nothing to reset.

What it drops: tracer auto-detection memos (so the next example re-detects rather than reusing a stale tracer), the Sidekiq auto-configure validation memo, and the one-time fiber-isolation warning (so a later example can still trigger it).

What it deliberately leaves alone, because resetting it would be wrong rather than merely unnecessary:

  • Axn.config and Axn::Extensions.config. A host app configures axn once in an initializer; resetting it here would silently un-configure every example after the first, surfacing as unrelated failures deep in someone else's suite rather than as anything traceable to this call.
  • The registries (Strategies, Async::Adapters, Mountable::MountingStrategies). Clearing them restores built-ins and discards deliberate registrations — that's axn's own suite's business, not a host app's.
  • Tools::Registry's recorded action classes. That set accumulates every action class defined in the process; clearing it mid-suite would make Axn.tools_for blind to classes that are still loaded.
  • Registered tool adapters. An adapter gem registers at file-load time, and require runs once per process — a registration dropped here could never be re-established within that process, so a host app with any tool-adapter gem in its Gemfile would have Axn.tools_for (and every adapter lookup) fail after the first example.
  • The Sidekiq auto-configure registration flags (registered?, middleware_registered?, death_handler_registered?). Registering installs onto Sidekiq's actual global middleware chain and death-handler list once per process, so these flags are a record of that installation, not state axn can regenerate. Clearing them would make the record disagree with Sidekiq's real state — the middleware would still be installed, but validation would read it as missing and raise on the next job.

RSpec configuration

Configuring rspec to treat files in spec/actions as service specs (very optional):

ruby
RSpec.configure do |config|
  config.define_derived_metadata(file_path: "spec/actions") do |metadata|
    metadata[:type] = :service
  end
end