Using matchers

Introduction

So far we’ve been recording expectations with fixed argument values. But Mockify provides to you a very powerful mechanism of matchers, available via mockify.matchers module. Thanks to the matchers you can record expectations that will match more than just a single value. Let’s take a brief tour of what you can do with matchers!

Recording expectations with matchers

Let’s take a look at following code that we want to test:

import uuid


class ProductAlreadyExists(Exception):
    pass


class AddProductAction:

    def __init__(self, database):
        self._database = database

    def invoke(self, category_id, name, data):
        if self._database.products.exists(category_id, name):
            raise ProductAlreadyExists()
        product_id = str(uuid.uuid4())  # (1)
        self._database.products.add(product_id, category_id, name, data)  # (2)

That code represents a business logic of adding some kind of product into database. The product is identified by a name and category, and there cannot be more than one product of given name inside given category. But tricky part is at (1), where we calculate UUID for our new product. That value is random, and we are passing it into products.add() method, which will be mocked. How to mock that, when we don’t know what will the value be? And here comes the matchers:

from mockify.core import satisfied
from mockify.mock import Mock
from mockify.actions import Return
from mockify.matchers import _  # (1)


def test_add_product_action():
    database = Mock('database')

    database.products.exists.\
        expect_call('dummy-category', 'dummy-name').\
        will_once(Return(False))
    database.products.add.\
        expect_call(_, 'dummy-category', 'dummy-name', {'description': 'A dummy product'})  # (2)

    action = AddProductAction(database)
    with satisfied(database):
        action.invoke('dummy-category', 'dummy-name', {'description': 'A dummy product'})

We’ve used a wildcard matcher imported in (1), and placed it as first argument of our expectation (2). That underscore object is in fact instance of mockify.matchers.Any and is basically equal to every possible Python object, therefore it will match any possible UUID value, so our test will pass.

Of course you can also use another matcher if you need a more strict check. For example, we can use mockify.matchers.Regex to check if this is a real UUID value:

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

any_uuid = Regex(r'^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$')


def test_add_product_action():
    database = Mock('database')

    database.products.exists.\
        expect_call('dummy-category', 'dummy-name').\
        will_once(Return(False))
    database.products.add.\
        expect_call(any_uuid, 'dummy-category', 'dummy-name', {'description': 'A dummy product'})

    action = AddProductAction(database)
    with satisfied(database):
        action.invoke('dummy-category', 'dummy-name', {'description': 'A dummy product'})

Combining matchers

You can also combine matchers using | and & binary operators.

For example, if you want to expect values that can only be integer numbers or lower case ASCII strings, you can combine mockify.matchers.Type and mockify.matchers.Regex matchers like in this example:

from mockify.mock import Mock
from mockify.actions import Return
from mockify.matchers import Type, Regex

mock = Mock('mock')
mock.\
    expect_call(Type(int) | Regex(r'^[a-z]+$', 'LOWER_ASCII')).\
    will_repeatedly(Return(True))

And now let’s try it:

>>> mock(1)
True
>>> mock('abc')
True
>>> mock(3.14)
Traceback (most recent call last):
    ...
mockify.exc.UnexpectedCall: No matching expectations found for call:

at <doctest default[2]>:1
-------------------------
Called:
  mock(3.14)
Expected (any of):
  mock(Type(int) | Regex(LOWER_ASCII))

In the last line we’ve called our mock with float number which is neither integer, nor lower ASCII string. And since it did not matched our expectation, mockify.exc.UnexpectedCall was raised - the same that would be raised if we had used fixed values in expectation.

And now let’s try with one more example.

This time we are expecting only positive integer numbers. To expect that we can combine previously introduced Type matcher with mockify.matchers.Func matcher. The latter is very powerful, as it accepts any custom function. Here’s our expectation:

from mockify.mock import Mock
from mockify.actions import Return
from mockify.matchers import Type, Func

