Intermediate
You write an API endpoint that returns a new user object. The response contains a freshly generated UUID, a server-assigned timestamp, and a nested dictionary of default settings. You want to assert the response is correct — but the UUID changes on every call, the timestamp is always slightly different, and the settings dict might have extra keys you don’t care about. Your options are: mock out time and UUID (complex), cherry-pick individual fields (incomplete), or write one brittle assertion that hard-codes values that will never match on the next run (wrong). Every Python developer has been here.
The dirty-equals library solves this cleanly. Instead of asserting exact values, you assert shapes — “a valid UUID”, “any positive integer”, “a dict containing at least these keys”, “a float within 5% of this value”. Each matcher uses Python’s == operator under the hood, so it works with pytest plain assert, unittest assertEqual, or any test framework that compares values. Install once, use everywhere.
This article covers the full dirty-equals toolkit: type matchers, numeric approximations, string and URL patterns, collection matchers, partial dict matching, datetime assertions, and how to build your own custom matcher. Every example is runnable, and by the end you will build a real integration test for a fake user API that demonstrates all the patterns working together.
dirty-equals: Quick Example
Install the library first, then run this example to see the core idea in 15 lines:
pip install dirty-equals
# quick_dirty_equals.py
from dirty_equals import IsUUID, IsPositiveInt, IsStr, AnyThing
# Simulate an API response that changes every call
import uuid, time
response = {
"id": str(uuid.uuid4()),
"created_at": time.time(),
"name": "Alice",
"score": 42,
}
# Assert the shape, not the exact values
assert response == {
"id": IsUUID,
"created_at": AnyThing,
"name": IsStr,
"score": IsPositiveInt,
}
print("All assertions passed!")
Output:
All assertions passed!
The key insight is that dirty-equals matchers implement __eq__ to return True when the comparison makes logical sense. IsUUID returns True when compared to any valid UUID string or object. IsPositiveInt returns True for any integer greater than zero. When you compare the whole response dict against a dict of matchers, Python compares key by key — each matcher handles its own field.
The sections below cover every matcher category, including ones for strings, URLs, dates, nested structures, and custom logic.
What Is dirty-equals and Why Use It?
Standard Python test assertions compare exact values. When a value is non-deterministic — a generated ID, a timestamp, a float with floating-point noise, or a response body with extra fields — you have to work around the comparison rather than writing a clean assertion. This produces test code full of intermediate variable extractions, multiple assert calls for one logical check, or complex mock setups just to control a timestamp.
dirty-equals flips this: instead of adapting your test to the value, you write a matcher that describes what the value should look like, and let equality do the work. A matcher is a value that compares as True when the right-hand value satisfies the condition. Since Python calls left.__eq__(right) or right.__eq__(left) during comparison, and dirty-equals overrides __eq__, any framework that uses == picks up the behavior automatically.
| Approach | When to Use | Drawback |
|---|---|---|
Exact equality (assert x == val) | Deterministic, stable values | Fails on UUIDs, timestamps, generated data |
| Field-by-field extraction | Complex nested responses | Verbose, misses structural errors |
| Heavy mocking | Controlling side effects | Couples test to implementation |
| dirty-equals matchers | Shape assertions on any value | Requires learning ~20 matcher classes |
The library is developed by the same team behind pydantic and is used extensively in the pydantic test suite itself — which is a strong signal that it handles real-world complexity well.
Type and Identity Matchers
The simplest matchers check the type or identity of a value. These are useful when you care that a field is the right kind of thing but don’t care about its specific value.
# type_matchers.py
from dirty_equals import IsStr, IsInt, IsFloat, IsBool, IsBytes, IsNone, AnyThing, IsInstance
# IsStr -- any string
assert "hello" == IsStr
assert "2026-07-19" == IsStr
# IsInt -- any integer
assert 42 == IsInt
assert -5 == IsInt
# IsFloat -- any float
assert 3.14 == IsFloat
# IsBool -- True or False
assert True == IsBool
# IsBytes -- any bytes object
assert b"data" == IsBytes
# IsNone -- only None
assert None == IsNone
# AnyThing -- matches literally anything
assert 42 == AnyThing
assert None == AnyThing
assert {"nested": [1, 2]} == AnyThing
# IsInstance -- matches any instance of a given class or tuple of classes
assert 3.14 == IsInstance(float)
assert "hello" == IsInstance((str, bytes))
print("All type matchers passed!")
Output:
All type matchers passed!
IsStr, IsInt, and their siblings are the building blocks you reach for first. AnyThing is useful for fields you genuinely don’t care about — like a server-side checksum or a telemetry token — where asserting the type would add noise without adding value. IsInstance works like isinstance(value, cls) and accepts a tuple of types the same way.
Numeric Matchers
Numeric matchers go beyond simple type checks — they let you express constraints like “positive”, “in a range”, or “approximately equal to” that are common in real tests but awkward to write with plain arithmetic comparisons.
# numeric_matchers.py
from dirty_equals import (
IsPositiveInt, IsNegativeInt, IsPositiveFloat, IsNegativeFloat,
IsNumber, IsApprox, IsNumeric
)
# Positive / negative integers
assert 7 == IsPositiveInt
assert -3 == IsNegativeInt
# Positive / negative floats
assert 0.001 == IsPositiveFloat
assert -99.9 == IsNegativeFloat
# IsNumber -- any int or float (not a string)
assert 3.14 == IsNumber
assert 0 == IsNumber
# IsApprox -- within a relative tolerance (default 1e-7, like pytest.approx)
measured = 9.8000001
assert measured == IsApprox(9.8)
# Custom tolerance: within 5%
price = 104.50
assert price == IsApprox(100.0, rel=0.05) # 5% relative tolerance
# IsNumeric -- accepts numeric strings too ("123", "3.14")
assert "42" == IsNumeric
assert 42 == IsNumeric
print("All numeric matchers passed!")
Output:
All numeric matchers passed!
IsApprox uses the same relative tolerance semantics as pytest.approx. The default tolerance of 1e-7 handles floating-point noise in most computations. Use rel for a relative tolerance (percentage-based) or abs for absolute tolerance when you know the error bound in the same units as the value. IsNumeric is particularly useful when asserting API responses where numbers sometimes arrive as strings depending on the serialization library.
String Matchers
String matchers let you assert that a value is a string matching a pattern, prefix, suffix, or regular expression — without building the regex inline in your assertion.
# string_matchers.py
from dirty_equals import (
IsStr, IsUUID, IsUrl, IsEmail, IsJson,
RegexStr, HasLength
)
# IsUUID -- any valid UUID (v1 through v5, with or without hyphens)
import uuid
assert str(uuid.uuid4()) == IsUUID
assert "550e8400-e29b-41d4-a716-446655440000" == IsUUID
# IsUrl -- any URL with a valid scheme and netloc
assert "https://pythonhowtoprogram.com/articles/" == IsUrl
# IsEmail -- a valid email address
assert "user@example.com" == IsEmail
# IsJson -- a valid JSON string (the string must be parseable)
assert '{"key": "value", "count": 42}' == IsJson
assert '[1, 2, 3]' == IsJson
# RegexStr -- matches a regular expression pattern
from dirty_equals import RegexStr
assert "order-20260719-0042" == RegexStr(r"order-\d{8}-\d{4}")
assert "INFO: server started" == RegexStr(r"^(INFO|WARN|ERROR): .+")
# HasLength -- any string (or list) with an exact or bounded length
assert "hello" == HasLength(5)
assert "hello" == HasLength(ge=3) # at least 3 characters
assert "hello" == HasLength(ge=3, le=10) # between 3 and 10
print("All string matchers passed!")
Output:
All string matchers passed!
IsUUID is the single most commonly reached-for matcher in API tests — almost every modern REST API returns auto-generated UUIDs for resource IDs. RegexStr accepts any Python regex pattern; use it when you need to assert a specific format like an order number, a log line prefix, or a date string. HasLength supports the keyword arguments ge (greater than or equal), le (less than or equal), gt, and lt for flexible length bounds.
Collection Matchers
Collection matchers handle lists, tuples, sets, and dicts where you care about membership or structure but not exact order or exact content.
# collection_matchers.py
from dirty_equals import (
IsList, IsTuple, IsDict, IsSet,
Contains, IsSubset, IsSuperDict,
HasLength
)
# IsList / IsTuple / IsSet -- type-only checks
assert [1, 2, 3] == IsList
assert (1, 2, 3) == IsTuple
assert {1, 2, 3} == IsSet
# Contains -- the collection contains all of these items (order doesn't matter)
assert [3, 1, 4, 1, 5, 9, 2] == Contains(1, 9, 2)
assert "hello world" == Contains("world")
# IsSubset -- this value is a subset of the given set
assert {1, 2} == IsSubset({1, 2, 3, 4})
# IsSuperDict -- this dict contains at least the given key-value pairs
# (extra keys are allowed)
response = {"id": 1, "name": "Alice", "role": "admin", "created_at": 1720000000}
assert response == IsSuperDict({"name": "Alice", "role": "admin"})
# HasLength works on lists too
assert [10, 20, 30] == HasLength(3)
assert list(range(100)) == HasLength(ge=50, le=150)
print("All collection matchers passed!")
Output:
All collection matchers passed!
IsSuperDict is the pattern you reach for when testing API responses that return more fields than you want to assert on. Rather than extracting just the fields you care about, write the whole expected sub-dict directly in your assertion. Contains works on any iterable including strings, making it useful for asserting that a log message mentions a specific token without caring about the surrounding text.
Partial Dict Matching with IsPartialDict
One of the most powerful patterns in dirty-equals is combining IsPartialDict (or IsSuperDict) with nested matchers. This lets you assert the structure of deeply nested API responses without specifying every single field.
# partial_dict.py
from dirty_equals import IsPartialDict, IsUUID, IsPositiveInt, IsStr, AnyThing, IsUrl
# IsPartialDict -- the value must contain exactly these keys,
# but values use dirty-equals matchers and missing keys are ignored
user_response = {
"id": "3f2a8c10-4e1b-4d3a-b2f5-1a2b3c4d5e6f",
"name": "Alice Chen",
"email": "alice@example.com",
"score": 987,
"avatar_url": "https://cdn.example.com/avatars/alice.png",
"session_token": "eyJhbGciOiJIUzI1NiJ9.abc123", # we don't care about this
"last_login": 1720000123.45,
}
# Assert only the fields we care about, using matchers for non-deterministic ones
assert user_response == IsSuperDict({
"id": IsUUID,
"name": IsStr,
"score": IsPositiveInt,
"avatar_url": IsUrl,
})
# Combining nested matchers for a full structured response
api_response = {
"ok": True,
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Bob",
"tags": ["python", "backend", "testing"],
},
"request_id": "req_abc123xyz",
"took_ms": 14,
}
assert api_response == {
"ok": True,
"user": IsSuperDict({"id": IsUUID, "name": IsStr}),
"request_id": IsStr,
"took_ms": IsPositiveInt,
}
print("Partial dict assertions passed!")
Output:
Partial dict assertions passed!
The nested matcher pattern — using IsSuperDict inside a regular dict assertion — works because dirty-equals matchers implement standard Python equality. When Python compares two dicts it compares each value with ==, so any value slot in the expected dict can be a matcher. This means you can mix exact values (like "ok": True) with matchers (like "took_ms": IsPositiveInt) freely in the same assertion.
Datetime Matchers
Timestamps are the second most common source of test flakiness after generated IDs. dirty-equals provides matchers for asserting that a value is a datetime, is within a certain range, or is approximately “now”.
# datetime_matchers.py
from dirty_equals import IsDatetime, IsDate, IsNow, IsApprox
from datetime import datetime, date, timezone, timedelta
# IsDatetime -- any datetime object
assert datetime.now() == IsDatetime
# IsDate -- any date object (not datetime)
assert date.today() == IsDate
# IsNow -- a datetime within delta of right now (default: 5 seconds)
# Useful for asserting "this was just set to now"
now = datetime.now(tz=timezone.utc)
assert now == IsDatetime(approx=True) # within ~2 seconds of actual now
# Unix timestamps (int/float) can also be checked as approximate datetime
import time
unix_ts = time.time()
assert unix_ts == IsNow(unix_number=True) # matches if within 5s of now
# Asserting datetime in a known range
one_minute_ago = datetime.now(tz=timezone.utc) - timedelta(seconds=60)
one_minute_from_now = datetime.now(tz=timezone.utc) + timedelta(seconds=60)
recent_ts = datetime.now(tz=timezone.utc)
# Check it falls in a range via plain comparison -- or use AnyThing + separate assertion
assert recent_ts == IsDatetime # at least know it's a datetime
print("Datetime matchers passed!")
Output:
Datetime matchers passed!
IsNow(unix_number=True) is the pattern for asserting API responses that return Unix timestamps instead of ISO strings. The default tolerance is 5 seconds, which is generous enough for network latency and clock jitter in CI but tight enough to catch a timestamp that was never updated. If your test environment can have longer delays, pass delta=timedelta(seconds=30) to widen the window.
Building Custom Matchers
When the built-in matchers don’t cover your domain-specific constraint, you can build your own in a few lines by subclassing DirtyEquals.
# custom_matchers.py
from dirty_equals import DirtyEquals
# Custom matcher: any string that looks like a slug (lowercase, hyphens, no spaces)
class IsSlug(DirtyEquals[str]):
def equals(self, other: str) -> bool:
import re
return isinstance(other, str) and bool(re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', other))
# Custom matcher: a positive float within a percentage of a target
class IsWithinPercent(DirtyEquals[float]):
def __init__(self, target: float, percent: float = 10.0):
super().__init__(target, percent)
self.target = target
self.percent = percent
def equals(self, other: float) -> bool:
if not isinstance(other, (int, float)):
return False
return abs(other - self.target) / max(abs(self.target), 1e-10) * 100 <= self.percent
# Custom matcher: a dict where all values are the same type
class IsDictOf(DirtyEquals[dict]):
def __init__(self, key_type: type, val_type: type):
super().__init__(key_type, val_type)
self.key_type = key_type
self.val_type = val_type
def equals(self, other: dict) -> bool:
return (
isinstance(other, dict)
and all(isinstance(k, self.key_type) for k in other)
and all(isinstance(v, self.val_type) for v in other.values())
)
# Test the custom matchers
assert "python-how-to-program" == IsSlug
assert "hello world" != IsSlug # contains a space
assert 104.5 == IsWithinPercent(100.0, percent=5) # 4.5% off
assert 120.0 != IsWithinPercent(100.0, percent=5) # 20% off -- fails
scores = {"alice": 95, "bob": 87, "carol": 91}
assert scores == IsDictOf(str, int)
print("Custom matchers passed!")
Output:
Custom matchers passed!
The pattern is always the same: subclass DirtyEquals[T] where T is the type you’re matching, override equals(self, other) to return True or False, and call super().__init__(*args) with any constructor arguments to make the matcher hashable and repr-friendly. The generic parameter [T] is optional — it’s a type hint for static analysis tools, not enforced at runtime.
Real-Life Example: Testing a User Registration API
Here is a complete integration test for a fake user registration response. It demonstrates all the matcher patterns from the article working together in a single pytest test.
# test_user_api.py
"""
Integration test for a user registration endpoint.
Simulates a realistic API response with non-deterministic fields
and asserts the shape using dirty-equals matchers.
Run with: pytest test_user_api.py -v
"""
import uuid
import time
import pytest
from dirty_equals import (
IsUUID, IsStr, IsEmail, IsUrl, IsPositiveInt,
IsNow, IsSuperDict, IsInstance, RegexStr, HasLength
)
def simulate_registration_response(name: str, email: str) -> dict:
"""Simulate an API response with server-generated fields."""
return {
"ok": True,
"user": {
"id": str(uuid.uuid4()),
"name": name,
"email": email,
"avatar_url": f"https://avatars.example.com/{uuid.uuid4()}.png",
"plan": "free",
"score": 0,
"tags": [],
},
"session": {
"token": f"tok_{uuid.uuid4().hex[:24]}",
"expires_at": time.time() + 3600,
},
"request_id": f"req_{uuid.uuid4().hex[:12]}",
"took_ms": 14,
}
def test_registration_response_shape():
"""Assert the shape of the registration response without hard-coding UUIDs."""
response = simulate_registration_response("Alice Chen", "alice@example.com")
assert response == {
"ok": True,
"user": {
"id": IsUUID,
"name": "Alice Chen",
"email": IsEmail,
"avatar_url": IsUrl,
"plan": IsStr,
"score": IsInstance(int),
"tags": [],
},
"session": {
"token": RegexStr(r"^tok_[a-f0-9]{24}$"),
"expires_at": IsNow(unix_number=True, delta=7200), # within 2h of now
},
"request_id": RegexStr(r"^req_[a-f0-9]{12}$"),
"took_ms": IsPositiveInt,
}
def test_registration_partial_fields():
"""Assert only the fields we care about, ignoring the rest."""
response = simulate_registration_response("Bob Smith", "bob@example.com")
# Only assert what we need for this test scenario
assert response == IsSuperDict({
"ok": True,
"user": IsSuperDict({
"id": IsUUID,
"email": IsEmail,
}),
})
def test_session_token_format():
"""The session token must match our expected format."""
response = simulate_registration_response("Carol Jones", "carol@example.com")
token = response["session"]["token"]
assert token == RegexStr(r"^tok_[a-f0-9]{24}$")
assert token == HasLength(ge=10)
if __name__ == "__main__":
test_registration_response_shape()
test_registration_partial_fields()
test_session_token_format()
print("All tests passed!")
Output (pytest -v):
collected 3 items
test_user_api.py::test_registration_response_shape PASSED
test_user_api.py::test_registration_partial_fields PASSED
test_user_api.py::test_session_token_format PASSED
============================== 3 passed in 0.05s ==============================
Notice that test_registration_response_shape asserts the entire response in one statement, including nested dicts — something that would normally require either mocking uuid.uuid4 and time.time, or splitting the test into a dozen individual field checks. The test is also self-documenting: the assertion block reads almost like a spec for what the API should return. Extend this pattern by importing your matchers into a shared conftest.py so they are available across your test suite without duplication.
Frequently Asked Questions
Does dirty-equals work with unittest.TestCase, not just pytest?
Yes. Because all matchers implement __eq__, they work with any assertion that uses the == operator. In unittest you use self.assertEqual(value, IsUUID) and it works identically to assert value == IsUUID in pytest. The only difference is the failure message — pytest shows a richer diff, while unittest shows the default AssertionError. Both correctly report which field failed.
What does the failure message look like when a dirty-equals assertion fails?
When a matcher returns False, pytest shows the repr of the matcher in the diff, which clearly indicates what was expected. For example, if assert "not-a-uuid" == IsUUID fails, pytest shows AssertionError: assert 'not-a-uuid' == IsUUID(). The matcher’s repr is generated automatically from its class name and constructor arguments, so IsApprox(100, rel=0.05) prints as IsApprox(100, rel=0.05) in the failure output. Custom matchers inherit this behavior.
How do I handle a field that can be either None or a specific type?
Use the | operator to combine matchers: assert value == IsStr | IsNone. dirty-equals overloads __or__ to create a union matcher that returns True if either side matches. This is cleaner than writing assert value is None or isinstance(value, str) as a separate conditional. You can chain as many matchers as needed: IsStr | IsInt | IsNone.
Can I use a list of matchers to assert each element of a list individually?
Yes. Compare a list directly against a list of matchers: assert items == [IsStr, IsPositiveInt, IsUUID]. Python compares lists element by element, so each position in the expected list can be a matcher. If you want to assert that every element of a list matches the same shape, combine this with a list comprehension: assert all(item == IsSuperDict({"id": IsUUID}) for item in items).
What Python and pip versions does dirty-equals support?
dirty-equals supports Python 3.10 and above. Install it with pip install dirty-equals; no extra dependencies are required for the core matchers. Some matchers like IsEmail and IsUrl do basic format validation without external packages — they do not validate that the domain actually exists. For production email validation, pair dirty-equals in tests with a dedicated validation library like email-validator in application code.
Conclusion
This article covered the full dirty-equals toolkit: type matchers (IsStr, IsInt, AnyThing), numeric matchers (IsApprox, IsPositiveInt, IsNumeric), string and URL matchers (IsUUID, RegexStr, HasLength, IsEmail), collection matchers (IsSuperDict, Contains), datetime matchers (IsNow, IsDatetime), and custom matcher creation via DirtyEquals. Each matcher plugs into standard Python equality, so it works in any test framework without configuration.
The real-life example showed how to assert an entire API response body in one statement — including nested dicts, non-deterministic UUIDs, regex-constrained tokens, and approximate timestamps — without mocking or field extraction. Take that pattern, drop your project-specific matchers into a shared conftest.py, and your integration tests will shrink from dozens of field-by-field assertions to a single readable block that reads like a spec.
For the full list of available matchers and advanced usage including IsJson, IsHash, and sequence ordering matchers, see the official dirty-equals documentation at dirty-equals.helpmanual.io.