by Lin Jen-Shin (godfat)
Muack -- A fast, small, yet powerful mocking library.
Inspired by RR, and it's 32x times faster (750s vs 23s) than RR for running Rib tests.
Because RR has/had some bugs and it is too complex for me to fix it. Muack is much simpler and thus much faster and much more consistent.
- Tested with MRI (official CRuby) and JRuby.
gem install muack
Here's a quick example using Pork.
require 'pork/auto'
require 'muack'
describe 'Hello' do
include Muack::API
before{ Muack.reset }
after { Muack.verify }
would 'say world!' do
str = 'Hello'.dup
mock(str).say('!'){ |arg| "World#{arg}" }
str.say('!').should.eq 'World!'
end
end
There are 3 parts in Muack, which are:
Mocks are objects with injected methods which we could observe, and mocks modifiers are telling how we want to observe the mocks, and finally argument verifiers could help us observe the arguments passed to the injected methods.
Let's explain them one by one.
There are also 4 different kinds of mocks in Muack, which are:
- Mocks
- Stubs
- Spies
- Coats
You could think of mocks are sort of stubs combined with spies. Here's the inequation: (we'll talk about coats later)
mock >= stub + spy
Stubs help us inject methods into the objects we want to observe. Spies help us observe the behaviours of the objects. As for mocks, they inject methods and observe the behaviours in realtime. They complain immediately if the behaviours were unexpected. In contrast, if we're not asking spies, stubs won't complain themselves.
Here's an example using a mock:
obj = Object.new
mock(obj).name{ 'obj' }
p obj.name # 'obj'
p Muack.verify # true
Which is similar to using a stub with a spy:
obj = Object.new
stub(obj).name{ 'obj' }
p obj.name # 'obj'
spy(obj).name
p Muack.verify # true
You might wonder, then why mocks or why stubs with spies? The advantage of using mocks is that, you only need to specify once. I guess this is quite obvious. However, sometimes we don't care if the injected methods are called or not, but sometimes we do care. With stubs and spies, we could always put stubs in the before/setup block, and only when we really care if they are called or not, we put spies to examine.
The other difference is that, spies could partially verify the corresponding stubs, but not necessarily completely as mocks. For example, we could stub two methods, but only verify one of them with a spy.
obj = Object.new
stub(obj).name{ 'obj' }
stub(obj).id { 12345 }
p obj.name # 'obj'
p obj.id # 12345
spy(obj).name
p Muack.verify # true
This is similar as mixing mocks and stubs, as in the following example:
obj = Object.new
stub(obj).name{ 'obj' }
mock(obj).id { 12345 }
p obj.name # 'obj'
p obj.id # 12345
p Muack.verify # true
However you should not mix mocks and stubs with the same method, or you might encounter some unexpected result. Jump to Caveat for more detail.
The other differences for stubs and spies, please check Pattern Matching for stubs and spies. In short, stubs and spies would do some kind of pattern matching, making the order of the same method irrelevant.
On the other hand, stubs aren't limited to testing. If we want to monkey patching something, stubs could be useful as we don't care how many times the injected methods are called. Jump to Muack as a mocky patching library section for more detail.
Calling Muack.reset
is essentially resetting all mocks, returning all
objects/classes back to their original states. In the very first example,
we do this in a before block to make sure that we're in a clean state.
Calling Muack.verify
is essentially verifying if all mocks and spies are
satisfied, if so, it would return true; otherwise, raising an exception.
Then, no matter verification passed or not, Muack would reset itself.
That means we don't really need to call Muack.reset
in a before block if
we're pretty sure that all test cases would call Muack.verify
in the end,
resetting everything.
On the other hand, we could also reset or verify a single object without affecting the others. This is helpful in the cases of mocking some very basic objects like Time, without causing too much side effect.
name = 'str'.dup
stub(name).to_s{ 'hi' }
stub(Time).new { Time.at(0) }
mock(Time).now { Time.new }
p name.to_s # 'hi'
p Time.now.to_i # 0
p Time.new.to_i # 0
p Muack.verify(Time) # true
p name.to_s # 'hi'
p Time.now.to_i > 0 # true
p Time.new.to_i > 0 # true
Muack.reset(name)
p name.to_s # 'str'
p Muack.verify # true
Now we could talk about coats. It's a kind of mocks but it would wear out
instead of raising an exception when it's called more than expected times.
This is useful when we want to restore the original behaviour of a
particular method at some point. The problem is that we can't simply
call the original method because it's already mocked! We could workaround
this by using Muack.verify
or Muack.reset
at some point, or let coats
handle that.
Here's an example with coats:
coat(Time).now{ Time.at(0) }.times(2)
p Time.now.to_i == 0 # true
p Time.now.to_i == 0 # true
p Time.now.to_i > 0 # true
p Muack.verify # true
Without coats we might end up with:
mock(Time).now{ Time.at(0) }
mock(Time).now{ Muack.verify(Time); Time.at(0) }
p Time.now.to_i == 0 # true
p Time.now.to_i == 0 # true
p Time.now.to_i > 0 # true
p Muack.verify # true
Sometimes we just want to stub something without a concrete object in mind.
By calling mock
or stub
without any argument, we're creating an anonymous
mock/stub. This is because the default argument for mock
and stub
is just
Object.new
.
But how do we access the anonymously created object? We'll use the object
method on the modifier to access it. Here's an example:
obj = mock.name{ 'obj' }.object
p obj.name # 'obj'
p Muack.verify # true
This is exactly equivalent to this:
mock(obj = Object.new).name{ 'obj' }
p obj.name # 'obj'
p Muack.verify # true
Also, if we want to mock over multiple methods, we could also take the
advantage of block form of mock
and stub
method.
obj = mock{ |m|
m.name{ 'obj' }
m.id { 12345 }
}.object
p obj.name # 'obj'
p obj.id # 12345
p Muack.verify # true
We can't omit the object
method here because after defining the injected
method, we'll get a modifier to describe the properties of the injected
method. Jump to Mocks Modifiers for details.
There are chances that we don't really want to change the underlying implementation for a given method, but we still want to make sure the named method is called, and that's what we're testing for.
In those cases, proxy mode would be quite helpful. To turn a mock or stub into proxy mode we simply do not provide any block to the injected method, but just name it. Here's an example:
str = 'str'.dup
mock(str).reverse
p str.reverse # 'rts'
p Muack.verify # true
Note that if reverse was not called exactly once, the mock would complain. We could also use stub + spy to do the same thing as well:
str = 'str'.dup
stub(str).reverse
p str.reverse # 'rts'
spy(str).reverse
p Muack.verify # true
You might also want to use peek_args
and peek_return
modifier along with
proxies in order to slightly tweak the original implementation. Jump to
Muack as a mocky patching library section for more detail.
Occasionally we would want to fake some of the values inside a hash, but we don't want to interfere with the other values in that hash, and we also don't want to modify it directly, or we'll need to make sure to restore it after the tests.
Partial mode is not really a mode, but a combination of using proxy mode and
the pattern matching mechanism specialized in stubs.
Suppose we want to stub ENV
(which is not a hash but you get the idea),
enabling some of the flags inside tests without really setting it, we'll do:
@user = ENV['USER']
p ENV['NDEBUG'] # nil
stub(ENV)[is_a(String)] #- NOTE: NEED TO DEFINE THIS PROXY FIRST
stub(ENV)['NDEBUG'].returns{ '1' } #- `returns` workaround Ruby syntax
p ENV['NDEBUG'] # '1'
p ENV['USER'] # @user
p Muack.verify # true
p ENV['NDEBUG'] # nil
Note that in order to make this work, proxy should be defined first. Because
stubs are searched in Last In First Out (LIFO) order, it would first check
if the key is matching 'NDEBUG'
in this case. If it's not matched, then
search the next one. Eventually it would reach to the first stub, which
we put is_a(String)
there so it must match, and return the original value
inside ENV
.
If the order is reversed, then it would always return the original value, because the proxy would always match, and Muack would stop searching the next stub.
We only talked about mocking a specific object, but never mentioned what if the objects we want to mock aren't at hand at the time we define mocks? In those cases, instead of trying to mock object creation and return the mock we defined, we might want to simply mock any instance of a particular class, since this would make the process much easier.
Here we could use a special "mock" called any_instance_of
, which takes a
class and returns a Muack::AnyInstanceOf
which represents the instance of
the class we just passed. Having this special representation, we could treat
it as if a real instance and define regular mocks/stubs on it. It would then
applies to any instance of the class we gave.
Example speaks:
array = any_instance_of(Array)
stub(array).name{ 'array' }
p [ ].name # 'array'
p [0].name # 'array'
p Muack.verify # true
And as most of the time we don't care about the representation after mocks were defined, we could use the block form:
any_instance_of(Array) do |array|
stub(array).name{ 'array' }
stub(array).id { 1234567 }
end
p [ ].name # 'array'
p [0].id # 1234567
p Muack.verify # true
Note that if you need to access the real instance instead of the representation in the injected method, you might want to enable instance_exec mode. Please jump to instance_exec mode section for more detail.
Here's an quick example:
any_instance_of(Array) do |array|
p array.class # Muack::AnyInstanceOf
mock(array).name.returns(:instance_exec => true){ inspect }
end
p [0, 1].name # '[0, 1]'
p Muack.verify # true
Lastly, you could also use any_instance_of
along with proxy mode,
or any other combination you could think of:
any_instance_of(Array) do |array|
stub(array).name{ 'array' }
mock(array).max
end
p [ ].name # 'array'
p [0].max # 0
p Muack.verify # true
Though you should still not mix mocks and stubs with the same method,
and as you could tell from the above example, Muack would not complain
for every array without calling max
once. This is because any_instance_of
would count on all instances, instead of individual instances. Here
we're actually telling Muack that max
should be called exactly once
amongst all instances of array, and it is indeed called exactly once
amongst two instances here.
This might or might not be what we want. But think it twice, if we're mocking any instance of a very basic class in Ruby, testing against individual instances could be too strict since it's used everywhere!
Please check Caveat section for more details.
A modifier is something specifying a property of an injected method. By making a mock/stub/spy, it would return a modifier descriptor which we could then specify properties about the injected method.
Note that we could chain properties for a given modifier descriptor because all public methods for declaring a property would return the modifier descriptor itself. Let's see the specific usages for each properties with concrete examples.
By using mocks, we are saying that the injected method should be called
exactly once. However the injected method might be called more than once,
say, twice. We could specify this with times
modifier:
obj = Object.new
mock(obj).name{ 'obj' }.times(2)
p obj.name # 'obj'
p obj.name # 'obj'
p Muack.verify # true
This is actually also semantically equivalent to making the mock twice:
obj = Object.new
mock(obj).name{ 'obj' }
mock(obj).name{ 'obj' }
p obj.name # 'obj'
p obj.name # 'obj'
p Muack.verify # true
Note that it does not make sense to specify times
for stubs, because
stubs don't care about times. Spies do, though. So this is also
similar to below:
obj = Object.new
stub(obj).name{ 'obj' }
p obj.name # 'obj'
p obj.name # 'obj'
spy(obj).name.times(2)
p Muack.verify # true
Or without using times for spy:
obj = Object.new
stub(obj).name{ 'obj' }
p obj.name # 'obj'
p obj.name # 'obj'
spy(obj).name
spy(obj).name
p Muack.verify # true
The advantage of specifying mocks twice is that we could actually provide different results for each call. You could think of it as a stack. Here's a simple example:
obj = Object.new
mock(obj).name{ 0 }
mock(obj).name{ 1 }
mock(obj).name{ 2 }
p obj.name # 0
p obj.name # 1
p obj.name # 2
p Muack.verify # true
We could also use the block form for convenience:
obj = Object.new
mock(obj) do |m|
m.name{ 0 }
m.name{ 1 }
m.name{ 2 }
end
p obj.name # 0
p obj.name # 1
p obj.name # 2
p Muack.verify # true
Note that this does not apply to stubs because stubs never run out. Instead, the latter stub would overwrite the previous one.
obj = Object.new
stub(obj) do |m|
m.name{ 0 }
m.name{ 1 }
m.name{ 2 }
end
p obj.name # 2
p obj.name # 2
p obj.name # 2
p Muack.verify # true
Note that if you do not want a given method be called at all, you could
use times(0)
to enforce this.
We haven't talked about verifying arguments. With with_any_args
modifier,
we're saying that we don't care about the arguments. If we're not specifying
any arguments like above examples, we're saying there's no arguments at all.
Here we'll show an example for with_any_args
. If you do want to verify some
specific arguments, jump to Arguments Verifiers section.
obj = Object.new
mock(obj).name{ 'obj' }.with_any_args.times(4)
p obj.name # 'obj'
p obj.name(1) # 'obj'
p obj.name(nil) # 'obj'
p obj.name(true) # 'obj'
p Muack.verify # true
For some methods, we can't really pass a block to specify the implementation.
For example, we can't pass a block to []
, which is a Ruby syntax limitation.
To workaround it, we could use returns
property:
obj = Object.new
mock(obj)[0].returns{ 0 }
p obj[0] # 0
p Muack.verify # true
This is also useful when we want to put the implementation block in the last instead of the beginning. Here's an example:
obj = Object.new
mock(obj).name.times(2).with_any_args.returns{ 'obj' }
p obj.name # 'obj'
p obj.name # 'obj'
p Muack.verify # true
On the other hand, there's also another advantage of using returns
than
passing the block directly to the injected method. With returns
, there's
an additional option we could use by passing arguments to returns
. We
can't do this in regular injected method definition because those arguments
are for verifying the actual arguments. Jump to Arguments Verifiers section
for details.
The only option right now is :instance_exec
.
By default, the block passed to the injected method is lexically/statically scoped. That means, the scope is bound to the current binding. This is the default because usually we don't need dynamic scopes, and we simply want to return a plain value, and this is much easier to understand, and it is the default for most programming languages, and it would definitely reduce surprises. If we really need to operate on the object, we have it, and we could touch the internal by calling instance_eval on the object.
However, things are a bit different if we're using any_instance_of
.
If we're using any_instance_of
, then we don't have the instance at
hand at the time we're defining the block, but only a Muack::AnyInstanceOf
instance to represent the instance. There's no way we could really touch
the object without instance_exec
option.
This would also be extremely helpful if we're using Muack as a monkey patching library. We don't have to copy the original codes in order to monkey patching a class, we could simply inject what we really want to fix the internal stuffs in the broken libraries we're using. Jump to Muack as a mocky patching library section for more detail.
Here's an quick example:
any_instance_of(Array) do |array|
p array.class # Muack::AnyInstanceOf
mock(array).name.returns(:instance_exec => true){ inspect }
end
p [0, 1].name # '[0, 1]'
p Muack.verify # true
Note that this :instance_exec
option also applies to other modifiers which
accepts a block for its implementation, i.e. peek_args
and peek_return
.
What if we don't really want to change an underlying implementation for a
given method, but we just want to slightly change the arguments, or we
might just want to take a look at the arguments? Here's an example using
peek_args
to modify the original arguments.
Note that here we use the proxy mode for the mock, because if we're defining
our own behaviour, then we already have full control of the arguments.
There's no points to use both. This also applies to peek_return
.
str = 'ff'.dup
mock(str).to_i.with_any_args.peek_args{ |radix| radix * 2 }
p str.to_i(8) # 255
p Muack.verify # true
peek_args
also supports :instance_exec
mode. Here's an example:
any_instance_of(Array) do |array|
stub(array).push.with_any_args.
peek_args(:instance_exec => true){ |_| size }
end
a = []
p a.push.dup # [0]
p a.push.dup # [0, 1]
p a.push.dup # [0, 1, 2]
p Muack.verify # true
We could also omit |_|
if we don't care about the original argument
in the above example.
What if we don't really want to change an underlying implementation for a
given method, but we just want to slightly change the return value, or we
might just want to take a look at the return? Here's an example using
peek_return
to modify the original return value.
str = 'ff'.dup
mock(str).to_i.with_any_args.peek_return{ |int| int * 2 }
p str.to_i(16) # 510
p Muack.verify # true
peek_return
also supports :instance_exec
mode. Here's an example:
any_instance_of(Array) do |array|
stub(array).push.with_any_args.
peek_return(:instance_exec => true){ |_| size }
end
a = []
p a.push(0) # 1
p a.push(0) # 2
p a.push(0) # 3
p a # [0, 0, 0]
p Muack.verify # true
We could also omit |_|
if we don't care about the original return value
in the above example.
If we're not passing any arguments to the injected method we define, then
basically we're saying that there's no arguments should be passed to the
method. If we don't care about the arguments, then we should use
with_any_args
modifier. If we want the exact arguments, then we
should just pass the arguments, which would be checked with ==
operator.
Here's an example:
obj = Object.new
mock(obj).say('Hi'){ |arg| arg }
p obj.say('Hi') # 'Hi'
p Muack.verify # true
This also applies to multiple arguments:
obj = Object.new
mock(obj).say('Hello', 'World'){ |*args| args.join(', ') }
p obj.say('Hello', 'World') # 'Hello, World'
p Muack.verify # true
We could also retrieve the block argument:
obj = Object.new
mock(obj).say{ |&block| block.call('Hi') }
obj.say{ |msg| p msg } # 'Hi'
p Muack.verify # true
Moreover, we could also have stubs on the same method for different arguments. We could think of this as a sort of pattern matching, and Muack would try to find the best matched stub for us.
obj = Object.new
stub(obj).find(0){ 0 }
stub(obj).find(1){ 1 }
p obj.find(1) # 1
p obj.find(0) # 0
p Muack.verify # true
If obj.find(2)
is called and Muack cannot find a matched stub, it would
raise a Muack::Unexpected
and list the candidates for us. This also
applies to spies.
However, What if we don't want to be so exact? Then we should use verifiers.
We'll introduce each of them in next section. Note that verifiers
are not recursive though. If you need complex arguments verification,
you'll need to use satisfy
verifier which you could give an arbitrary
block to verify anything.
anything
is a wildcard arguments verifier. It matches anything.
Although this actually verifies nothing, we could still think of
this as an arity verifier. Since one anything is not two anythings.
obj = Object.new
mock(obj).say(anything){ |arg| arg }.times(2)
p obj.say(0) # 0
p obj.say(true) # true
p Muack.verify # true
is_a
would check if the argument is a kind of the given class.
Actually, it's calling kind_of?
underneath.
obj = Object.new
mock(obj).say(is_a(String)){ |arg| arg }
p obj.say('something') # 'something'
p Muack.verify # true
matching
would check the argument with match
method. Usually this is
used with regular expression, but anything which responds to match
should work.
obj = Object.new
mock(obj).say(matching(/\w+/)){ |arg| arg }
p obj.say('Hi') # 'Hi'
p Muack.verify # true
Note that please don't pass the regular expression directly without wrapping it with a match verifier, or how do we distinguish if we really want to make sure the argument is exactly the regular expression?
including
would check if the actual argument includes the given value
via include?
method.
obj = Object.new
mock(obj).say(including(0)){ |arg| arg }
p obj.say([0,1]) # [0,1]
p Muack.verify # true
within
is the reverse version of including
, verifying if the actual
argument is included in the given value.
obj = Object.new
mock(obj).say(within([0, 1])){ |arg| arg }
p obj.say(0) # 0
p Muack.verify # true
responding_to
would check if the actual argument would be responding to
the given message, checked via respond_to?
, also known as duck typing.
obj = Object.new
mock(obj).say(responding_to(:size)){ |arg| arg }
p obj.say([]) # []
p Muack.verify # true
Note that you could give multiple messages to responding_to
.
obj = Object.new
mock(obj).say(responding_to(:size, :reverse)){ |arg| arg }
p obj.say([]) # []
p Muack.verify # true
where
would check if the actual argument matches given specification.
obj = Object.new
mock(obj).say(where(:a => is_a(Integer))){ |arg| arg }
p obj.say(:a => 0) # {:a => 0}
p Muack.verify # true
Note that this could be recursive.
obj = Object.new
mock(obj).say(where(:a => {:b => [is_a(Integer)]})){ |arg| arg[:a] }
p obj.say(:a => {:b => [0]}) # {:b => [0]}
p Muack.verify # true
having
would check if the actual argument is a superset of given
specification.
obj = Object.new
mock(obj).say(having(:a => 0)){ |arg| arg }
p obj.say(:a => 0, :b => 1) # {:a => 0, :b => 1}
p Muack.verify # true
Note that this could be recursive.
obj = Object.new
mock(obj).say(having(:a => {:b => [is_a(Integer)]})){ |arg| arg[:c] }
p obj.say(:a => {:b => [1]}, :c => 2) # 2
p Muack.verify # true
allowing
would check if the actual argument is a subset of given
specification.
obj = Object.new
mock(obj).say(allowing(:a => 0, :b => [1])){ |arg| arg }
p obj.say(:a => 0) # {:a => 0}
p Muack.verify # true
Note that this could be recursive.
obj = Object.new
mock(obj).say(allowing(:a => {:b => is_a(Integer), :c => 1})){ |arg| arg[:a] }
p obj.say(:a => {:b => 2}) # {:b => 2}
p Muack.verify # true
satisfying
accepts a block to let you do arbitrary verification.
nil and false are considered false, otherwise true, just like in
regular if expression.
obj = Object.new
mock(obj).say(satisfying{ |arg| arg % 2 == 0 }){ |arg| arg }
p obj.say(0) # 0
p Muack.verify # true
If what we want is the actual argument be within either 0..1
or 3..4
?
We don't really have to use satisfy
to build custom verifier, we could
compose verifiers with disjunction operator (|).
obj = Object.new
mock(obj).say(within(0..1) | within(3..4)){ |arg| arg }.times(2)
p obj.say(0) # 0
p obj.say(4) # 4
p Muack.verify # true
Or boolean, you might say:
obj = Object.new
mock(obj).say(is_a(TrueClass) | is_a(FalseClass)){ |arg| arg }.times(2)
p obj.say(true) # true
p obj.say(false) # false
p Muack.verify # true
If what we want is the actual argument not only a kind of something, but also responds to something. For example, an Enumerable requires the class implements each method. We could use conjunction for this.
obj = Object.new
mock(obj).say(is_a(Enumerable) & responding_to(:each)){}.times(3)
p obj.say( [] ) # nil
p obj.say( {} ) # nil
p obj.say(0..1) # nil
p Muack.verify # true
We could and probably would also want to mix mocks and stubs, for example, we might be concerned about some methods for a given object, but not the other methods.
obj = Object.new
stub(obj).name{ 'obj' }
mock(obj).id { 12345 }
p obj.name # 'obj'
p obj.name # 'obj'
p obj.id # 12345
p Muack.verify # true
However, it might act unexpectedly if we mock and stub on the same object for the same method. It would somehow act like the latter would always win! So if we define mock later for the same method, previously defined stub would never be called. On the other hand, if we define stub later for the same method, previously defined mock would always complain because it would never be called, either!
This does not mean previously defined mocks or stubs get overwritten, because it would still take effect. It's just that there's no way they could get called. So this is mostly not desired.
The ideal solution to this would be raising an error immediately, or really make it could be overwritten. However I didn't find a good way to handle this without rewriting the internal details. So I'll just leave it as it is, and hope no one would ever try to do this.
We might assume that mocks with any_instance_of would work exactly the same as regular mocks, but this is actually not the case. Regular mocks count on every individual instance, but all instances share the same count for any_instance_of.
With one instance:
any_instance_of(Array){ |array| mock(array).f{true}.times(2) }
a = []
p a.f # true
p a.f # true
p Muack.verify # true
With two instances:
any_instance_of(Array){ |array| mock(array).f{true}.times(2) }
p [].f # true
p [].f # true
p Muack.verify # true
So remember to count on all instances, but not individual ones.
Consider you're using a broken library and you need an immediate fix without waiting for upstream to merge your patch, and release a new version.
You could fix it more elegantly by subclassing the original class, or try to include or extend a module to make the original class work correctly. But sometimes we just cannot do this because of the implementation. They might not be extensible at all. Consider if there's a method contains 1,000 lines... There's no way to change it in the middle of the method other than touching the lines directly, unless we have some line based AOP tools... which is not really practical.
In this case, we could fork it and maintain everything by ourselves, and merge from upstream occasionally. However we might only want to do this as the last resort since this could cost a lot.
Alternatively, we can copy the original code, and put it somewhere, and load it after the original code was loaded, so we have the patched and correct code running. This is also called monkey patching, patching like a monkey. Generally this is a bad idea, but sometimes we can only do this to workaround some broken libraries. For example, some libraries might not be maintained, or the authors refused to fix this due to other reasonable or unreasonable reason.
The most notable drawback of monkey patching is that, we're copying a lot of codes which could be changed upstream, and we might not be aware of that, and update our monkey patch accordingly. This could cause some incompatible issues.
That means, the fewer copied codes, the better. Muack could actually help
in this case. I called this mocky patching. The advantage of using this
technique is that, we have peek_args
and peek_return
which we could
modify the arguments or return values in runtime, without changing any
implementation of a particular method.
Here's a real world example with rails_admin. The problem in rails_admin is that, it assumes every associated records should have already been saved, thus having an id, and there's also a particular show page for it.
However, in our application, we could have associated records not yet saved in the database. rails_admin would try to retrieve routes for those unsaved records, and rails would raise RoutingError because rails_admin is passing no id for a show path.
The idea of this fix is simple. Just don't try to get the show page for records which are not yet saved, i.e. records without an id. However this is actually extremely hard to fix in rails_admin without monkey patching!
I'll skip all those details and my rants. In the end, I fixed this by trying to peek the arguments for a particular method, and if and only if the passed records are not yet saved in the database, we fake the arguments. Otherwise, we just bypass and fallback to the original implementation.
Here's the code:
Muack::API.stub(RailsAdmin::Config::Actions).find.with_any_args.
peek_args do |*args|
custom_key, bindings = args
if bindings && bindings[:object] && bindings[:object].id.nil?
[nil, {}] # There's no show page for unsaved records
else
args # Bypass arguments
end
end
If we don't do mocky patching but monkey patching, we'll end up with copying the entire method for RailsAdmin::Config::Actions.find, which then, we'll be responsible for updating this method if some of the original implementation changed.
Note that in mocky patching, we should always use stub and never call
Muack.verify
or Muack.reset
, or that would defeat the purpose of
mocky patching.
Ever consider a static type system in Ruby? You could actually see a lot of
asserts inserted in the beginning of some methods in some libraries. For
example, there are assert_valid_key_size
, assert_kind_of
, etc, in
dm-core, and assert_valid_keys
, assert_valid_transaction_action
,
and various random asserts in activerecord.
You could find them by searching against raise ArgumentError
because
rails is much less consistent and sometimes it's hard to find a pattern in
rails. But you get the idea, those ArgumentError
would much help us debug
our code from misusing the API, and that's exactly the point of type system,
or more specifically, static type system.
We could also use some static analysis tools to do something like this, for example, there's ruby-lint. However, as you might already know, since Ruby is so dynamic, static analysis tools cannot really do a great job if our code is quite dynamic. Of course we could write it more statically, and treat our static analysis tools better, but that might not be the spirit of Ruby somehow.
Alternatively, it would be great to do this static type checking dynamically... I mean, in the runtime rather than compile time. This means it would be much more accurate, just like those asserts in the above examples.
However, if we're doing those checks in a hot path, for example, right
inside a loop looping over a million times, this would definitely slow
things down if we're checking them in the runtime. Even if we put $DEBUG
guards around those check, we're still suffering from checking the flag.
It would be great if we could actually just remove those checks in
production, while turn it on when we're developing or debugging.
Muack could actually fulfill this desire, as it could inject codes
externally and seamlessly, and we could remove them anytime when we
call Muack.reset
, or, simply don't do any stubs in production config.
Consider we have two classes:
Food = Class.new
User = Class.new(Struct.new(:food))
And we could make sure User#food is always a kind of Food
by putting this
into a development config or so:
Muack::API.module_eval do
any_instance_of(User) do |user|
stub(user).food = is_a(Food)
end
end
And then if we're trying to set a food other than a Food
...
u, f = User.new, Food.new
u.food = f # ok
u.food = 1 # raise Muack::Unexpected
This could go wild and we could customize our own domain specific argument verifiers. For example, we could do this to check if the food is frozen:
Food = Class.new
User = Class.new(Struct.new(:food))
FoodFrozen = Class.new(Muack::Satisfying) do
def match actual_arg
actual_arg.frozen?
end
end
Muack::API.module_eval do
any_instance_of(User) do |user|
stub(user).food = FoodFrozen.new
end
end
u = User.new
p u.food = Food.new.freeze # ok
p u.food = Food.new # raise Muack::Unexpected
Please check Arguments Verifiers (Satisfying) section for more argument verifiers details.
Long story short. I can't find a set of good APIs along with good
implementation. My ideal APIs would be that for mocks and stubs, they
do check if the injected methods exist before, and if we don't want
that check, we use fake
instead of mock
or stub
.
However, how do we specify if fake
should act like mock
or stub
?
Introducing yet another name would make the terms even more confusing
(which are already fairly confusing!), and I don't want something like:
fake.mock
or mock.fake
or fake_mock
or mock_fake
. Using an option
would also raise the other questions.
What if we make mock.with_any_times
work exactly like stub
then?
Then we could have fake.with_any_times
and that would be the stub
version of fake. This should greatly reduce the complexity and confusion.
However this won't work well because stub is not just mock without times.
They are different in:
- Mocked methods are called in FIFO (queue) order
- Stubbed methods are called in FILO (stack) order
- Stubbed methods could do some pattern matching
Of course we could break them though, but do we really have to, just for
this simple feature? Also, it could be pretty challenging to implement
existing method checking for any_instance_of
.
If you could find a good set of APIs while implementing it nicely, please do let me know. Compatibility is not an issue. We could always bump the major number to inform this incompatibility. I am open to breaking legacy. Or, I am happy to break legacy.
- Lin Jen-Shin (@godfat)
Apache License 2.0 (Apache-2.0)
Copyright (c) 2013-2023, Lin Jen-Shin (godfat)
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.