mock = Mock('mock')
mock.\
    expect_call(Type(int) & Func(lambda x: x > 0, 'POSITIVE_ONLY')).\
    will_repeatedly(Return(True))

And now let’s do some checks:

>>> mock(1)
True
>>> mock(10)
True
>>> mock(3.14)
Traceback (most recent call last):
    ...
mockify.exc.UnexpectedCall: No matching expectations found for call:

at <doctest default[2]>:1
-------------------------
Called:
  mock(3.14)
Expected (any of):
  mock(Type(int) & Func(POSITIVE_ONLY))

Using matchers in structured data

You are not only limited to use matchers in expect_call() arguments and keyword arguments. You can also use it inside larger structures, like dicts. That is a side effect of the fact that matchers are implemented by customizing standard Python’s __eq__() operator, which is called every time you compare one object with another. Here’s an example:

from mockify.mock import Mock
from mockify.actions import Return
from mockify.matchers import Type, List

mock = Mock('mock')
mock.expect_call({
    'action': Type(str),
    'params': List(Type(int), min_length=2),
}).will_repeatedly(Return(True))

We’ve recorded expectation that mock() will be called with dict containing action key that is a string, and params key that is a list of integers containing at least 2 elements. Here’s how it works:

>>> mock({'action': 'sum', 'params': [2, 3]})
True
>>> mock({'action': 'sum', 'params': [2, 3, 4]})
True
>>> mock({'action': 'sum', 'params': [2]})
Traceback (most recent call last):
    ...
mockify.exc.UnexpectedCall: No matching expectations found for call:

at <doctest default[2]>:1
-------------------------
Called:
  mock({'action': 'sum', 'params': [2]})
Expected (any of):
  mock({'action': Type(str), 'params': List(Type(int), min_length=2)})

In the last example we got mockify.exc.UnexpectedCall exception because our params key got only one argument, while it was expected at least 2 to be given. There is no limit of how deep you can go with your structures.

Using matchers in custom objects

You can also use matchers with your objects. Like in this example:

from collections import namedtuple

from mockify.mock import Mock
from mockify.matchers import Type

Vec2 = namedtuple('Vec2', 'x, y')  # (1)

Float = Type(float)  # (2)

canvas = Mock('canvas')
canvas.draw_line.expect_call(
    Vec2(Float, Float), Vec2(Float, Float)).\
    will_repeatedly(Return(True))  # (3)

We’ve created a vector object (1), then an alias to Type(float) (2) for a more readable expectation composing (an _ alias for mockify.matchers.Any is created in same way). Finally, we’ve created canvas mock and mocked draw_line() method, taking start and end point arguments in form of 2-dimensional vectors. And here’s how it works:

>>> canvas.draw_line(Vec2(0.0, 0.0), Vec2(5.0, 5.0))
True
>>> canvas.draw_line(Vec2(0, 0), Vec2(5, 5))
Traceback (most recent call last):
    ...
mockify.exc.UnexpectedCall: No matching expectations found for call:

at <doctest default[1]>:1
-------------------------
Called:
  canvas.draw_line(Vec2(x=0, y=0), Vec2(x=5, y=5))
Expected (any of):
  canvas.draw_line(Vec2(x=Type(float), y=Type(float)), Vec2(x=Type(float), y=Type(float)))

Using matchers out of Mockify library

Matchers are pretty generic tool that you can also use outside of Mockify - just for assertion checking. For example, if you have a code that creates some records with auto increment ID you can use a matcher from Mockify to check if that ID matches some expected criteria - especially when exact value is hard to guess:

Here’s an example code:

import itertools

_next_id = itertools.count(1)  # This is private

def make_product(name, description):
    return {
        'id': next(_next_id),
        'name': name,
        'description': description
    }

And here’s an example test:

from mockify.matchers import Type, Func

def test_make_product():
    product = make_product('foo', 'foo desc')
    assert product == {
        'id': Type(int) & Func(lambda x: x > 0, 'GREATER_THAN_ZERO'),
        'name' : 'foo',
        'description': 'foo desc',
    }