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:
let(:subaction_response) { Axn::Result.ok("custom message", foo: 1) }
before do
expect(Subaction).to receive(:call).and_return(subaction_response)
endcall!
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
let(:subaction_response) { Axn::Result.ok("custom message", foo: 1) }
before do
expect(Subaction).to receive(:call!).and_return(subaction_response)
endFailure
Because call! will raise, we need to use and_raise rather than and_return:
let(:subaction_exception) { SomeValidErrorClass.new("whatever you expect subclass to raise") }
before do
expect(Subaction).to receive(:call!).and_raise(subaction_exception)
endNOTE: 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.
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 }
endAmbient context
To drive expects :x, on: :ambient_context inputs from a spec, wrap the call in with_ambient_context:
with_ambient_context(user: admin_user) do
result = SomeAction.call(...) # SomeAction AND any nested actions it calls see user
endThe 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:
require "axn/testing"
RSpec.configure do |config|
config.before { Axn::Testing.reset! }
endIt'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.configandAxn::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 makeAxn.tools_forblind to classes that are still loaded.- Registered tool adapters. An adapter gem registers at file-load time, and
requireruns 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 haveAxn.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):
RSpec.configure do |config|
config.define_derived_metadata(file_path: "spec/actions") do |metadata|
metadata[:type] = :service
end
end