Managing multiple mocks

Introduction

So far we’ve discussed situations where single mock object is suitable and fits well. But those are very rare situations, as usually you will need more than just one mock. Let’s now take a look at following Python code:

import hashlib
import base64


class AlreadyRegistered(Exception):
    pass


class RegisterUserAction:

    def __init__(self, database, crypto, mailer):
        self._database = database
        self._crypto = crypto
        self._mailer = mailer

    def invoke(self, email, password):
        session = self._database.session()
        if session.users.exists(email):
            raise AlreadyRegistered("E-mail {!r} is already registered".format(email))
        password = self._crypto.hash_password(password)
        session.users.add(email, password)
        self._mailer.send_confirm_registration_to(email)
        session.commit()

That classes implements business logic of user registration process:

  • User begins registration by entering his/her e-mail and password,
  • System verifies whether given e-mail is already registered,
  • System adds new user to users database and marks as “confirmation in progress”,
  • System sends confirmation email to the User with confirmation link.

That use case has dependencies to database, e-mail sending service and service that provides some sophisticated way of generating random numbers suitable for cryptographic use. Now let’s write one test for that class:

from mockify.core import satisfied
from mockify.mock import Mock
from mockify.actions import Return

def test_register_user_action():
    session = Mock('session')  # (1)
    database = Mock('database')
    crypto = Mock('crypto')
    mailer = Mock('mailer')

    database.session.\
        expect_call().will_once(Return(session))
    session.users.exists.\
        expect_call('foo@bar.com').will_once(Return(False))
    crypto.hash_password.\
        expect_call('p@55w0rd').will_once(Return('***'))
    session.users.add.\
        expect_call('foo@bar.com', '***')
    mailer.send_confirm_registration_to.\
        expect_call('foo@bar.com')
    session.commit.\
        expect_call()

    action = RegisterUserAction(database, crypto, mailer)

    with satisfied(database, session, crypto, mailer):  # (2)
        action.invoke('foo@bar.com', 'p@55w0rd')

We had to create 3 mocks + one additional at (1) for mocking database session. And since we have 4 mock objects, we also need to remember to verify them all at (2). And remembering things may lead to bugs in the test code. But Mockify is supplied with tools that will help you deal with that.

Using mock factories

First solution is to use mockify.mock.MockFactory class. With that class you will be able to create mocks without need to use mockify.mock.Mock directly. Morever, mock factories will not allow you to duplicate mock names and will automatically track all created mocks for you. Besides, all mocks created by one factory will share same session object and that is important for some of Mockify’s features.

Here’s our previous test rewritten to use mock factory instead of several mock objects:

from mockify.core import satisfied
from mockify.mock import MockFactory
from mockify.actions import Return

def test_register_user_action():
    factory = MockFactory()  # (1)
    session = factory.mock('session')
    database = factory.mock('database')
    crypto = factory.mock('crypto')
    mailer = factory.mock('mailer')

    database.session.\
        expect_call().will_once(Return(session))
    session.users.exists.\
        expect_call('foo@bar.com').will_once(Return(False))
    crypto.hash_password.\
        expect_call('p@55w0rd').will_once(Return('***'))
    session.users.add.\
        expect_call('foo@bar.com', '***')
    mailer.send_confirm_registration_to.\
        expect_call('foo@bar.com')
    session.commit.\
        expect_call()

    action = RegisterUserAction(database, crypto, mailer)

    with satisfied(factory):  # (2)
        action.invoke('foo@bar.com', 'p@55w0rd')

Although the code did not change a lot in comparison to previous version, we’ve introduced a major improvement. At (1) we’ve created a mock factory instance, which is used to create all needed mocks. Also notice, that right now we only check factory object at (2), so we don’t have to remember all the mocks we’ve created. That saves a lot of problems later, when test is modified; each new mock will most likely be created using factory object and it will automatically check that new mock.

Using mock factories with test suites

Mock factories work the best with test suites containing setup and teardown customizable steps executed before and after every single test. Here’s once again our test, but this time in form of test suite (written as an example, without use of any specific framework):

from mockify.core import assert_satisfied
from mockify.mock import MockFactory
from mockify.actions import Return

