Chapters

2.1 Trailblazer Test Documentation

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

trailblazer-test 1.0.0

Whenever you introduce a new operation class to your application, you have four choices for testing.

  1. You could skip testing and program the next feature - then, you shouldn’t be reading this.
  2. 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.
  3. Write your own operation unit tests.
  4. Use #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.

Base class

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.

Assert_pass

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.

Assert_pass Return value

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

Assert_pass Block style

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.

Assert_pass Model assertions

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.

Assert_pass model_at

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].

Assert_pass WTF?

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.

Assert_fail

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.

Assert_fail Return value

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.

Assert_fail Block style

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.

Assert_fail Contract errors

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.

Assert_fail WTF?

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.

Suite

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

  • simplify the merging of the input arguments
  • merge expected model attributes automatically without having to repeat those
  • set defaults, such as the tested operation

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

Suite Installation

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.

Suite default_ctx

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:

  • Pass a hash to the assertion helpers, which will be merged with the model-specific values.
  • 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.

Suite Expected attributes

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.

Suite operation

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].

Suite key_in_params

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

Suite assert_pass

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.

Suite assert_fail

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.

Ctx Helper

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.

Ctx Helper Merge

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.

Ctx Helper Params Merge

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.

Ctx Helper Exclude

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

Mock_step

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, {
  # ...
}

Mock_step Nesting

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.

Assert_exposes

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.

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.