In this series:

Testing pipelines.

Testing any complex workflow can be challenging. Composable pipelines can make it easier to use a “divide and conquer” approach to testing.

1. Unit test each step in isolation.

Steps may or may not be complex, but their simple #call(Result) Result interface makes them easy to test.

step = MultiplyBy.(2)
initial_result = Result.new([1, 2, 3, 4])
result = step.call(initial_result)

expect(result.continue?).to be(true)
expect(result.value).to eq([2, 4, 6, 8])

You can test that specialised steps add the right metadata to the result.

step = ParamsValidatorStep.new do |schema|
  schema.field(:limit).type(:integer).required
end

initial_result = Result.new([], params: { limit: 'nope!' })
result = step.call(initial_result)

expect(result.continue?).to be(false)
expect(result.errors[:limit][0]).to eq('must be an integer')

2. Test that the pipeline is composed correctly.

# An RSpec helper to assert that a pipeline is composed of a sequence of steps
expect(NumberCruncher).to be_composed_of_steps(
  ValidateSetSize,
  MultiplyBy.(2),
  LimitSet
)

Such an RSpec matcher basically needs to compare the given steps with Pipeline#steps.

You can of course test an entire pipeline end-to-end, in much the same way you’d test an individial step.

initial_result = Result.new([1, 2, 3, 4], params: { limit: 5 })
result = NumberCruncher.call(initial_result)

expect(result.continue?).to be(true)
expect(result.value).to eq([2, 4, 6, 8])

There really isn’t a lot more to it.

A big caveat is that whether a step has side effects (calling a database, an external API, the file system, etc) is up to you (unless you stick to functional patterns and avoid side effects, which is not a given). In that case you’d setup and test those dependencies accordingly, like you’d do with other similar cases.