Test
- Last updated 22 Aug 22
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.
- Integration tests or system tests covering the full stack, and using Capybara to “click through” the happy path and possible edge-cases such as an erroring form. Smoke tests make sure of the integrity of your application, and assert that controllers, views and operations play well together. We will provide more documentation about system tests shortly.
- Operation unit tests guarantee that your operations, data processing and validations do what they’re supposed to. As they’re much faster and easier to write than full stack “smoke tests” they can cover any possible input to your operation and help quickly asserting the created side-effects. The
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.
Installation
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.
Operation unit tests
Whenever you introduce a new operation class to your application, you have four choices for testing.
- You could skip testing and program the next feature - then, you shouldn’t be reading this.
- If the operation is simple enough, cover its behavior in a smoke test and test both the successful invocation and its invalid state in a UI test. Nevertheless, this can be cumbersome and slow.
- Write your own operation unit tests.
- Use
assert_pass
andassert_fail
to quickly test all possible inputs and outcomes in a unit test.
Overview
The goal of trailblazer-test
is to provide simple, strong, non-verbose testing of operations and edge cases.
it "converts {duration} to seconds" do
assert_pass( {duration: "2.24"}, {duration: 144} )
end
The assertion helpers focus on minimizing test code and the associated pain with it.
A typical operation test case might look as follows.
# test/operation/song_operation_test.rb
class SongOperationTest < OperationSpec
# The default ctx passed into the tested operation.
let(:default_ctx) do
{
params: {
song: { # Note the {song} key here!
band: "Rancid",
title: "Timebomb",
# duration not present
}
}
}
end
# What will the model look like after running the operation?
let(:expected_attributes) do
{
band: "Rancid",
title: "Timebomb",
}
end
let(:operation) { Song::Operation::Create }
let(:key_in_params) { :song }
it "passes with valid input, {duration} is optional" do
assert_pass( {}, {} )
end
it "converts {duration} to seconds" do
assert_pass( {duration: "2.24"}, {duration: 144} )
end
it "converts {duration} to seconds" do
assert_pass( {duration: "2.24"}, {duration: 144} ) do |result|
assert_equal true, result[:model].persisted?
end
end
it "converts {duration} to seconds" do
result = assert_pass( {duration: "2.24"}, {duration: 144} ) # with our without a block
assert_equal true, result[:model].persisted?
end
describe "we have {:song}, not {:model}" do
let(:operation) {
Class.new(Song::Operation::Create) do
step :set_song
def set_song(ctx, **)
ctx[:song] = ctx[:model]
ctx[:model] = "i don't exist"
end
end
}
it "what" do
assert_pass( {duration: "2.24"}, {duration: 144}, model_at: :song )
end
end
it "fails with missing {title} and invalid {duration}" do
assert_fail( {duration: 1222, title: ""}, [:title, :duration] )
end
# ...
end
As - hopefully - visible in this example trailblazer-test
focuses on minimalistic, less verbose testing of operations. This in turn makes testing literally a no-brainer where testing all edge cases actually is fun.
Note that only #assert_pass
and #assert_fail
are specific to Trailblazer. Structural helpers such as it
and describe
are provided by Minitest, and the test style is completely up to you. See below for conventions of how we test operations.
Base class
It is a good idea to maintain a slim OperationTest
or OperationSpec
base class in your test_helper.rb
.
# test/test_helper.rb
# ...
class OperationSpec < Minitest::Spec
include Trailblazer::Test::Assertions
include Trailblazer::Test::Operation::Assertions
end
By including the assertion modules your tests can use our assertions such as assert_pass
and assert_exposes
.
Configuration
The trailblazer-test
gem allows to configure common options on the test level.
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: {
song: { # Note the {song} key here!
band: "Rancid",
title: "Timebomb",
# duration not present
}
}
}
end
Note that default_ctx
is always the full context that you’d normally pass into Operation.call()
.
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
{
band: "Rancid",
title: "Timebomb",
}
end
As in 99% of operation usage a model is created or altered, this hash represents the attributes of the model that were modified by the operation.
Instead of having to pass what operation gets tested to each assertion you can conveniently define it using let()
.
let(:operation) { Song::Operation::Create }
Again, you may use any level of describe
to fine-tune your tests.
assert_pass
The assertion helpers are bringing together operation
, default_ctx
and expected_attributes
.
Use assert_pass
to run an operation and assert it was successful, while checking if the attributes of the operation’s :model
are what you’re expecting.
it "passes with valid input, {duration} is optional" do
assert_pass( {}, {} )
end
assert_pass
with empty hashes will invoke the operation configured via let(:operation)
by passing the hash from let(:default_ctx)
into call
. After that, it grabs the operation’s result[:model]
and tests all attributes from expected_attributes
on it.
If you were to hand-write this code, something along this would happen.
it "passes with valid input, {duration} is optional" do
result = Song::Operation::Create.(default_ctx)
assert result.success?
assert_equal "Rancid", result[:model].band
assert_equal "Timebomb", result[:model].title
end
That’s quite a lot of code, right?
assert_pass
makes it really simple to override ingoing form attributes. This is perfect for testing validations of your contracts by simulating differing user input.
it "converts {duration} to seconds" do
assert_pass( {duration: "2.24"}, {duration: 144} )
end
Per default, the assertion accepts two arguments.
- The first hash to
assert_pass
is merged into thedefault_ctx
. - The second argument is merged with
expected_attributes
and allows to assert a different outcome.
However, and now listen up, default_ctx
and the first hash are not merged on the top-level! If let(:key_in_params)
is set the merging behavior is changed.
let(:key_in_params) { :song }
With this configuration in place, the first argument given to assert_pass
is merged with the hash found at default_ctx[:params][:song]
. This allows for quickly changing the input that usually represents a form or API document - things to test that you will spend a lot of time on.
Again, if you’re hand-coding, here’s what is going on.
assert_pass( {duration: "2.24"}, {duration: 144} )
# identical to
input = default_ctx[:params][:song].merge!(duration: "2.24")
result = Song::Operation::Create.(input)
# ... assertions
Changing the form input part in order to test validations and processing is a pretty common task. The assert_pass
assertion is built to minimize test code and helps you with just that.
In case you don’t use a key in your form input you can set :key_in_params
to false
.
let(:key_in_params) { false }
let(:default_ctx) do
{
params: {
band: "Rancid",
title: "Timebomb",
}
}
end
Note that this decision is also reflected in your default_ctx
, which now doesn’t have the :song
key anymore.
When using assert_pass
and assert_fail
you can still worryless pass form input. The assertion now merges it directly with :params
.
it "sets {title}" do
assert_pass( {title: "Ruby Soho"}, {title: "Ruby Soho"} )
end
assert_pass
usually tests the expected attributes on result[:model]
. Use :model_at
to change the key where to find the asserted object.
In this example, the asserting code will now grab the model from result[:song]
.
If you need more specific assertions, use a block with assert_pass
.
it "converts {duration} to seconds" do
assert_pass( {duration: "2.24"}, {duration: 144} ) do |result|
assert_equal true, result[:model].persisted?
end
end
This will run all assertions built into assert_pass
. By yielding the operation’s result in case of success, additional assertions can be made manually.
Note that the block is not run when an earlier assertion in assert_pass
has failed.
Both versions of assert_pass
(with or without a block) return the result object of the operation call.
it "converts {duration} to seconds" do
result = assert_pass( {duration: "2.24"}, {duration: 144} ) # with our without a block
assert_equal true, result[:model].persisted?
end
Use this for adding assertions outside the block.
assert_fail
To test an unsuccessful outcome of an operation, use assert_fail
. This is helpful for testing all kinds of validations. By invoking the operation with insufficient or wrong data it will fail and add errors onto the errors object. Those can be matched against a list of expected errors.
it "fails with missing {title} and invalid {duration}" do
assert_fail( {duration: 1222, title: ""}, [:title, :duration] )
end
The merging of the form input works exactly as in assert_pass
, respecting keys_in_params
and default_ctx
.
- First, the operation is run, returning the result object. The first assertion is whether
result.failure?
is true. - After that, the operation’s error object is extracted and its keys are compared to the list that was provided to
assert_fail
as the second argument. If the keys in the actual errors object and your array match, this assertions passes, and proves that particular validations do work.
This translates to the following manual test case.
it "fails with missing {title} and invalid {duration}" do
assert_fail( {duration: 1222, title: ""}, [:title, :duration] )
# is almost identical to:
input = {params: {song: {title: "", band: "Rancid", duration: 1222}
result = Song::Operation::Create(input)
assert result.failure?
assert_equal [:title, :duration], result["contract.default"].errors.messages.keys
end
Per default, no assumptions are made on the model. Use assert_fail
with a block to do so.
In 2.0 and 2.1, the errors object defaults to result["contract.default"].errors
. In TRB 2.2, there will be an operation-wide errors object decoupled from the contracts.
If testing the presence of validation errors is not enough you can pass a hash to assert_fail
to check that a particular error message (or multiple) is set.
it "fails with missing {title} and invalid {duration} with given error messages" do
assert_fail( {duration: 1222, title: ""},
{title: ["must be filled"], duration: ["must be String"]} # hash instead of array!
)
end
When there is only one error message per field, you may omit the array for your convenience.
it "fails with missing {title} and invalid {duration} with given error messages" do
assert_fail( {title: "", duration: "1222"}, {title: "must be filled"} )
end
assert_fail
automatically wraps error messages in an array.
You can use a block with assert_fail
for more specific assertions.
it "fails with missing {title} and invalid {duration}" do
assert_fail( {duration: 1222, title: ""}, [:title, :duration] ) do |result|
assert_equal false, result[:model].persisted?
assert_equal 2, result[:"contract.default"].errors.size
end
end
The block is run in addition to assert_fail
’s built-in assertions. Just as in assert_pass
it receives the operation’s result
object and allows you to add assertions as you desire.
Both versions of assert_fail
(with or without a block) return the result object of the operation call.
it "fails with missing {title} and invalid {duration}" do
result = assert_fail( {duration: 1222, title: ""}, [:title, :duration] )
assert_equal false, result[:model].persisted?
end
Use this for adding assertions outside the block.
Debugging
Both assert_pass
and assert_fail
allow to run the operation using wtf?
. When you’re unsure about what is going on inside the tested operation, the :wtf?
option comes to rescue.
it "fails with missing {title} and invalid {duration}" do
assert_fail( {duration: 1222, title: ""}, [:title, :duration], :wtf? )
#=>
# -- Song::Operation::Create
# |-- Start.default
# |-- model.build
# |-- contract.build
# |-- contract.default.validate
# | |-- Start.default
# | |-- contract.default.params_extract
# | |-- contract.default.call
# | `-- End.failure
# `-- End.failure
end
In the trace on the console, it’s obvious that the contract validation failed. Again, note that this works for both assert_pass
and assert_fail
.
Options
All let()
options can also be passed directly to the assertion, allowing you to override the let()
option or not having to define it on the test level at all.
it "Update allows integer {duration}" do
assert_pass( {duration: 2224}, {duration: 2224}, operation: Song::Operation::Update )
end
Accepted options are :operation
, :default_ctx
, :expected_attributes
and :key_in_params
.
Ctx Helper
If you need a customized ctx
to pass into the tested operation, use Ctx()
. It’s mostly used as the first argument to assert_pass
and assert_fail
.
it "converts {duration} to seconds" do
assert_pass( Ctx({current_user: yogi}), {title: "Timebomb"} )
end
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 "provides {Ctx()}" do
ctx = Ctx({current_user: yogi} )
#=> {:params=>{:song=>{:band=>"Rancid", :title=>"Timebomb"}},
# :current_user=>#<User name="Yogi">}
# ...
end
This allows to quickly add injections such as the :current_user
to the ctx.
Anything under the :params
key is deep-merged with your default_ctx
, which is super handy when you need to add non-form variables.
it "provides {Ctx()}" do
ctx = Ctx(
{
current_user: yogi,
params: {song: {duration: 999}} # this is deep-merged!
}
)
#=> {:params=>{:song=>{:band=>"Rancid", :title=>"Timebomb", duration: 999}},
# :current_user=>#<User name="Yogi">}
# ...
end
Note how :duration
is added to the existing :song
hash.
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=>{:song=>{:band=>"Rancid"}}}
# ...
end
The :title
field under :song
is now removed from the input.
You may also use :exclude
in combination with the deep-merge.
it "provides {Ctx()}" do
ctx = Ctx({current_user: yogi}, exclude: [:title])
#=> {:params=>{:song=>{:band=>"Rancid"}},
# :current_user=>#<User name="Yogi">}
# ...
end
assert_exposes
Test attributes of an arbitrary object.
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.
factory
You should always use operations as factories in tests. The factory
method calls the operation and raises an error should the operation have failed. If successful, it will do the exact same thing call
does.
it "calls the operation and raises an error and prints trace when fails" do
exp = assert_raises do
factory Create, params: {title: "Shipwreck", band: "The Chats"}
end
exp.inspect.include? %(Operation trace)
exp.inspect.include? "OperationFailedError: factory(Create) has failed due to validation "\
"errors: {:band=>['must be Rancid']}"
end
If the factory operation fails, for example due to invalid form input, it raises a OperationFailedError
exception.
factory( Song::Create, { title: "" } )["model"]
#=> Trailblazer::Test::OperationFailedError: factory( Song::Create ) failed.
It is absolutely advisable to use factory
in combination with let
.
let(:song) { factory( Song::Create, { title: "Timebomb", band: "Rancid" } ) }
Also, you can safely use FactoryGirl’s attributes_for
to generate input.
This helper allows you to mock any step within a given or deeply nested activities. For example,
class Show < Trailblazer::Activity::FastTrack
class Complexity < Trailblazer::Activity::FastTrack
class ExternalApi < Trailblazer::Activity::FastTrack
step :make_call
# ...
end
step :some_complex_task
step Subprocess(ExternalApi)
# ...
end
step :load_user
step Subprocess(Complexity)
# ...
end
To skip processing inside :load_user
and use a mock instead, use mock_step
.
it "mocks loading user" do
new_activity = mock_step(Show, id: :load_user) do |ctx|
ctx[:user] = Struct.new(:name).new('Mocky')
end
assert_pass new_activity, default_params, {} do |(signal, (ctx, flow_options))|
assert_equal ctx[:user].name, 'Mocky'
assert_equal ctx[:seq], [:some_complex_task, :make_call]
end
end
Internally, it creates and returns a fresh, subclassed activity (via patching) whilst replacing the step for given :id
. Be advised that this does not change the original activity class.
You can also mock any nested activity (aka Subprocess
) which does any heavy computations or I/O calls.
new_activity = mock_step(Show, id: Show::Complexity) do
true # no-op to avoid any Complexity
end
In case you want to mock only single step from the nested activity, you can do so by passing it as a subprocess
.
new_activity = mock_step(Show, id: :some_complex_task, subprocess: Show::Complexity) do
# Mock only single step from nested activity to do nothing
true
end
It’ll search the :id
to be mocked within nested activity instead of top-level activity.
In addition, if you want to mock any deeply nested step in subprocess's
activity, it can be done via passing subprocess_path
.
new_activity = mock_step(Show, id: :make_call, subprocess: Show::Complexity, subprocess_path: [Show::Complexity::ExternalApi]) do
# Some JSON response
end
subprocess_path
should list n-level of nested activities in the order they are nested. Internally, it uses patching API supported by Subprocess
helper.
Rspec
We’re still working on RSpec matchers. Please chat with us if you want to help.
System Test
This document will describe how we test the full stack using Capybara and system tests.
Also described in part II of the new book.