Looking for the old trailblazer-test-0.1.1 docs? They’re here, but don’t forget to upgrade to 1.0 - it’s worth it!
Testing a Trailblazer project is very simple. Your test suite usually consists of two separate layers.
trailblazer-test
gem is here to help with that.There’s no need to test controllers, models, service objects, etc. in isolation - unless you want to do so for a better documentation of your internal APIs. As operations are the single entry-point for your functions, your entire stack is covered with the two test types.
The trailblazer-test
gem allows simple, streamlined operation unit tests.
Add this line to your application’s Gemfile:
gem 'trailblazer-test', ">= 1.0.0"
The provided assertions and helpers work with Minitest. For RSpec support use rspec-trailblazer
which provides matchers such as pass_with
and fail_with
around our assertions.
We’re working on RSpec matchers. The current implementation is documented here. Please chat with us if you want to help.
Whenever you introduce a new operation class to your application, you have four choices for testing.
#assert_pass
and #assert_fail
to quickly test all possible inputs and outcomes in a unit test.The goal of trailblazer-test
is to provide an API for writing extremely robust tests for operations (or activities) with a minimal amount of written test code. Asserting edge cases - such as specific validations or possible breaking scenarios - should be a one-liner with this library.
TODO: how to include, etc.
It is a good idea to maintain a slim OperationTest
or OperationSpec
base class in your test_helper.rb
.
By including the assertion modules your tests can use our assertions such as assert_pass
and assert_exposes
.
The very basic version of #assert_pass
runs an operation and asserts the reached terminus was :success
.
it "just check if operation passes" do
input = {params: {memo: {title: "Todo", content: "Buy beer"}}}
assert_pass Memo::Operation::Create, input
end
You need to pass the operation constant and the entire input hash yourself.
The assertion helper returns the operation’s result object (or ctx
when testing an Activity
) allowing you to write your own assertions per test case.
it "returns result" do
# ...
result = assert_pass Memo::Operation::Create, input
assert_equal result[:model].title, "Todo" # your own assertion
end
To implement assertions in addition to the default ones, you can also use the block style.
assert_pass Memo::Operation::Create, input do |result|
assert_equal result[:model].title, "Todo"
end
You can combine your own assertions with the model assertions provided by #assert_pass
.
In many cases, you may want to assert attributes on the model the operation produced. After the input hash, #assert_pass
takes keyword arguments of attributes that must match the model’s.
assert_pass Memo::Operation::Create, input,
title: "Todo",
persisted?: true,
id: ->(asserted:, **) { asserted.id > 0 } # dynamic test.
Internally, the key/value pairs will be transformed to assertions like so.
assert_equal result[:model].title, "Todo"
assert_equal result[:model].persisted?, true
assert result[:model].id > 0
Note that you can use the lambda form for dynamic assertions, for example to check if a date or an ID is set correctly.
When the asserted model isn’t named result[:model]
, use the :model_at
option.
# ...
assert_pass Memo::Operation::Create, input, model_at: :record,
title: "Todo"
Here, the internal logic will check attributes against result[:record]
.
Whenever you’re unsure what’s happening in your operation, or a test fails that shouldn’t, add a question mark and use assert_pass?
.
assert_pass? Memo::Operation::Create, input
Now, run the test case.
The operation will be called with wtf? and the trace is printed on the terminal.
Just like your new best friend #assert_pass
, its counterpart is designed to help you with deliberately failing operations, for instance, to test validation errors.
In the basic form, #assert_fail
runs an operation and checks if it terminates on :failure
.
assert_fail Memo::Operation::Create, {params: {memo: {}}}
Due to unmet validations the Create
operation fails. In turn, this assertion is valid. It simply checks if the operation terminates on the :failure
terminus.
The assertion helper returns the result of the failed operation.
result = assert_fail Memo::Operation::Create, {params: {memo: {}}}#, [:title, :content]
assert_equal result[:"contract.default"].errors.size, 2
You can write your own assertions by plucking the result object.
Use the block form for a more concise test.
assert_fail Memo::Operation::Create, {params: {memo: {}}} do |result|
assert_equal result[:"contract.default"].errors.size, 2
end
It’s possible to mix block style and the assertion helpers explained below.
Just like #assert_pass
helps you testing the model, #assert_fail
is built to assist you with testing contract errors.
You can pass an array of contract fields that must have errors.
assert_fail Memo::Operation::Create, {params: {memo: {}}},
[:title, :content] # fields with errors.
Testing validation errors has just become much simpler. This assertion checks if the title
and content
field of the default contract contains errors.
If you want to test the exact error messages, use the hash form.
assert_fail Memo::Operation::Create, {params: {memo: {}}},
# error messages from contract:
{
title: ["must be filled"],
content: ["must be filled", "size cannot be less than 8"]
}
The assertion will check the error messages for both fields.
Note that the helper will tell you if the errors did not match.
Please let us know if you need anything else! Every app has a slightly different testing style and we’re depending on your input to provide the best testing helpers possible.
If tests fail and you have no clue, add a question mark and use #assert_fail?
.
assert_fail? Memo::Operation::Create, {params: {memo: {}}}
The operation’s trace is printed on the console for you, so you can quickly debug.
In addition to the above assertion helpers, the “Suite” test mode helps you with configuring defaults, so there’s even less code.
The “Suite” test is designed to
A typical operation test case might look as follows.
class MemoOperationTest < Minitest::Spec # test/memo/operation_test.rb
Trailblazer::Test::Assertion.module!(self, suite: true)
# The default ctx passed into the tested operation.
let(:default_ctx) do
{
params: {
memo: { # Note the {:memo} key here!
title: "Todo",
content: "Stock up beer",
}
}
}
end
# What will the model look like after running the operation?
let(:expected_attributes) do
{
title: "Todo",
content: "Stock up beer",
}
end
let(:operation) { Memo::Operation::Create }
let(:key_in_params) { :memo }
it "accepts {tag_list} and converts it to an array" do
assert_pass(
{tag_list: "fridge,todo"}, # input + default_ctx
{tag_list: ["fridge", "todo"]}) # what's expected on the model.
end
it "passes with valid input, {tag_list} is optional" do
assert_pass( {}, {} )
end
it "converts {tag_list} and converts it to an array" do
assert_pass( {tag_list: "fridge,todo"}, {tag_list: ["fridge", "todo"]} ) do |result|
assert_equal true, result[:model].persisted?
end
end
it "fails with invalid {tag_list}" do
assert_fail({tag_list: []}, [:tag_list])
end
it "Update allows integer {duration}" do
assert_pass({tag_list: "todo"}, {tag_list: ["todo"]}, operation: Memo::Operation::Update )
end
let(:yogi) { "Yogi" }
it "fails with missing key {:title}" do
assert_fail( Ctx(exclude: [:title]), [:title] ) do |result|
assert_equal ["must be filled"], result[:"contract.default"].errors[:title]
end
end
it "passes" do
assert_pass( Ctx({current_user: yogi}), {} )
end
it "passes with correct {current_user}" do
ctx = Ctx({current_user: yogi} )
puts ctx
#=> {:params=>{:memo=>{:title=>"Todo", :content=>"Stock up beer"}},
# :current_user=>"Yogi"}
assert_pass ctx, {}
assert_equal %({:params=>{:memo=>{:title=>\"Todo\", :content=>\"Stock up beer\"}}, :current_user=>\"Yogi\"}), ctx.inspect
end
# allows deep-merging additionnal {:params}
it "passes with correct tag_list for user" do
ctx = Ctx(
{
current_user: yogi,
# this is deep-merged with default_ctx!
params: {memo: {title: "Reminder"}}
}
)
assert_pass ctx, {title: "Reminder"}
=begin
{:params=>{
:memo=>{
:title=>"Reminder",
:content=>"Stock up beer"
}
},
:current_user=>"Yogi"}
=end
assert_equal ctx.inspect, %({:params=>{:memo=>{:title=>\"Reminder\", :content=>\"Stock up beer\"}}, :current_user=>\"Yogi\"})
end
it "provides {Ctx()}" do
ctx = Ctx(exclude: [:title])
#=> {:params=>{:memo=>{:content=>"Stock up beer"}}}
assert_fail ctx, [:title]
assert_equal %{{:params=>{:memo=>{:content=>\"Stock up beer\"}}}}, ctx.inspect
end
it "allows {:exclude} and merge into {params}" do
ctx = Ctx({params: {memo: {tag_list: "todo"}}}, exclude: [:title])
#=> {:params=>{:memo=>{:content=>"Stock up beer", :tag_list=>"todo"}}}
assert_equal ctx.inspect, %({:params=>{:memo=>{:content=>"Stock up beer", :tag_list=>"todo"}}})
end
it "provides {Ctx()}" do
ctx = Ctx({current_user: yogi}, exclude: [:title])
#=> {:params=>{:memo=>{:content=>"Stock up beer"}},
# :current_user=>#<User name="Yogi">}
assert_equal ctx.inspect, %({:params=>{:memo=>{:content=>\"Stock up beer\"}}, :current_user=>\"Yogi\"})
end
it "{Ctx} provides {merge: false} to allow direct ctx building without any automatic merging" do
ctx = Ctx({current_user: yogi}, merge: false)
assert_equal %{{:current_user=>\"Yogi\"}}, ctx.inspect
end
end
If you want to use the Suite logic to save typing, instruct our Test.module
using the :suite
option (you could’ve guessed that!).
class MemoOperationTest < Minitest::Spec # test/memo/operation_test.rb
Trailblazer::Test::Assertion.module!(self, suite: true)
This will initialize merging logic along with default_ctx
, expected_attributes
, operation
and key_in_params
. However, you still need to configure those defaults yourself.
You can define a default_ctx
using let()
(and maintain different versions in describe
blocks). default_ctx
defines what to pass into the operation per default.
let(:default_ctx) do
{
params: {
memo: { # Note the {:memo} key here!
title: "Todo",
content: "Stock up beer",
}
}
}
end
Note that default_ctx
is always the full, complete context you’d normally pass into Operation.call()
.
You have two ways to customize this ctx
in your actual test cases:
Ctx()
for more extensive changes, or to entirely overwrite the default structure.The default_ctx
is used as the input
argument for #assert_pass
and #assert_fail
.
The counterpart of default_ctx
defines what the attributes of the operation’s model
will look like when the operation was run successfully.
# What will the model look like after running the operation?
let(:expected_attributes) do
{
title: "Todo",
content: "Stock up beer",
}
end
This hash is used only with #assert_pass
and represents the third positional argument.
Instead of having to pass the asserted operation constant each time you use an assertion helper, you can conveniently define it using let()
.
let(:operation) { Memo::Operation::Create }
Again, you may use any level of describe
to fine-tune your tests. You may also override it using the [:operation
option].
As typical for a Rails app, the actual incoming form fields are nested under a specific key, such as params[:memo][...]
. The key_in_params
sets this key to :memo
. This is necessary for the automatic merging logic in Suite.
Note that, if your app doesn’t nest attributes under a “root” key, you can set key_in_params
to false
.
let(:key_in_params) { false }
This will instruct the merging logic to omit that model-named key. In turn, the default_ctx
shall have one nesting level less.
let(:default_ctx) do
{
params: {
title: "Todo",
content: "Stock up beer",
}
}
end
The assertion helpers from Suite
are bringing together operation
, default_ctx
and expected_attributes
. As a matter of fact, #assert_pass
in “Suite style” doesn’t need any arguments at all.
it "passes with valid input, {tag_list} is optional" do
assert_pass( {}, {} )
end
This resolves to an effective call like so.
assert_pass operation, default_ctx, expected_attributes
As you can see, the Suite helper uses your defaults wherever possible. Now, the real power becomes visible when testing specific cases.
it "accepts {tag_list} and converts it to an array" do
assert_pass(
{tag_list: "fridge,todo"}, # input + default_ctx
{tag_list: ["fridge", "todo"]}) # what's expected on the model.
end
The first hash passed here represents a chunk of the params
you want to test. It is merged into default_ctx[:params][:memo]
for you.
The second hash is merged with expected_attributes
. In addition to all default assumptions, it checks whether the model’s tag_list
property is an array of two elements.
All tactical features such as the block style or returning the result object are identical to the basic #assert_pass
helper.
Suite’s #assert_pass
is aiming at very typical use cases when writing tests with a huge cover rate. Nevertheless, it saves you from having to repeat params
hashes or expected attributes over and over again.
The assertion helper for testing validation errors also uses the default_ctx
.
it "fails with invalid {tag_list}" do
assert_fail({tag_list: []}, [:tag_list])
end
This makes testing a specific edge case very straight-forward. Here, we test if passing an invalid tag_list
in params[:memo]
will yield the expected contract validation error.
Note that only tag_list
is invalid! The required field title
is still set via the default_ctx
.
If the rather simple auto-merging of the ctx
in Suite
is not enough, and you’re in need of a custom-tailored ctx
to pass into the tested operation, use Ctx()
. It mostly goes as the first argument to #assert_pass
and #assert_fail
.
it "passes with correct tag_list for user" do
ctx = Ctx(
{
current_user: yogi,
# this is deep-merged with default_ctx!
params: {memo: {title: "Reminder"}}
}
)
assert_pass ctx, {title: "Reminder"}
The Ctx()
helper will, per default, grab the default_ctx
and deep-merge it with the hash you’re providing. The merge results in the following data-structure to be passed into an operation.
{:params=>{
:memo=>{
:title=>"Reminder",
:content=>"Stock up beer"
}
},
:current_user=>"Yogi"}
The helper brings some nice API especially for testing edge cases and failures.
it "fails with missing key {:title}" do
assert_fail( Ctx(exclude: [:title]), [:title] ) do |result|
assert_equal ["must be filled"], result[:"contract.default"].errors[:title]
end
end
Whenever you use Ctx()
the computed hash is passed through directly into the operation, so you have full control over what’s going in. Both assert_pass
and assert_fail
are not altering your ctx anymore.
The standard behavior of Ctx()
is to merge the passed hash with default_ctx
.
it "passes with correct {current_user}" do
ctx = Ctx({current_user: yogi} )
puts ctx
#=> {:params=>{:memo=>{:title=>"Todo", :content=>"Stock up beer"}},
# :current_user=>"Yogi"}
assert_pass ctx, {}
# ...
end
This allows to quickly add variables such as the :current_user
to the ctx.
Anything under the :params
key is deep-merged with your default_ctx
, which is helpful for customizing form field values.
it "passes with correct tag_list for user" do
ctx = Ctx(
{
current_user: yogi,
# this is deep-merged with default_ctx!
params: {memo: {title: "Reminder"}}
}
)
assert_pass ctx, {title: "Reminder"}
Note how :title
is added to the existing :memo
hash, effectively overwriting the default :title
.
If you want to delete a certain form field from the input, you can use :exclude
. This is great for testing presence or required validations.
it "provides {Ctx()}" do
ctx = Ctx(exclude: [:title])
#=> {:params=>{:memo=>{:content=>"Stock up beer"}}}
assert_fail ctx, [:title]
# ...
end
The :title
field under :song
is now removed from the input.
You may also use :exclude
in combination with the params merging.
ctx = Ctx({params: {memo: {tag_list: "todo"}}}, exclude: [:title])
#=> {:params=>{:memo=>{:content=>"Stock up beer", :tag_list=>"todo"}}}
Or exclude form fields and still add variables.
it "provides {Ctx()}" do
ctx = Ctx({current_user: yogi}, exclude: [:title])
#=> {:params=>{:memo=>{:content=>"Stock up beer"}},
# :current_user=>#<User name="Yogi">}
# ...
end
To stub any step with heavy logic, I/O calls or external API interactions, use #mock_step
.
Consider the following operation.
module Memo::Operation
class Create < Trailblazer::Operation
step :model
step Subprocess(Validate), id: :validate
step :save
# ...
end
end
In order to create a “copy” of Create
, where the step :save
is replaced with your logic do as follows.
create_operation = mock_step(Memo::Operation::Create, path: [:save]) do |ctx, **|
# new logic for {save}.
ctx[:saved] = true
end
The :path
option allows specifying what step to mock, the block implements the new logic for that particular step.
You now have to pass the mocked operation into your assertions (or, when using Suite
, make the let(:operation)
block return the mocked one).
assert_pass create_operation, {
# ...
}
The #mock_step
feature internally uses patching for this allows to mock even deeply nested steps when using Subprocess()
.
Here’s the Validate
operation from the above example.
module Memo::Operation
class Validate < Trailblazer::Operation
step :check_params
step :verify_content
# ...
end
end
Let’s say we want to stub verify_content
in Validate
, which in turn sits in Create
. You need to pass the complete path to the helper.
create_operation = mock_step(Memo::Operation::Create,
path: [:validate, :verify_content]) do |ctx, **|
# new logic for {Validate#verify_content}.
ctx[:is_verified] = true
end
Make sure you use IDs for nested operations to make patching simpler. Check the patching docs for a better understanding.
To test a bunch of attributes of an arbitrary object, #assert_pass
internally uses #assert_exposes
.
Pass a hash of key/value tuples to assert_exposes
to test that all attributes of the asserted object match the provided values.
it do
assert_exposes model, title: "Timebomb", band: "Rancid"
end
Per default, this will read the values via model.{key}
from the asserted object (model
) and compare it to the expected values.
This is a short-cut for tests such as the following.
assert_equal "Timebomb", model.title
assert_equal "Rancid", model.band
Note that assert_exposes
accepts any object with a reader interface.
If the asserted object exposes a hash reader interface, use the :reader
option.
it do
assert_exposes model, {title: "Timebomb", band: "Rancid"}, reader: :[]
end
This will read values with via #[]
, e.g. model[:title]
.
If the object has a generic reader, you can pass the name via :reader
.
it do
assert_exposes model, {title: "Timebomb", band: "Rancid"}, reader: :get
end
Now the value is read via model.get(:title)
.
You can also pass a lambda to assert_expose
in order to compute a dynamic value for the test, or for more complex comparisons.
it do
assert_exposes model, title: "Timebomb", band: ->(actual:, **) { actual.size > 3 }
end
The lambda will receive a hash with the :actual
value read from the asserted object. It must return a boolean.