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
 5def test_read_all_from_socket():
 6    with Scenario() as s:
 7        s.sock.recv(4096) >> b'data1'
 8        s.sock.recv(4096) >> b'data2'
 9        s.sock.recv(4096) >> b'data3'
10        NO_MORE_DATA = b''
11        s.sock.recv(4096) >> b''
12        s.sock.close()
13
14        tested = reader.Reader(Fake('sock'))
15        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@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
 6def override_imports(patch_module):
 7    patch_module(my_server, 'socket') # replace socket with Fake('socket')
 8
 9def test_my_server():
10    with Scenario() as s:
11        s.socket.socket() >> Fake('server_sock')
12        s.server_sock.listen()
13
14        s.server_sock.accept() >> (Fake('connection'), 'some address info')
15        s.connection.send(b'hi')
16        s.connection.close()
17
18        tested = my_server.MyServer()
19        tested.serve_request()
20

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:

  1. Created and passed in directly

  2. Created and returned as the return value of an expectation

  3. Replacing a global name (usually an imported module) using patch_module

  4. Implicitly created when addressing a method of a fake object, e.g. server_sock.accept above.

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
 5def test_read_all_from_socket():
 6    with Scenario() as s:
 7        s.sock.recv(IgnoreArgument()) >> b'data1'
 8        s.sock.recv(IgnoreArgument()) >> b'data2'
 9        s.sock.recv(IgnoreArgument()) >> b'data3'
10        NO_MORE_DATA = b''
11        s.sock.recv(IgnoreArgument()) >> b''
12        s.sock.close()
13
14        tested = reader.Reader(Fake('sock'))
15        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
 3def test_unordered_expecation():
 4    with Scenario() as s:
 5        s.some_object('a')
 6        s.some_object('b')
 7        s.some_object('c').unordered()
 8
 9        my_fake = Fake('some_object')
10        my_code(my_fake)
11
12def my_code(x):
13    x('a')
14    x('c')
15    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
 3def test_unordered_expecation():
 4    with Scenario() as s:
 5        s.some_object('a')
 6        s.some_object('b')
 7        s.some_object('c').unordered().everlasting()
 8
 9        my_fake = Fake('some_object')
10        my_code(my_fake)
11
12def my_code(x):
13    x('c')
14    x('c')
15    x('c')
16
17    x('a')
18    x('c')
19    x('b')
20
21    x('c')
22    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
 3def test_fake_context():
 4    locker_mock = Fake('locker')
 5    with Scenario() as s:
 6        s.__with__.locker.Lock() >> Fake('locked')
 7        s.locked.read() >> 'value'
 8        s.locked.updater.go('another_value')
 9        my_code(locker_mock)
10
11
12def my_code(locker):
13    with locker.Lock() as locked:
14        whatever = locked.read()
15        locked.updater.go(f'another_{whatever}')