Advanced Argument Expectations

Most of our examples of working with Scenario expectations are of exact matches, e.g.

s.classroom.set('A', index=9, name='Alpha')

Means we expect the .set() method to be called on the fake object Fake('classroom') with these exact arguments: a positional argument with the value 'A', and two keyword arguments index=9, name='Alpha'.

In most cases this is exactly what we want. However, sometimes we want something else. Let’s see what Testix supports.

Ignoring A Specific Argument

Sometimes we don’t care about the value of a specific argument. For example, we might want to verify that our code calls time.sleep() but we don’t want to test for a specific number of seconds.

Here’s how to do this in Testix:

with Scenario() as s:
    s.time.sleep(IgnoreArgument())
    # must call time.sleep() with one argument, but any value for this argument will be OK

You can also use this in a kwarg expectation

with Scenario() as s:
    s.greeter.greet('hello!', person=IgnoreArgument())
    # must use 'hello!', but any value for person will be OK

Ignoring All Call Details

What if we want to make sure a method is called, but we don’t care about the arguments at all? This is what IgnoreCallDetails() is for, e.g. this test expects the .connect() method to be called on the fake object Fake('database') three times, but specifies that it doesn’t care about the exact arguments:

 1from testix import *
 2
 3import server
 4
 5def test_person_connects_somehow():
 6    with Scenario() as s:
 7        s.database.connect(IgnoreCallDetails())
 8        s.database.connect(IgnoreCallDetails())
 9        s.database.connect(IgnoreCallDetails())
10
11        tested = server.Server(Fake('database'))
12        tested.connect1()
13        tested.connect2()
14        tested.connect3()

Here is an example of code that passes this test

 1class Server:
 2    def __init__(self, database):
 3        self._database = database
 4
 5    def connect1(self):
 6        self._database.connect()
 7
 8    def connect2(self):
 9        self._database.connect(1, 2, 3, x='y')
10
11    def connect3(self):
12        self._database.connect(a='1', b='2', c='3')

As you can see, the .connect() method is called three times, but with different arguments each time, and this satisfies the IgnoreCallDetails() expectation.

Testing for Object Identity

Sometimes we want to ensure a method is called with a specific object. We are not satisfied with it being called with an equal object, we want the same actual object. That is, we are interested in testing actual is expected and not actual == expected.

We can do this with ArgumentIs. Here is an example:

 1import pytest
 2from testix import *
 3
 4import classroom
 5
 6def test_this_will_pass():
 7    joe = classroom.Person('Joe')
 8    with Scenario() as s:
 9        s.mylist.append(ArgumentIs(joe))
10
11        tested = classroom.Classroom(Fake('mylist'))
12        tested.enter_original(joe)
13
14def test_this_will_fail():
15    joe = classroom.Person('Joe')
16    with Scenario() as s:
17        s.mylist.append(ArgumentIs(joe))
18
19        tested = classroom.Classroom(Fake('mylist'))
20        tested.enter_copy(joe)

We list here two tests, both demand that the object passed to mylist.append() will be the actual object joe created at the start of the test. The test test_this_will_fail() is made to fail on purpose by using the wrong method on the tested object, this is the code that passed (and fails) these tests:

 1class Person:
 2    def __init__(self, name):
 3        self.name = name
 4
 5    def __eq__(self, other):
 6        return self.name == other.name
 7
 8class Classroom:
 9    def __init__(self, people: list):
10        self._people = people
11
12    def enter_original(self, student):
13        self._people.append(student)
14
15    def enter_copy(self, student):
16        self._people.append(Person(student.name))

If we didn’t use ArgumentIs and just used

s.mylist.append(joe)

The test_this_will_fail() test would have passed, because joe is equal to the object passed to .append() as defined by the __eq__ method. With ArgumentIs, however, you will get something like this failure message:

E       testix: ExpectationException
E       testix details:
E       === Scenario (no title) ===
E        expected: mylist.append(|IS <classroom.Person object at 0x7f5162c2c5e0>|)
E        actual  : mylist.append(<classroom.Person object at 0x7f5162c2c250>)

Capturing Arguments

Sometimes we don’t want to demand anything about a method’s arguments, but we do want to capture them. This is useful for when we want to simulate the triggering of an internal callback.

For example, suppose we have a class which implements some logic when the process ends via an atexit <https://docs.python.org/3/library/atexit.html>_ handler. Testing this might seem hard, since we don’t want to actually make the process (which is running our test) exit.

Here’s how to do it using Testix’s SaveArgument feature.

 1from testix import *
 2import pytest
 3import robot
 4
 5@pytest.fixture
 6def mock_imports(patch_module):
 7    patch_module(robot, 'atexit') # mock atexit module
 8
 9def test_atexit_handler(mock_imports):
10    with Scenario() as s:
11        s.atexit.register(saveargument.SaveArgument('the_handler'))
12
13        tested = robot.Robot(cleanup_func=Fake('cleanup_logic'))
14
15        handler = saveargument.saved()['the_handler']
16        s.cleanup_logic(1, 2, 3)
17
18        handler()

We use saveargument.SaveArgument() to capture the argument passed to atexit.register(), and name this captured argument the_handler.

We later retrieve the captured callback via the saveargument.saved() dictionary.

This enables us to trigger the callback ourselves by calling handler() - which satisfies our demand s.cleanup_logic(1, 2, 3).

NOTE a simpler way might have been to make the _cleanup() function public, and then we could just call it: tested.cleanup(). However, if this is not called in our code, we should not make it public, and since we are forbidden by good ethics from accessing a private function from outside the class, we need to capture it.

Implementing Arbitrary Argument Matching

Sometimes you need some complicated logic that Testix doesn’t support out of the box.

You can define your own argument expectation classes with some arbitrary logic, and use them in your tests, by implementing classes derived from the ArgumentExpectation base class, which is essentially an interface:

class ArgumentExpectation:
    def __init__(self, value):
        self.expectedValue = value

    def ok(self, value):
        # returns true if value meets the expectation, false otherwise
        raise Exception("must override this")

The following tests implements a new StartsWith expectation, which expects a string that starts with a given prefix.

 1import pytest
 2from testix import *
 3
 4import temporary_storage
 5
 6@pytest.fixture(autouse=True)
 7def mock_builtin(patch_module):
 8    patch_module(temporary_storage, 'open')
 9
10class StartsWith(ArgumentExpectation):
11    def ok(self, value):
12        return value.startswith(self.expectedValue)
13
14def test_person_connects_somehow():
15    with Scenario() as s:
16        s.open(StartsWith('/tmp/'), 'w') >> Fake('the_file')
17        s.the_file.write('file_name: some_file\n\n')
18
19        tested = temporary_storage.TemporaryStorage()
20        the_file = tested.create_file('some_file')
21        assert the_file is Fake('the_file')

We demand that open() be called with a filename that starts with /tmp/, and with exactly 'w' as the second argument.

This code passes this test:

1class TemporaryStorage:
2    def create_file(self, filename):
3        f = open('/tmp/' + filename, 'w')
4        f.write('file_name: ' + filename + '\n\n')
5        return f

The ArgumentExpectation base class implements a default __repr__ function, but you can implement one yourself e.g.

class StartsWith(ArgumentExpectation):
    def ok(self, value):
        return value.startswith(self.expectedValue)

    def __repr__(self):
        return 'StartsWith({})'.format(repr(self.expectedValue))

Using this ArgumentExpectation interface, you can make Testix support any arbitrary and complicated argument verification you need.