Creating mocks and recording expectations¶
Introduction¶
Since version 0.6 Mockify provides single mockify.mock.Mock
class
for mocking things. With that class you will be able to mock:
- functions,
- objects with methods,
- modules with functions,
- setters and getters.
That new class can create attributes when you first access them and then you can record expectations on that attributes. Furthermore, that attributes are callable. When you call one, it consumes previously recorded expectations.
To create a mock, you need to import mockify.mock.Mock
class and
instantiate it with a name of choice:
from mockify.mock import Mock
foo = Mock('foo')
That name should reflect what is being mocked and this should be function, object or module name. You can only use names that are valid Python identifiers or valid Python module names, with submodules separated with a period sign.
Now let’s take a brief introduction to what can be done with just created foo object.
Mocking functions¶
Previously created foo mock can be used to mock a function or any other callable. Consider this example code:
def async_sum(a, b, callback):
result = a + b
callback(result)
We have “asynchronous” function that calculates sum of a and b and triggers given callback with a sum of those two. Now, let’s call that function with foo mock object as a callback. This will happen:
>>> async_sum(2, 3, foo)
Traceback (most recent call last):
...
mockify.exc.UninterestedCall: No expectations recorded for mock:
at <doctest default[0]>:3
-------------------------
Called:
foo(5)
Now you should notice two things:
- Mock object foo is callable and was called with 5 (2 + 3 = 5),
- Exception
mockify.exc.UninterestedCall
was raised, caused by lack of expectations on mock foo.
Raising that exception is a default behavior of Mockify. You can change this
default behavior (see mockify.Session.config
for more details), but
it can be very useful, because it will make your tests fail early and you
will see what expectation needs to be recorded to move forward. In our case
we need to record foo(5) call expectation.
To do this you will need to call expect_call() method on foo object:
foo.expect_call(5)
Calling expect_call() records call expectation on rightmost mock attribute, which is foo in this case. Given arguments must match the arguments the mock will later be called with.
And if you call async_sum again, it will now pass:
from mockify.core import satisfied
with satisfied(foo):
async_sum(2, 3, foo)
Note that we’ve additionally used mockify.core.satisfied()
. It’s a context
manager for wrapping portions of test code that satisfies one or more
given mocks. And mock is satisfied if all expectations recorded for it are
satisfied, meaning that they were called exactly expected number of
times. Alternatively, you could also use mockify.core.assert_satisfied()
function:
from mockify.core import assert_satisfied
foo.expect_call(3)
async_sum(1, 2, foo)
assert_satisfied(foo)
That actually work in the same way as context manager version, but can be used out of any context, for example in some kind of teardown function.
Mocking objects with methods¶
Now let’s take a look at following code:
class APIGateway:
def __init__(self, connection):
self._connection = connection
def list_users(self):
return self._connection.get('/api/users')
This class implements a facade on some lower level connection object. Let’s now create instance of APIGateway class. Oh, it cannot be created without a connection argument… That’s not a problem - let’s use a mock for that:
connection = Mock('connection')
gateway = APIGateway(connection)
If you now call APIGateway.list_users() method, you will see similar error to the one we had earlier:
>>> gateway.list_users()
Traceback (most recent call last):
...
mockify.exc.UninterestedCall: No expectations recorded for mock:
at <doctest default[0]>:7
-------------------------
Called:
connection.get('/api/users')
And again, we need to record matching expectation to move test forward. To record method call expectation you basically need to do the same as for functions, but with additional attribute - a method object:
connection.get.expect_call('/api/users')
with satisfied(connection):
gateway.list_users()
And now it works fine.
Mocking functions behind a namespace or module¶
This kind of mocking is extended version of previous one.
Now consider this example:
class APIGateway:
def __init__(self, connection):
self._connection = connection
def list_users(self):
return self._connection.http.get('/api/users')
We have basically the same example, but this time our connection interface was divided between various protocols. You can assume that connection object handles entire communication with external world by providing a facade to lower level libs. And http part is one of them.
To mock that kind of stuff you basically only need to add another attribute to connection mock, and call expect_call() on that attribute. Here’s a complete example:
connection = Mock('connection')
gateway = APIGateway(connection)
connection.http.get.expect_call('/api/users')
with satisfied(connection):
gateway.list_users()
Creating ad-hoc data objects¶
Class mockify.mock.Mock
can also be used to create ad-hoc data
objects to be used as a response for example. To create one, you just need to
instantiate it, and assign values to automatically created properties. Like
in this example:
mock = Mock('mock')
mock.foo = 1
mock.bar = 2
mock.baz.spam.more_spam = 'more spam' # (1)
The most cool feature about data objects created this way is (1) - you can assign values to any nested attributes. And now let’s get those values:
>>> mock.foo
1
>>> mock.bar
2
>>> mock.baz.spam.more_spam
'more spam'
Mocking getters¶
Let’s take a look at following function:
def unpack(obj, *names):
for name in names:
yield getattr(obj, name)
That function yields attributes extracted from given obj in order specified by names. Of course it is a trivial example, but we’ll use a mock in place of obj and will record expectations on property getting. And here’s the solution:
from mockify.actions import Return
obj = Mock('obj')
obj.__getattr__.expect_call('a').will_once(Return(1)) # (1)
obj.__getattr__.expect_call('b').will_once(Return(2)) # (2)
with satisfied(obj):
assert list(unpack(obj, 'a', 'b')) == [1, 2] # (3)
As you can see, recording expectation of getting property on (1) and (2) is that you record call expectation on a magic method __getattr__. And similar to data objects, you can record getting attribute expectation at any nesting level - just prefix expect_call() with __getattr__ attribute and you’re done.
Mocking setters¶
Just like getters, setters can also be mocked with Mockify. The difference is that you will have to use __setattr__.expect_call() this time and obligatory give two arguments:
- attribute name,
- and value you expect it to be set with.
Here’s a complete solution with a pack function - a reverse of the one used in previous example:
def pack(obj, **kwargs):
for name, value in kwargs.items():
setattr(obj, name, value)
obj = Mock('obj')
obj.__setattr__.expect_call('a', 1)
obj.__setattr__.expect_call('b', 2)
with satisfied(obj):
pack(obj, a=1, b=2)
And that also work on nested attributes.