class TestRegisterUserAction:

    def setup(self):
        self.factory = MockFactory()  # (1)

        self.session = self.factory.mock('session')   # (2)
        self.database = self.factory.mock('database')
        self.crypto = self.factory.mock('crypto')
        self.mailer = self.factory.mock('mailer')

        self.database.session.\
            expect_call().will_repeatedly(Return(self.session))  # (3)

        self.uut = RegisterUserAction(self.database, self.crypto, self.mailer)  # (4)

    def teardown(self):
        assert_satisfied(self.factory)  # (5)

    def test_register_user_action(self):
        self.session.users.exists.\
            expect_call('foo@bar.com').will_once(Return(False))
        self.crypto.hash_password.\
            expect_call('p@55w0rd').will_once(Return('***'))
        self.session.users.add.\
            expect_call('foo@bar.com', '***')
        self.mailer.send_confirm_registration_to.\
            expect_call('foo@bar.com')
        self.session.commit.\
            expect_call()

        self.uut.invoke('foo@bar.com', 'p@55w0rd')

Notice, that we’ve moved factory to setup() method (1), and created all mocks inside it (2) along with unit under test instance (4). Also notice that obtaining database session (3) was also moved to setup step and made optional with will_repeatedly(). Finally, our factory (and every single mock created by it) is verified at (5), during teardown phase of test execution. Thanks to that we have only use case specific expectations in test method, and a common setup code, so it is now much easier to add more tests to that class.

Note

If you are using pytest, you can take advantage of fixtures and use those instead of setup/teardown methods:

import pytest

from mockify.core import satisfied
from mockify.mock import MockFactory


@pytest.fixture
def mock_factory():
    factory = MockFactory()
    with satisfied(factory):
        yield factory


def test_something(mock_factory):
    mock = mock_factory.mock('mock')
    # ...

Using sessions

A core part of Mockify library is a session. Sessions are instances of mockify.core.Session class and their role is to provide mechanism for storing recorded expectations, and matching them with calls being made. Normally sessions are created automatically by each mock or mock factory, but you can also give it explicitly via session argument:

from mockify.core import Session
from mockify.mock import Mock, MockFactory

session = Session()  # (1)

first = Mock('first', session=session)  # (2)
second = MockFactory(session=session)  # (3)

In example above, we’ve explicity created session object (1) and gave it to mock first (2) and mock factory second (3), which now share the session. This means that all expectations registered for mock first or any of mocks created by factory second will be passed to a common session object. Some of Mockify features, like ordered expectations (see Recording ordered expectations) will require that to work. Although you don’t have to create one common session for all your mocks, creating it explicitly may be needed if you want to:

  • override some of Mockify’s default behaviors (see mockify.Session.config for more info),
  • write a common part for your tests.

For the sake of this example let’s stick to the last point. And now, let’s write a base class for our test suite defined before:

from mockify.core import Session

class TestCase:

    def setup(self):
        self.mock_session = Session()  # (1)

    def teardown(self):
        self.mock_session.assert_satisfied()  # (2)

As you can see, nothing really interesting is happening here. We are creating session (1) in setup section and checking it it is satisfied (2) in teardown section. And here comes our test from previous example:

class TestRegisterUserAction(TestCase):

    def setup(self):
        super().setup()

        self.factory = MockFactory(session=self.mock_session)  # (1)

        self.session = self.factory.mock('session')
        self.database = self.factory.mock('database')
        self.crypto = self.factory.mock('crypto')
        self.mailer = self.factory.mock('mailer')

        self.database.session.\
            expect_call().will_repeatedly(Return(self.session))

        self.uut = RegisterUserAction(self.database, self.crypto, self.mailer)

    def test_register_user_action(self):
        self.session.users.exists.\
            expect_call('foo@bar.com').will_once(Return(False))
        self.crypto.hash_password.\
            expect_call('p@55w0rd').will_once(Return('***'))
        self.session.users.add.\
            expect_call('foo@bar.com', '***')
        self.mailer.send_confirm_registration_to.\
            expect_call('foo@bar.com')
        self.session.commit.\
            expect_call()

        self.uut.invoke('foo@bar.com', 'p@55w0rd')

As you can see, teardown() method was completely removed because it was no longer needed - all mocks are checked by one single call to mockify.core.Session.assert_satisfied() method in base class. The part that changed is a setup() function that triggers base class setup method, and a mock factory (1) that is given a session. With this approach you only implement mock checking once - in a base class for your tests. The only thing you have to remember is to give a session instance to either factory, or each of your mocks for that to work.