{{ page.title }}
- Last updated 05 May 2017
- representable v3.0
Trailblazer-test 0.1
- Last updated 28 Nov 21
Test
In Trailblazer, you write operation and integration tests. Operations encapsulate all business logic and are single-entry points to operate your application. 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.
However, the idea of operation tests is: Run the complete unit with a certain input set, and test the side-effects. This differs to the Rails Way™ testing style, where smaller units of code, such as a specific validation or a callback, are tested in complete isolation. While that might look tempting and clean, it will create a test environment that is not identical to what happens in production.
In production, you will never trigger one specific callback or a particular validation, only. Your application will run all code required to create a Song
object, for instance. In Trailblazer, this means running the Song::Create
operation, and testing that very operation with all its side-effects.
Luckily, trailblazer-test provides a simple abstraction allowing to run operations and test all side-effects without creating verbose, unmaintable test code.
Testing Trailblazer applications usually involves the following tests.
- Unit tests for operations: They test all edge cases in a nice, fast unit test environment without any HTTP involved.
- Integration tests for controllers: These Smoke tests only test the wiring between controller, operation and presentation layer. Usually, a coded click path simulates you manually clicking through your app and testing if it works. The preferred way here is using Rack-test and Capybara.
- Unit tests for cells By invoking your cells with arbitrary data you functionally test the rendered markup using Capybara.
Installation
Add this line to your application’s Gemfile:
gem 'trailblazer-test'
And then execute:
$ bundle
Or install it yourself as:
$ gem install trailblazer-test
Assertions
To use available assertions, add in your test _helper
the following modules:
include Trailblazer::Test::Assertions
include Trailblazer::Test::Operation::Assertions
If you are using Trailblazer v2.0 you need to add also:
require "trailblazer/test/deprecation/operation/assertions"
include Trailblazer::Test::Deprecation::Operation::Assertions # in your test class
To be able to test an operation we need 3 auxiliary methods which have to be defined at the start of your tests:
default_params
(required): hash of params which will be always passed to the operation unless overriden byparams
orctx
expected_attrs
(required): hash always used to assert model attributesdefault_options
(required if usingctx
): hash of options which will be always passed to the operation unless overriden byctx
We are also providing 2 helper methods:
params(new_params)
ctx(new_params, options)
Those will merge params and options for you and return the final inputs which then can be passed to the operation under testing.
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.
describe "Create with sane data" do
let(:default_params) { {band: "Rancid"} }
let(:expected_attrs) { {band: "Rancid", title: "Timebomb"} }
# just works
it { assert_pass Create, params(title: "Ruby Soho"), title: "Ruby Soho" }
# trimming works
it { assert_pass Create, params(title: " Ruby Soho "), title: "Ruby Soho" }
end
Both default_params
and expected_attrs
have to be made available via let
to provide all default data. They will automatically get merged with the data per test-case. default_params
will be merged with the params passed into the operation call
, expected_attrs
represent your expected outcome.
Pass deep_merge: false
to disable the deep merging of the third argument expected_attributes
and the auxiliary method expected_attrs
.
The second test case would resolve to this manual test code.
it do
result = Create( band: "Rancid", title: " Ruby Soho " )
assert result.success?
assert_equal "Rancid", result["model"].band
assert_equal "Timebomb", result["model"].title
end
As you can see, assert_pass
drastically reduces the amount of test code.
assert_pass: Block
If you need more specific assertions, use a block with assert_pass
.
describe "Update with sane data" do
let(:default_params) { {band: "Rancid"} }
let(:expected_attrs) { {band: "Rancid", title: "Timebomb"} }
it do
assert_pass Update, ctx(title: " Ruby Soho"), {} do |result|
assert_equal "Ruby Soho", result[:model].title
end
end
end
Here, the only assertion made automatically is whether the operation was run successfully. By yielding the result object in case of success, all other assertions can be made manually.
To test an unsuccessful outcome of an operation, use assert_fail
. This is used for testing all kinds of validations. By passing insufficient or wrong data to the operation, it will fail and mark errors on the errors object.
describe "Update with invalid data" do
let(:default_params) { {band: "Rancid"} }
it { assert_fail Update, ctx(band: "Adolescents"), expected_errors: [:band] }
end
Here, your params are merged into default_params
and the operation is called. The first assertion is whether result.failure?
is true.
After that, the operation’s error object is grabbed. With an array as the third argument to assert_fail
this will test if the errors object keys and your expected keys of error messages are equal.
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.
This roughly translates to the following manual test case.
it do
result = Comment::Create( band: " Adolescents", title: "Timebomb" )
# Timebomb is a Rancid song.
assert result.failure?
assert_equal [:band], result["contract.default"].errors.messages.keys
end
Per default, no assumptions are made on the model.
assert_fail: Block
You can use a block with assert_fail
.
describe "Update with invalid data" do
let(:default_params) { {band: "Rancid"} }
it do
assert_fail Update, ctx(band: " Adolescents") do |result|
assert_equal({band: ["must be Rancid"]}, result["contract.default"].errors.messages)
end
end
end
Only the failure?
outcome is asserted here automatically.
This will test that the operation fails due to a policy failure.
describe "Update with failing policy" do
let(:default_params) { {band: "Rancid"} }
let(:not_allowed_user) { Struct.new(:name).new("not_allowed") }
it do
assert_policy_fail Update, ctx({title: "Ruby Soho"}, current_user: not_allowed_user)
end
end
Add this in your test file to be able to use it:
include Trailblazer::Test::Operation::PolicyAssertions
Change policy name using policy_name
.
assert_policy_fail CustomUpdate, ctx({title: "Ruby Soho"}, current_user: not_allowed_user), policy_name: "custom"
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.
assert_exposes: reader
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)
.
assert_exposes: Lambda
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.
Helpers
There are several helpers to deal with operation tests and operations used as factories.
Add this in your _helper.rb
file to use all available helpers.
include Trailblazer::Test::Operation::Helper
Instead of manually invoking an operation, you can use the call
helper.
it "calls the operation" do
result = call Create, params: {title: "Shipwreck", band: "Rancid"}
assert_equal true, result.success?
end
This will call
the operation and passes through all other arguments. It returns the operation’s result object, which allows you to test it.
result.success? #=> true
result["model"] #=> #<Song id=1, ...>
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.
params
accepts one argument which is merged into default_params
.
let(:default_params) { { title: 'My title' } }
params(artist: 'My Artist') # => { params: { title: 'My title', artist: 'My Artist' } }
params(title: 'Other one') # => { params: { title: 'Other one' } }
ctx
accepts 2 arguments, first one will be merged into the default_params
and the second one will be merged into default_options
let(:default_params) { { title: 'My title' } }
let(:default_options) { { current_user: 'me' } }
ctx(artist: 'My Artist') # => { params: { title: 'My title', artist: 'My Artist' }, current_user: 'me' }
ctx({title: 'Other one'}, current_user: 'you') # => { params: { title: 'Other one' }, current_user: 'you' }
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.
</div>