Skip to content

Comparing with matchers#

Sometimes, when you're stubbing or verifying calls (or really when you're doing any sort of equality assertion in a test), you need to loosen a given assertion. 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.

Decoy includes the decoy.matchers module, which is a set of Python classes with __eq__ methods defined that you can use in rehearsals and/or assertions in place of actual values

Available matchers#

Matcher Description
decoy.matchers.Anything Matches any value that isn't None
decoy.matchers.DictMatching Matches a dict based on some of its values
decoy.matchers.ErrorMatching Matches an Exception based on its type and message
decoy.matchers.HasAttributes Matches an object based on its attributes
decoy.matchers.IsA Matches using isinstance
decoy.matchers.IsNot Matches anything that isn't a given value
decoy.matchers.StringMatching Matches a string against a regular expression
decoy.matchers.Captor Captures the comparison value (see below)

Basic usage#

To use, import decoy.matchers and use a matcher wherever you would normally use a value.

import pytest
from typing import cast, Optional
from decoy import Decoy, matchers

from .logger import Logger
from .my_thing import MyThing

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

    subject = MyThing(logger=logger)

    # call code under test
    subject.log_warning("Oh no, something went wrong with request abc123efg456")

    # verify double called correctly
    decoy.verify(
        logger.warn(matchers.StringMatching("request abc123efg456"))
    )

Capturing values#

When testing certain APIs, especially callback APIs, it can be helpful to capture the values of arguments passed to a given dependency. For this, Decoy provides decoy.matchers.Captor.

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.

import pytest
from typing import cast, Optional
from decoy import Decoy, matchers

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)
    captor = matchers.Captor()

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

    # verify listener attached and capture the listener
    decoy.verify(event_source.register(event_listener=captor))

    # trigger the listener
    event_handler = captor.value  # or, equivalently, captor.values[0]

    assert subject.has_heard_event is False
    event_handler()
    assert subject.has_heard_event is True

This is a pretty verbose way of writing a test, so in general, you may want to approach using matchers.Captor 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.

Writing custom matchers#

You can write your own matcher class and use it wherever you would use a built-in matcher. All you need to do is define a class with an __eq__ method:

class Is42:
    def __eq__(self, other: object) -> bool:
        return other == 42

check_answer = decoy.mock(name="check_answer")

decoy.when(
    check_answer(Is42())
).then_return("huzzah!")

assert check_answer(42) == "huzzah!"
assert check_answer(43) is None

This is especially useful if 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 MatchesDataFrame class to work around this:

import pandas as pd

class MatchesDataFrame:
    def __init__(self, data) -> None:
        self._data_frame = pd.DataFrame(data)

    def __eq__(self, other: object) -> bool:
        return self._data_frame.equals(other)

check_data = decoy.mock(name="check_data")

decoy.when(
    check_answer(MatchesDataFrame({"x1": range(1, 42)}))
).then_return("huzzah!")

assert check_data(pd.DataFrame({"x1": range(1, 42)})) == "huzzah!"
assert check_data(pd.DataFrame({"x1": range(1, 43)})) is None