Basic Usage: Scenarios and Fake Objects¶
Scenarios and Fake Objects¶
Testix is a mocking framework designed to support the TDD style of programming. When writing a test with Testix, we use a Scenario() object to specify what should happen or what we expect should happen when the tested code is run. We can refer to these as demands or expectations.
Here’s a test that expects the tested code to repeatedly call .recv(4096) on a socket, until an empty sequence is returned, and then call .close() on the socket. Furthermore, tested.read() should return the accumulated data.
1from testix import *
2
3import reader
4
5
6def test_read_all_from_socket():
7 with Scenario() as s:
8 s.sock.recv(4096) >> b'data1'
9 s.sock.recv(4096) >> b'data2'
10 s.sock.recv(4096) >> b'data3'
11 NO_MORE_DATA = b''
12 s.sock.recv(4096) >> b''
13 s.sock.close()
14
15 tested = reader.Reader(Fake('sock'))
16 assert tested.read() == b'data1data2data3'
Scenarios track fake objects - instances of Fake. Fake objects have a name, e.g. Fake('sock') - and you can demand various method calls on a fake object, e.g.
s.sock.recv(4096) >> b'data1'
Means “we require that the .recv() method be called on the fake object named 'sock' with exactly one argument: the number 4096. When this happens, this function call will return b'data1' to the caller”.
Note this last part - when we define an expectation, we may also define the return value returned should the expectation come true.
The code which passes this test is
1class Reader:
2 def __init__(self, socket):
3 self._sock = socket
4
5 def read(self):
6 accumulated = b''
7 while True:
8 data = self._sock.recv(4096)
9 if data == b'':
10 self._sock.close()
11 return accumulated
12 accumulated += data
The Scenario() object helps us define our expectations from our code, and also enforces these expectations when used, as above, in a with Scenario() as s statement. When the with ends, the Scenario object will make sure that all expectations have been exactly met.
Try to comment out some lines in the Reader class, and you will see that the test no longer passes. E.g. if you comment out the .close() call you will get
> return pytest.fail( message )
...
E Scenario ended, but not all expectations were met. Pending expectations (ordered): [sock.close()]
Testix tells you that not all expectations were met, like it should.
More Complex Expectations¶
You can specify any number of arguments, and also keyword arguments, e.g.
s.alpha.func1(1, 2, a=1, b='hi there')
This will ensure that the .func1() method is called on the Fake("alpha") object exactly like this:
def my_code(a):
a.func1(1, 2, a=1, b='hi there')
With this definition, my_code(Fake("alpha")) will pass the test. The value returned from .func1() in this case will be None. If you want to specify a return value, use >> as before
s.alpha.func1(1, 2, a=1, b='hi there')
Overriding Imported Modules With Fake Objects¶
Since Scenario can only track Fake objects, the tested code must have access to them. We already saw one way this can happen, when we pass in a fake, e.g.
tested = reader.Reader(Fake('sock'))
Another common pattern with Testix is to override some global names inside the tested module - this essentially overrides import statements.
Here is an example of overriding the socket import using Testix’s patch_module pytest fixture. This test demands that the MyServer() object create a socket, listen on it, accept a connection, send b'hi' over this connection, then close it.
1from testix import *
2import pytest
3import my_server
4
5
6@pytest.fixture(autouse=True) # if autouse is not used here, you will have to specify override_imports as an argument to test_my_server() below
7def override_imports(patch_module):
8 patch_module(my_server, 'socket') # replace socket with Fake('socket')
9
10
11def test_my_server():
12 with Scenario() as s:
13 s.socket.socket() >> Fake('server_sock')
14 s.server_sock.listen()
15
16 s.server_sock.accept() >> (Fake('connection'), 'some address info')
17 s.connection.send(b'hi')
18 s.connection.close()
19
20 tested = my_server.MyServer()
21 tested.serve_request()
NOTE: The patch_module helper will, when the test is over, return the original object to its place. It’s important to use patch_module and not do it yourself.
Another important point is that patch_module overrides global names, go, e.g. if we use patch_module like this
patch_module(my_module, 'xxx')
And my_module has this code
xxx = 300
def get_xxx():
return xxx
Then xxx will not be 300 for the duration of the test, but instead have a fake object by the same name Fake("xxx").
This will also be the case if my_module had
from important_constants import xxx
def get_xxx():
return xxx
Using patch_module To Mock Builtin Objects¶
We can also use patch_module to override builtin objects, such as the function open().
patch_module(my_module, 'open')
# sometime later
s.open('some_file.txt', 'w') >> Fake('open_file')
Using patch_module With Arbitrary Values¶
Usually we use patch_module to override module-level names with Fake objects, but you can specify any object as the override
patch_module(my_module, 'xxx', 500) # override xxx value with 500
The Most Common Ways To Create Fake Objects¶
So, Fake objects can be created and passed in directly, they can be used to mock imported modules using patch_module, and they can be returned as the result of another Fake object call, e.g. the line
s.socket.socket() >> Fake('server_sock')
In fact, in this line as well as in the one that follows:
s.server_sock.accept() >> (Fake('connection'), 'some address info')
There is, in fact, another method that creates Fake objects. The preceding line specifies that server_sock.accept is called - which, under the hood, implies the creation of a Fake("server_sock.accept") fake object.
To summarize, the main modes where fakes are created are:
Created and passed in directly
Created and returned as the return value of an expectation
Replacing a global name (usually an imported module) using
patch_moduleImplicitly created when addressing a method of a fake object, e.g.
server_sock.acceptabove.
Less Strict Expectations¶
Less Specific Arguments¶
Sometimes you want to specify an expectation, but with less strict demands.
Continuing with the previous example, maybe we don’t care that much that 4096 be used when calling .recv()
This can be accomplished using IgnoreArgument(), e.g.
1from testix import *
2
3import reader
4
5
6def test_read_all_from_socket():
7 with Scenario() as s:
8 s.sock.recv(IgnoreArgument()) >> b'data1'
9 s.sock.recv(IgnoreArgument()) >> b'data2'
10 s.sock.recv(IgnoreArgument()) >> b'data3'
11 NO_MORE_DATA = b''
12 s.sock.recv(IgnoreArgument()) >> b''
13 s.sock.close()
14
15 tested = reader.Reader(Fake('sock'))
16 assert tested.read() == b'data1data2data3'
You can also use IgnoreArgument() to specify that you demand some kwarg be used, but you don’t care about it’s value
s.alpha.func1(1, 2, a=IgnoreArgument(), b='hi there')
# must use (1, 2, a=<any object here>, b='hi there')
Unordered Expectations¶
Most of the time, in my experience, it’s a good idea that expectations are met in the exact order that they were specified.
s.alpha.func1(1, 2)
s.alpha.func2('this must come after func1')
These expectations will only be met if .func2() is called after .func1() was called.
However, sometimes we want to relax this a little, and demand that some function is called, but you don’t care if it’s before or after some other function. You can do this by using the .unordered() modifier:
1from testix import *
2
3
4def test_unordered_expecation():
5 with Scenario() as s:
6 s.some_object('a')
7 s.some_object('b')
8 s.some_object('c').unordered()
9
10 my_fake = Fake('some_object')
11 my_code(my_fake)
12
13
14def my_code(x):
15 x('a')
16 x('c')
17 x('b')
This demands that the fake is called with 'b' only after it was called with 'a' - but it forgives the call with 'c' - you can call the fake with 'c' before, after on in between the 'a' and 'b' calls.
The code in my_code() passes this test.
Of course you can still use the >> operator to specify return values for your expectation.
Everlasting Expectations¶
Sometimes we don’t want to test for a specific number of calls, but we do want to make sure that a function call is of a particular form.
We can modify an .unordered() expectation with .everlasting() and
this essentially modifies the expectation to mean “this call is expected zero or more times, in any order”.
Here’s a small example
1from testix import *
2
3
4def test_unordered_expecation():
5 with Scenario() as s:
6 s.some_object('a')
7 s.some_object('b')
8 s.some_object('c').unordered().everlasting()
9
10 my_fake = Fake('some_object')
11 my_code(my_fake)
12
13
14def my_code(x):
15 x('c')
16 x('c')
17 x('c')
18
19 x('a')
20 x('c')
21 x('b')
22
23 x('c')
24 x('c')
Of course you can still use the >> operator to specify return values for your expectation.
Context Manager Expectations¶
Sometimes we want to demand that an object is used as a context manager in a with statement.
Here’s an example of how to demand this on a fake object named 'locker', using Scenarios special __with__ modifier, along with the code that passes the test.
1from testix import *
2
3
4def test_fake_context():
5 locker_mock = Fake('locker')
6 with Scenario() as s:
7 s.__with__.locker.Lock() >> Fake('locked')
8 s.locked.read() >> 'value'
9 s.locked.updater.go('another_value')
10 my_code(locker_mock)
11
12
13def my_code(locker):
14 with locker.Lock() as locked:
15 whatever = locked.read()
16 locked.updater.go(f'another_{whatever}')