pychoir - Python Test Matchers for humans

Super duper low cognitive overhead matching for Python developers reading or writing tests. Implemented in pure Python, without any dependencies. Runs and passes its tests on 3.6, 3.7, 3.8 and 3.9. PyPy (3.6, 3.7) works fine too.

pychoir has mostly been developed for use with pytest, but nothing prevents from using it in any other test framework (like vanilla unittest) or even outside of testing, if you feel like it.

Installation

  • With pip: pip install pychoir
  • With pipenv: pipenv install --dev pychoir
  • With poetry: poetry add --dev pychoir

Documentation

Check out the API Reference on readthedocs for detailed info on all the available Matchers https://pychoir.readthedocs.io/en/stable/api.html

Why?

You have probably written quite a few tests where you assert something like

assert thing_under_test() == {'some_fields': 'some values'}

However, sometimes you do not expect exact equivalence. So you start

result = thing_under_test()

result_number = result.pop('number', None)
assert result_number is None or result_number < 3

result_list_of_strings = result.pop('list_of_strings', None)
assert (
    result_list_of_strings is not None
    and len(result_list_of_strings) == 5
    and all(isinstance(s, str) for s in result_list_of_strings)
)

assert result == {'some_fields': 'some values'}

...but this is not very convenient for anyone in the long run.

This is where pychoir comes in with matchers:

from pychoir import LessThan, All, HasLength, IsNoneOr, And, IsInstance

assert thing_under_test() == {
    'number': IsNoneOr(LessThan(3)),
    'list_of_strings': And(HasLength(5), All(IsInstance(str))),
    'some_fields': 'some values',
}

You can also check many things about the same value: for example And(IsInstance(int), 5) will make sure that the value is not only equal to 5, but is also an int (goodbye to accidental 5.0).

You can place a matcher almost anywhere where a value can be. pychoir matchers work well inside lists, tuples, dicts, dataclasses, ... You can also place normal values inside matchers, and they will match as with traditional == or !=.

A core principle is that pychoir Matchers are composable and can be used freely in various combinations. For example [Or(LessThan(3), 5)] is "equal to" a list with one item, holding a value equal to 5 or any value less than 3.

Can I write custom Matchers of my own

Yes, you can! pychoir Matcher baseclass has been designed to be usable by code outside the library. It also takes care of most of the generic plumbing, so your custom matcher typically needs very little code.

Here is the implementation of IsInstance as an example:

from typing import Any, Type
from pychoir import Matcher

class IsInstance(Matcher):
    def __init__(self, type_: Type[Any]):
        super().__init__()
        self.type = type_

    def _matches(self, other: Any) -> bool:
        return isinstance(other, self.type)

    def _description(self) -> str:
        return self.type.__name__

All you need to take care of is defining the parameters (if any) in __init__(), the match itself in _matches(), and a description of the parameters in _description().

Here is an even simpler Anything matcher that does not take parameters and matches literally anything:

from typing import Any
from pychoir import Matcher

class Anything(Matcher):
    def _matches(self, _: Any) -> bool:
        return True

    def _description(self) -> str:
        return ''

If your custom matcher is generic enough to be useful for everyone, please contribute (fork and make a pull request for now) and have it included in pychoir!

GitHub

https://github.com/kajaste/pychoir