Skip to content

Argument matchers#

Sometimes, you may not care exactly how a mock is called. For example, you may want to assert that a dependency is called with a string, but you don't care about the full contents of that string.

In Decoy, you can use a Matcher in place of an actual argument value in when and verify to "loosen" the match.

Available matchers#

Matcher Description
Matcher Match based on a comparison function.
Matcher.any Match any value, optionally by type and/or attributes.
Matcher.contains Match a list, dict, or str based its contents.
Matcher.error Match an Exception based on its type and message.
Matcher.is_not Match anything that isn't a given value.
Matcher.matches Match a string against a regular expression.

Basic usage#

Use the matcher instance wherever you would normally use a value. If you use static type checking, use Matcher.arg, which type-casts the matcher as the expected type.

from decoy.next import Decoy, Matcher

from .logger import Logger
from .my_thing import MyThing

def test_log_warning(decoy: Decoy):
    logger = decoy.mock(cls=Logger)
    subject = MyThing(logger=logger)

    subject.log_warning("Oh no, something went wrong with request abc123")

    decoy
        .verify(logger.warn)
        .called_with(Matcher.contains("abc123").arg)

A Matcher can also be used standalone, in an assert.

assert "hello world" == Matcher.contains("hello")

Matcher.any#

Match any value, including None.

any_matcher = Matcher.any()  # type: Matcher[Any]

assert "hello world" == any_matcher
assert 42 == any_matcher
assert None == any_matcher

You can scope down the matcher with an isinstance type. This will narrow arg and value to the passed type.

str_matcher = Matcher.any(str)  # type: Matcher[str]

assert "hello world" == any_matcher
assert 42 != any_matcher
assert None != any_matcher

You can also scope the matcher to "anything with the given attributes."

attrs_matcher = Matcher.any(attrs={"hello": "world"})  # type: Matcher[Any]

Matcher.contains#

Match mappings and sequences (including strings) that contain some values.

assert {"hello": "world", "hola": "mundo"} == Matcher.contains({"hello": "world"})
assert ["hello", "world", "hola", "mundo"] == Matcher.contains(["hello", "world"])
assert "hello, world - hola, mundo" == Matcher.contains("hello, world")

When checking non-string sequences, you can specify that the values should appear in order.

in_order_matcher = Matcher.contains(["hello", "world"], in_order=True)

assert ["hello", "world"] == in_order_matcher
assert ["world", "hello"] != in_order_matcher

Matcher.error#

Matcher.is_not#

Negate a match. For example, you may want to check that a value is simply anything except None.

something_matcher = Matcher.is_not(None)  # type: Matcher[Any]

assert "hello world" == something_matcher
assert 42 == something_matcher
assert None != something_matcher

Matcher.matches#

Match a string against a regex pattern.

hello_matcher = Matcher.matches("^hello")  # type: Matcher[str]

assert "hello world" == hello_matcher
assert "goodnight moon" != hello_matcher

Capturing values#

Sometimes, you need access to the actual values of arguments passed to a dependency. For this purpose, all matchers will capture any values that they are successfully compared with, available via decoy.next.Matcher.value and decoy.next.Matcher.values.

For example, our test subject may register an event listener handler, and we want to test our subject's behavior when the event listener is triggered.

from decoy.next import Decoy, Matcher

from .event_source import EventSource
from .event_consumer import EventConsumer


def test_event_listener(decoy: Decoy):
    event_source = decoy.mock(cls=EventSource)
    subject = EventConsumer(event_source=event_source)
    event_listener_matcher = Matcher(callable)

    # subject registers its listener when started
    subject.start_consuming()

    # verify listener attached and capture the listener
    decoy.verify(event_source.add_listener).called_with(event_listener_matcher.arg)

    # trigger the listener
    assert subject.has_heard_event is False
    event_listener_matcher.value()
    assert subject.has_heard_event is True

These "two stage" tests can become pretty verbose, so in general, approach using matcher-captured values as a form of potential code smell/test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions. For further reading on when (or rather, when not) to use argument captors, check out testdouble's documentation on its argument captor matcher.

Custom matchers#

Use the base Matcher class to create custom matchers. Pass Matcher a comparison function, and it will match any value that passes that function.

def is_odd_int(target: object) -> bool:
    return isinstance(target, int) and target % 2 == 1

is_odd_matcher = Matcher(is_odd_int)  # type: Matcher[Any]

assert 1 == is_odd_matcher
assert 2 != is_odd_matcher

If you define your comparison function with TypeIs, the Matcher will be narrowed to the appropriate type.

def is_odd_int(target: object) -> TypeIs[int]:
    return isinstance(target, int) and target % 2 == 1

is_odd_matcher = Matcher(is_odd_int)  # type: Matcher[int]

assert_type(Matcher(is_odd_int), Matcher[int])
assert_type(is_odd_matcher.arg, int)
assert_type(is_odd_matcher.value, int)

This is especially useful with built-in inspection functions, like callable, which also return TypeIs guards.

func_matcher = Matcher(callable)  # type: Matcher[Callable[..., object]]

Custom matcher example#

Custom matchers can be helpful when the value objects you are using as arguments are difficult to compare and out of your control. For example, Pandas DataFrame objects do not return a bool from __eq__, which makes it difficult to compare calls. We can define a data_frame_matcher to work around this.

import functools
import TypeIs from typing
import pandas as pd
from decoy import Matcher

def matchDataFrame(expected_data: object, target: object) -> TypeIs[pd.DataFrame]:
    return pd.DataFrame(expected_data).equals(target)

check_data = decoy.mock(name="check_data")
data_frame_matcher = Matcher(match=partial(matchDataFrame, {"x1": range(1, 42)}))

check_data(pd.DataFrame({"x1": range(1, 42)}))

decoy
    .verify(check_answer)
    .called_with(data_frame_matcher.arg)