It’s nice when the value on the right of assert is a literal - doesn’t work when we have a mess on the left though. In this very common case the test starts picking it apart: len(data["users"]), then data["users"][0]["name"], then a regex or a sort. Each line is fine, together they make you rebuild the shape of the data in your head.

How can it not?

Shaping the expectation

Let’s start with a tiny instance of this:

1
2
assert data["status"] == "created"
assert re.search(r"^Order #\d+", data["message"])

Two lines, and already no shape to see. A more glancable way would be:

1
2
assert data == {"status": "created",
                "message": Regex(r"^Order #\d+")}

Can this be made to work? Dicts already do their part in Python - they compare their values via ==, so we only need a special Regex class overloading equality:

1
2
3
4
class Regex:
    ...
    def __eq__(self, other):
        return re.search(self.pattern, other) is not None

I first saw this trick in a post by Ihor Kalnytskyi, where it was used just like that, for a single string. But it’s too good to stop at regexes.

Tackling the mess

Here is a typical test for some API listing users minus real long and messy values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def test_list_users():
    data = get_users()
    assert data["total"] == 2
    assert data["next"] is None
    assert len(data["users"]) == 2
    alice, bob = data["users"]
    assert isinstance(alice["id"], int)
    assert alice["name"] == "Alice"
    assert alice["email"].endswith("@example.com")
    assert sorted(alice["roles"]) == ["admin", "dev"]
    assert "created" in alice
    assert bob["name"] == "Bob"
    assert bob["roles"] == ["dev"]

What fields do we check? Do we check same fields for Alice and Bob? Did we forget something? What does response look like? You can tell, but you need to dig in for that.

Let’s rewrite it the new way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def test_list_users(M):
    assert get_users() == {
        "users": [
            {
                "id": M.isa(int),
                "name": "Alice",
                "email": M.re(r"@example\.com$"),
                "roles": M.unordered("admin", "dev"),
                "created": M.any,
            },
            M.dict(name="Bob", roles=["dev"]),
        ],
        "total": 2,
        "next": None,
    }

This is the shape of the response. Two users, a total, no next page. Alice is checked fully, Bob only by name and roles. One glance. I don’t even need to explain to you the M.whatnot() helpers, which I got from assert-matcher, these are quite guessable from their names and use.

As a bonus we check that no extra keys allowed for data nor Alice, the messy sample would miss it.

Not quite literal

The literal above is loose, and on purpose. An exact one would break for the silliest reasons.

created is a timestamp, different on every run, but it should be there, hence M.any. id comes from the database, so we only ask for an int. Roles come in unpredictable order, M.unordered() handles that.

And Bob? He is there to test filtering, we checked the full fields set on Alice already. So M.dict() with two keys, do not need to repeat all of them. The difference in Alice’s and Bob’s checks is also way more glancable now.

And when we do care, we can be as specific as we like. If the test created Alice itself, it knows her id, so we just write it. created can’t be exact, but it should be about now:

1
2
3
4
5
6
7
8
def test_list_users(M):
    alice = ...
    assert get_users() == {
        "users": [{
            "id": alice.id,
            "created": M.approx(datetime.now()),
            ...
        }, ...], ...}

Breaking Bob

Let’s be honest: nobody looks at tests until they fail. So let’s break ours, give Bob one more role, ["dev", "ops"]. With the matchers above, this is what we’ll see:

1
2
3
4
5
6
E   AssertionError: assert {'users': [{'... 'next': None} == {'users': [{'... 'next': None}
E
E     Omitting 2 identical items, use -vv to show
E     Differing items:
E     {'users': [{'id': 17, 'name': 'Alice', 'email': 'alice@example.com', 'roles': ['dev', 'admin'], ...}, {'id': 23, 'name': 'Bob', 'email': 'bob@example.com', 'roles': ['dev', 'ops'], ...}]} != {'users': [{'id': M.isa(int), 'name': 'Alice', 'email': r'@example\.com$', 'roles': M.unordered('admin', 'dev'), ...}, M.dict(name='Bob', roles=['dev'])]}
E     Use -v to get more diff

Pytest found the offending key, users, and stopped there. The rest is two long lines you are supposed to compare by eye. Okay, -vv then:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
E     Full diff:
E       {
E           'users': [
E               {
E     -             'id': M.isa(int),
E     ?                   ^^^^^^^^^^
E     +             'id': 17,
E     ?                   ^^
E                   'name': 'Alice',
E     -             'email': r'@example\.com$',
E     ?                      -         -    -
E     +             'email': 'alice@example.com',
E     ?                       +++++
E     -             'roles': M.unordered('admin', 'dev'),
E     -             'created': M.any,
E     +             'roles': [
E     +                 'dev',
E     +                 'admin',
E     +             ],
E     +             'created': '2026-10-30T12:01:44',
E               },
E     ... Bob goes below ...

Readable, but not at a glance. The actual error is even further down, among Bob’s lines.

Since it’s a string diff, it shows plenty false positives: M.isa(int) against 17 is a change and so on. Other limitations that dict’s keys are not sorted, pytest doesn’t dig more than one level and pretty printing may linewrap confusing the diff further.

Better call plugin

Forturnately it might be fixed with a plugin using the pytest_assertrepr_compare hook. assert-matcher installs one, which turns the above into:

1
2
E   AssertionError: assert {'users': ...xt': None} == {'users': ...xt': None}
E     users[1].roles: Left contains one more item: 'ops'

Saves your eyes and your tokens by pinpointing the exact bad path into the nested structure. And it works with plain data with no matchers included too, i.e. if we wrote the whole response exactly and got Bob’s email wrong:

1
2
3
4
5
6
7
8
E   AssertionError: assert {'users': ...xt': None} == {'users': ...xt': None}
E     users[1].email:
E       'bob@example.com' == 'bob@example.org'
E
E       - bob@example.org
E       ?              ^^
E       + bob@example.com
E       ?             + ^

So just plugin and no matchers might also be a valid choice.

Does anyone care anymore?

People don’t write tests these days though, few read them. If you still review what the agent wrote the glanceability might be helpful.

Talking agents only makes it a topic for another post or even a research, I took a bite at unittest vs pytest vs pytest + matchers here though. Spoiler: matchers kill slightly more mutants, pytest vs unittest is undecisive (need more cases?), costs are the same.

So throw this to your agent if you’re sold.

P.S. The trick is not new, even stdlib has ANY, a number of other libs went somewhat this way. None of those are as nice and none of them have a proper plugin though.