Mocking an Object¶
The DSL for defining and using typemock takes much of its inspiration from the mocking libraries of statically typed languages, kotlin’s mockk library in particular.
To mock a class or object, we use the tmock function. This returns a mock instance of the provided class type. This is actually a context manager, and you need to open the context to specify any behaviour.
You can pass a class or an instance of the class to the tmock function to be mocked. So…
with tmock(MyThing) as my_mock:
# define mock behaviour
and:
with tmock(MyThing()) as my_mock:
# define mock behaviour
are both acceptable.
There will be cases where, if an instance of the class has complex __init__ functionality, then mocking a class will not be able to discover instance level attributes. In this case, you can attempt to mock an already initialised instance to resolve this. See more in the Mocking Attributes section.
Note
- You must specify the behaviour of any method that your test is going to interact with. Interacting with a method with no specified behaviour results in an error.
- Typemock does not do static patching of the class being mocked. Any mocked behaviour will only be available from the mock instance itself, not via a class accessed call.
- Instance level attributes might not be available if the __init__ method has some more complex logic. Use an already instantiated object in this case.
Now lets look at how to specify the behaviour for a mocked class or object.
Mocking Methods¶
First, let us define a class that we wish to mock.
class MyThing:
def return_a_str(self) -> str:
pass
def convert_int_to_str(self, number: int) -> str:
pass
def concat(self, prefix: str, number: int) -> str:
pass
def do_something_with_side_effects(self) -> None:
pass
Simple response¶
expected_result = "a string"
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.return_a_str()).then_return(expected_result)
actual = my_thing_mock.return_a_str()
assert expected_result == actual
We also let the context of the mock close before we interacted with it, and it returned the response we had defined.
Different responses for different args¶
We can also specify different responses for different sets of method arguments as follows.
result_1 = "first result"
result_2 = "second result"
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.convert_int_to_str(1)).then_return(result_1)
when(my_thing_mock.convert_int_to_str(2)).then_return(result_2)
assert result_1 == my_thing_mock.convert_int_to_str(1)
assert result_2 == my_thing_mock.convert_int_to_str(2)
Series of responses¶
We can specify a series of responses for successive calls to a method with the same matching args.
responses = [
"first result"
"second result"
]
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.convert_int_to_str(1)).then_return_many(responses)
for response in responses:
assert response == my_thing_mock.convert_int_to_str(1)
By default, if we interact with the method more than the specified series, we will get an error. But you can set this to looping with the loop parameter for then_return_many responder.
Programmatic response¶
You can provide dynamic responses through a function handler.
The function should have the same signature as the method it is mocking so that mixes of positional and keyword arguments are handled in a deterministic way.
def bounce_back_handler(number: int) -> str:
return "{}".format(number)
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.convert_int_to_str(1)).then_do(bounce_back_handler)
assert "1" == my_thing_mock.convert_int_to_str(1)
Error responses¶
We can also make our mock raise an Exception.
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.return_a_str()).then_raise(IOError)
my_thing_mock.return_a_str() # <- Error raised here.
Arg Matching¶
Sometimes we want to be more general in the arguments needed to trigger a response. There is currently only the match.anything() matcher.
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.convert_int_to_str(match.anything())).then_return("hello")
assert "hello" == my_thing_mock.convert_int_to_str(1)
assert "hello" == my_thing_mock.convert_int_to_str(2)
Despite using this very broad matcher, any interactions with the mock will throw errors if they receive incorrectly typed args in their interactions.
Mocking async methods¶
We can also mock async methods. It just requires the addition an await key word when defining the behaviour. Here is an example:
# Given some object with async methods.
class MyAsyncThing:
async def get_an_async_result(self) -> str:
pass
# We can setup and verify in an async test case.
async def my_test(self):
expected = "Hello"
with tmock(MyAsyncThing) as my_async_mock:
when(await my_async_mock.get_an_async_result()).then_return(expected)
assert expected == await my_async_mock.get_an_async_result())
verify(my_async_mock).get_an_async_result()
Note
The the verify call does not need the await key word.
Mocking Attributes¶
Attributes are a little trickier than methods, given the layered namespaces of an instance of a class and the class itself.
With methods we can find the public members and their signatures regardless of if we are looking at an instance or a class. The state of a given instance/class implementation ie. its attributes can be defined in several ways, and so their type hints can be defined or deduced in several ways.
For now, typemock does its best to determine the type hints of attributes, and where it cannot, it is treated as untyped. Let’s look at an example class to see what type hints are discoverable.
class MyThing:
class_att = "foo" # <- not typed
class_att_with_type: int = 1 # <- typed, easy
class_att_with_typed_init = "bar" # <- type determined from __init__ annotation.
class_att_with_untyped_init = "wam" # <- not typed
def __init__(
self,
class_att_with_typed_init: str, # <- provides type for class level attribute
class_att_with_untyped_init, # <- no type for class level attribute
instance_att_typed_init: int, # <- provides type for instance attribute
instance_att_untyped_init, # <- not typed
):
self.class_att_with_typed_init = class_att_with_typed_init
self.class_att_with_untyped_init = class_att_with_untyped_init # <- not typed
self.instance_att_typed_init = instance_att_typed_init # <- type from init
self.instance_att_untyped_init = instance_att_untyped_init # <- not typed
self.instance_att_no_init: str = "hello" # <- has a type hint, but not discoverable = not typed
It might take some time to digest that, but essentially, effective attribute type hinting takes place either at a class level, or in the __init__ method signature.
If you pass in a class to the tmock function, typemock will try to instantiate an instance of the class so that it can discover instance level attributes. If some more complicated logic occurs in the __init__ method though, typemock may not be able to do this, and will log a warning. In this case, if you want to mock an instance level attribute you will need to provide an already instantiated instance to the tmock function.
To some up the basic guidelines for mocking attributes:
- Define your type hints at a class level or in the __init__ method signature.
- If the __init__ method of the class has some more complex logic, you may need to provide an instantiated instance to tmock
Depending on how this works in practice this may change, or some config may be introduced to assume attribute types from initial values.
With that quirkiness explained to some extent, let us look at how to actually mock an attribute. We will use this simpler class for the examples:
class MyThing:
name: str = "anonymous"
Note
Currently, it is also not necessary to always specify behaviour of an attribute. It will by default return the value it was initialised with.
Simple Get¶
Just as with a method call, we can specify the response of a get.
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.name).then_return("foo")
assert my_thing_mock.name == "foo"
Get Many¶
expected_results = [
"foo",
"bar"
]
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.name).then_return_many(expected_results)
for expected in expected_results:
assert my_thing_mock.name == expected
You can also provide the loop=True arg to make this behaviour loop through the list.
Get Raise¶
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.name).then_raise(IOError)
my_thing_mock.name # <- Error raised here.
Get programmatic response¶
As with methods, you can provide dynamic responses through a function handler.
It might be useful if you do want to wire up some stateful mocking/faking or have some other dependency.
def name_get_handler():
return "my name"
with tmock(MyThing) as my_thing_mock:
when(my_thing_mock.name).then_do(name_get_handler)
assert "my name" == my_thing_mock.name