spec/unit/support/stepper_spec.rb
require 'lightweight_spec_helper'
require 'support/stepper'
RSpec.describe 'Stepper' do
# make sure randomness is predictable/testable
subject(:stepper) { Stepper.new(self, random:) }
let(:random) { Random.new(1234) }
let(:target) { Target.new }
context 'in isolation (#step is mocked out - too much complexity)' do
before do
allow(stepper).to receive(:step) do |message, &block|
target.record(:stepper, :step, message)
block.call if block
end
end
describe '#instrument' do
it 'can instrument simple method with before step' do
stepper.instrument(target, :method_one, before: 'before step one')
target.method_one
expect(target.calls).to eq([[:stepper, :step, 'before step one'], [:method_one, '[]']])
end
it 'can instrument simple method with after step' do
stepper.instrument(target, :method_one, after: 'after step one')
target.method_one
expect(target.calls).to eq([[:method_one, '[]'], [:stepper, :step, 'after step one']])
end
it 'can instrument method with few args with before/after step' do
stepper.instrument(target, :method_two, before: 'before', after: 'after')
target.method_two(42, 'hello')
expect(target.calls).to eq([
[:stepper, :step, 'before'],
[:method_two, [42, 'hello'], '[]'],
[:stepper, :step, 'after']
])
end
it 'can instrument method with few args and kwargs with before/after step' do
stepper.instrument(target, :method_three, before: 'before', after: 'after')
target.method_three(42, 'hello', kwarg1: 'world', kwarg2: 91)
expect(target.calls).to eq([
[:stepper, :step, 'before'],
[:method_three, [42, 'hello'], { kwarg1: 'world', kwarg2: 91 }, '[]'],
[:stepper, :step, 'after']
])
end
it 'can instrument method with few args, kwargs and a block with before/after step' do
stepper.instrument(target, :method_four, before: 'before', after: 'after')
expected_block = -> {}
target.method_four(42, 'hello', kwarg1: 'world', kwarg2: 91, &expected_block)
expect(target.calls).to eq([
[:stepper, :step, 'before'],
[:method_four, [42, 'hello'], { kwarg1: 'world', kwarg2: 91 }, expected_block, '[]'],
[:stepper, :step, 'after']
])
end
it 'allows to use the value returned by the instrumented method inside of the block' do
got_value = nil
stepper.instrument(target, :method_one, before: 'before', after: 'after') do |return_value|
got_value = return_value
end
target.method_one
expect(got_value).to eq(:some_return_value)
end
end
describe '#start_thread + #interleave_order' do
it 'can start a single thread, keeps the same order as provided, and uses thread index prefixes' do
stepper.start_thread([
'step 1',
'step 2',
'step 3'
]) do
raise('should not run before #run is called')
end
stepper.interleave_order
expect(stepper.steps_left).to eq([
'[0] step 1',
'[0] step 2',
'[0] step 3'
])
end
it 'can start multiple threads, interleaves the order, and uses thread index prefixes' do
stepper.start_thread([
'step 1a',
'step 2a',
'step 3a'
]) do
raise('should not run before #run is called')
end
stepper.start_thread([
'step 1b',
'step 2b',
'step 3b'
]) do
raise('should not run before #run is called')
end
stepper.start_thread([
'step 1c',
'step 2c',
'step 3c'
]) do
raise('should not run before #run is called')
end
stepper.interleave_order
expect(stepper.steps_left).to eq([
'[2] step 1c',
'[1] step 1b',
'[0] step 1a',
'[0] step 2a',
'[0] step 3a',
'[2] step 2c',
'[2] step 3c',
'[1] step 2b',
'[1] step 3b'
])
end
end
describe '#run' do
it 'runs a single thread when there is one' do
stepper.instrument(target, :method_one, before: 'step 1', after: 'step 2')
stepper.instrument(target, :method_two, after: 'step 3')
error = RuntimeError.new('oopsie')
stepper.start_thread([
'step 1',
'step 2',
'step 3'
]) do
target.method_one
raise error
end
stepper.interleave_order
stepper.run
expect(target.calls).to eq([
[:stepper, :step, 'step 1'],
[:method_one, '[0]'],
[:stepper, :step, 'step 2']
])
expect(stepper.aborted?).to be(true)
expect(stepper.errors).to eq([error])
end
it 'handles exceptions' do
stepper.instrument(target, :method_one, before: 'step 1', after: 'step 2')
stepper.instrument(target, :method_two, after: 'step 3')
stepper.start_thread([
'step 1',
'step 2',
'step 3'
]) do
target.method_one
target.method_two(:arg1, :arg2)
end
stepper.interleave_order
stepper.run
expect(target.calls).to eq([
[:stepper, :step, 'step 1'],
[:method_one, '[0]'],
[:stepper, :step, 'step 2'],
[:method_two, %i[arg1 arg2], '[0]'],
[:stepper, :step, 'step 3']
])
end
it 'runs multiple threads' do
stepper.instrument(target, :method_one, before: 'step 1', after: 'step 2')
stepper.instrument(target, :method_two, after: 'step 3')
stepper.start_thread([
'step 1',
'step 2',
'step 3'
]) do
target.method_one
target.method_two(:arg1, :arg2)
end
stepper.start_thread([
'step 1',
'step 2',
'step 3'
]) do
target.method_one
target.method_two(:arg1, :arg2)
end
stepper.interleave_order
stepper.run
# sort is required here because #step is stubbed out, so
# the order is not predictable
expect(target.calls.to_a.sort).to eq([
[:method_one, '[0]'],
[:method_one, '[1]'],
[:method_two, %i[arg1 arg2], '[0]'],
[:method_two, %i[arg1 arg2], '[1]'],
[:stepper, :step, 'step 1'],
[:stepper, :step, 'step 1'],
[:stepper, :step, 'step 2'],
[:stepper, :step, 'step 2'],
[:stepper, :step, 'step 3'],
[:stepper, :step, 'step 3']
])
expect(stepper.errors).to be_empty
end
end
end
context 'in integration (#step is not mocked out)' do
let(:target) { Target.new }
before do
allow(stepper).to receive(:step).and_wrap_original do |original, message, &block|
original.call(message) do
target.record(:stepper, :step, message, "[#{Thread.current.name}]")
block.call if block
end
end
end
20.times do |i|
it "runs multiple threads in the interleaved order - #{i}" do
stepper.instrument(target, :method_one, before: 'step 1', after: 'step 2')
stepper.instrument(target, :method_two, before: 'step 3', after: 'step 4')
stepper.start_thread([
'step 1',
'step 2',
'step 3',
'step 4'
]) do
target.method_one
target.method_two(:arg1, :arg2)
end
stepper.start_thread([
'step 1',
'step 2',
'step 3',
'step 4'
]) do
target.method_one
target.method_two(:arg1, :arg2)
end
stepper.interleave_order
expect(stepper.steps_left).to eq([
'[1] step 1',
'[1] step 2',
'[0] step 1',
'[1] step 3',
'[0] step 2',
'[0] step 3',
'[0] step 4',
'[1] step 4'
])
stepper.run
expect(stepper.errors).to be_empty
expect(target.calls).to eq([
[:stepper, :step, 'step 1', '[1]'],
[:method_one, '[1]'],
[:stepper, :step, 'step 2', '[1]'],
[:stepper, :step, 'step 1', '[0]'],
[:method_one, '[0]'],
[:stepper, :step, 'step 3', '[1]'],
[:method_two, %i[arg1 arg2], '[1]'],
[:stepper, :step, 'step 2', '[0]'],
[:stepper, :step, 'step 3', '[0]'],
[:method_two, %i[arg1 arg2], '[0]'],
[:stepper, :step, 'step 4', '[0]'],
[:stepper, :step, 'step 4', '[1]']
])
end
end
end
class Target
attr_reader :calls
def initialize
@calls = []
@mutex = Mutex.new
end
def method_one
record(:method_one, "[#{Thread.current.name}]")
:some_return_value
end
def method_two(arg1, arg2)
record(:method_two, [arg1, arg2], "[#{Thread.current.name}]")
end
def method_three(arg1, arg2, kwarg1:, kwarg2:)
record(:method_three, [arg1, arg2], { kwarg1:, kwarg2: }, "[#{Thread.current.name}]")
end
def method_four(arg1, arg2, kwarg1:, kwarg2:, &block)
record(:method_four, [arg1, arg2], { kwarg1:, kwarg2: }, block, "[#{Thread.current.name}]")
end
def record(*args, **kwargs)
mutex.lock
calls << if kwargs.empty?
args
else
(args + [kwargs])
end
mutex.unlock
end
private
attr_reader :mutex
end
end