dirty-equals

repository·main·Indexed 21 days ago

https://github.com/samuelcolvin/dirty-equals

A Python library for Python 3.9+ that makes unit tests more declarative and readable by using special objects to override equality checks. It is designed for testing API responses and database contents where exact value matching is difficult, providing matchers like IsPositiveInt, IsStr, IsJson, and IsNow to validate properties and structures instead of exact values.

Tokens
4.8K
Snippets
24
Records
26
Agent score
76%

What's inside dirty-equals

  1. Compare with uninitialised types

    main

    In most cases, dirty-equals allows you to compare an object directly against a type class without calling it (i.e., without adding ()). This is a convenience to reduce boilerplate.

    Warning: This does not work with PyPy.

    Limitation: Types that require at least one argument during initialisation (such as IsApprox) cannot be used without parentheses. Comparing against an uninitialised type that requires arguments will simply return False.

    from dirty_equals import IsInt
    
    # These are equivalent
    assert 1 == IsInt
    assert 1 == IsInt()
  2. Handle timezones with IsDatetime

    main

    When comparing datetime objects using IsDatetime, you can control how timezone awareness and offsets are handled using the enforce_tz parameter.

    enforce_tz=True (Default)

    Strict matching of timezone status and offsets:

    • If the reference datetime is naive, the compared value must also be naive.
    • If the reference datetime is aware, the compared value must be aware and have the same offset.

    enforce_tz=False

    More flexible matching:

    • If the reference datetime is naive, the compared value can be either naive or aware, as long as the datetime values match.
    • If the reference datetime is aware, the compared value must represent the same point in time (it must be aware).
    from datetime import datetime
    from zoneinfo import ZoneInfo
    from dirty_equals import IsDatetime
    
    tz_london = ZoneInfo('Europe/London')
    new_year_london = datetime(2000, 1, 1, tzinfo=tz_london)
    
    tz_nyc = ZoneInfo('America/New_York')
    new_year_eve_nyc = datetime(1999, 12, 31, 19, 0, 0, tzinfo=tz_nyc)
    
    # Matches because they represent the same point in time, despite different offsets
    assert new_year_eve_nyc == IsDatetime(approx=new_year_london, enforce_tz=False)
    
    # Fails because offsets do not match
    assert new_year_eve_nyc != IsDatetime(approx=new_year_london, enforce_tz=True)
    
    new_year_naive = datetime(2000, 1, 1)
    
    # Fails because naive vs aware comparison with enforce_tz=False requires value match
    assert new_year_naive != IsDatetime(approx=new_year_london, enforce_tz=False)
    
    # Matches because values match and enforce_tz=False allows naive/aware mix for naive references
    assert new_year_london == IsDatetime(approx=new_year_naive, enforce_tz=False)
  3. Understand pytest compatibility and __repr__ behavior

    main

    The __repr__ method of dirty-equals types is designed to provide clean, readable diffs in pytest when assertions fail.

    Standard Behavior

    By default, repr() returns a string describing the type, similar to how it was created:

    • repr(IsInt) -> 'IsInt'
    • repr(IsInt()) -> 'IsInt()'
    • repr(IsApprox(42)) -> 'IsApprox(approx=42)'

    Comparison Behavior (The 'Black Magic')

    When a dirty-equals type is used in an equality (==) operation and the comparison returns True, the __repr__ of the type changes to return the repr() of the actual value being compared.

    This ensures that when pytest compares two large dictionaries and finds them equal, the diff doesn't show the dirty-equals type names, but rather the actual values, making the output much easier to read.

    Important: To get the cleanest pytest output, always use initialised types (e.g., IsPositiveInt() instead of IsPositiveInt).

    from dirty_equals import IsInt
    
    v = IsInt()
    assert 42 == v
    assert repr(v) == '42'
  4. Use sequence type checkers in dirty-equals

    main
    The dirty-equals library provides several checkers to validate the type and properties of sequences (lists, tuples, etc.) without requiring exact equality. You can use these to verify that an object is a specific sequence type, has a certain length, or contains specific elements.
  5. Combine types using Boolean logic

    main

    You can combine multiple dirty-equals types using bitwise operators to create complex validation logic:

    • & (AND): All checks must be True for the combined check to be True.
    • | (OR): Any single check can be True for the combined check to be True.
    • ~ (NOT): Inverts the type, which is equivalent to using != instead of ==.

    Note that when using ~, you are asserting that the object does not match the specified type.

    from dirty_equals import Contains, HasLen
    
    # All checks must be True
    assert ['a', 'b', 'c'] == HasLen(3) & Contains('a')
    
    # Any check can be True
    assert ['a', 'b', 'c'] == HasLen(3) | Contains('z')
    
    # Inversion (NOT)
    assert ['a', 'b', 'c'] != Contains('z')
    assert ['a', 'b', 'c'] == ~Contains('z')
  6. Create custom types by inheriting from DirtyEquals

    main

    You can define custom matching logic by creating a class that inherits from DirtyEquals. This is more robust than implementing a standard __eq__ method because:

    1. Error Handling: TypeError and ValueError raised within the equals method are caught and treated as a False (not-equals) result instead of crashing.
    2. Representation: A useful __repr__ is automatically generated. If the equality check returns True, the representation is modified for better debugging.
    3. Boolean Logic: Support for boolean operators (like | for OR) works out of the box.
    4. Uninitialised Usage: You can use the class itself (e.g., IsEven) in comparisons rather than needing to instantiate it (e.g., IsEven()).

    To implement a custom type, inherit from DirtyEquals[T] (where T is the type of the value you are comparing against) and implement the equals(self, other: Any) -> bool method.

    from decimal import Decimal
    from typing import Any, Union
    
    from dirty_equals import DirtyEquals, IsOneOf
    
    
    class IsEven(DirtyEquals[Union[int, float, Decimal]]):
        def equals(self, other: Any) -> bool:
            return other % 2 == 0
    
    
    assert 2 == IsEven
    assert 3 != IsEven
    assert 'foobar' != IsEven
    assert 3 == IsEven | IsOneOf(3)
  7. Use dirty-equals for declarative testing

    main

    The dirty-equals library allows you to perform declarative assertions by using special objects that override the __eq__ method. Instead of checking exact values, you can check for properties like being a positive integer, matching a regex, or being close to the current time. This is particularly useful when testing API responses or database records where some fields (like IDs, timestamps, or generated strings) are non-deterministic.

    from dirty_equals import IsJson, IsNow, IsPositiveInt, IsStr
    
    def test_user_endpoint(client: 'HttpClient', db_conn: 'Database'):
        client.post('/users/create/', data=...)
    
        user_data = db_conn.fetchrow('select * from users')
        assert user_data == {
            'id': IsPositiveInt,  # Checks if id is a positive integer
            'username': 'samuelcolvin',  # Standard equality check
            'avatar_file': IsStr(regex=r'/[a-z0-9\-]{10}/example\.png'),  # Matches a regex pattern
            'settings_json': IsJson({'theme': 'dark', 'language': 'en'}),  # Validates JSON content against a dict
            'created_ts': IsNow(delta=3),  # Checks if datetime is within 3 seconds of now
        }
  8. Perform complex dictionary assertions with dirty-equals

    main

    You can use dirty-equals to validate complex dictionaries by replacing specific values with constraint objects. This allows you to verify the structure and certain properties of a dictionary without needing to know the exact values of every field.

    Commonly used constraints include:

    • IsPositiveInt: Validates that a value is a positive integer.
    • IsStr(regex=...): Validates that a string matches a specific regular expression.
    • IsJson(dict): Validates that a value is a JSON string that, when decoded, matches the provided dictionary.
    • IsNow(delta=...): Validates that a datetime is close to the current time within a certain number of seconds.
    from dirty_equals import IsJson, IsNow, IsPositiveInt, IsStr
    
    # user_data is a dict returned from a database or API which we want to test
    assert user_data == {
        # we want to check that id is a positive int
        'id': IsPositiveInt,
        # we know avatar_file should be a string, but we need a regex as we don't know whole value
        'avatar_file': IsStr(regex=r'/[a-z0-9\-]{10}/example\.png'),
        # settings_json is JSON, but it's more robust to compare the value it encodes, not strings
        'settings_json': IsJson({'theme': 'dark', 'language': 'en'}),
        # created_ts is datetime, we don't know the exact value, but we know it should be close to now
        'created_ts': IsNow(delta=3),
    }
  9. Use numeric type checkers in dirty-equals

    main

    The dirty_equals library provides several specialized classes for validating numeric types and their properties. These can be used in assertions to check if a value matches specific numeric criteria such as being an integer, a float, or falling within a specific sign range (positive, negative, etc.).

    # Example usage of numeric checkers
    from dirty_equals import IsInt, IsPositive, IsApprox
    
    assert 10 == IsInt()
    assert 5 == IsPositive()
    assert 3.14159 == IsApprox(3.14, rel=1e-3)
  10. Reference: Numeric type checkers

    main

    The following classes are available for numeric validation in dirty_equals:

    dirty_equals.IsInt
    dirty_equals.IsFloat
    dirty_equals.IsPositive
    dirty_equals.IsNegative
    dirty_equals.IsNonNegative
    dirty_equals.IsNonPositive
    dirty_equals.IsPositiveInt
    dirty_equals.IsNegativeInt
    dirty_equals.IsPositiveFloat
    dirty_equals.IsNegativeFloat
    dirty_equals.IsFloatInf
    dirty_equals.IsFloatInfPos
    dirty_equals.IsFloatInfNeg
    dirty_equals.IsFloatNan
    dirty_equals.IsApprox
    dirty_equals.IsNumber
    dirty_equals.IsNumeric
  11. Common dirty-equals matchers

    main

    The library provides several matchers to handle non-deterministic data:

    • IsPositiveInt: Asserts the value is a positive integer.
    • IsStr(regex=...): Asserts the value is a string matching the provided regular expression.
    • IsJson(obj): Asserts a JSON string encodes a specific Python object.
    • IsNow(delta=2): Asserts a datetime is close to the current time. The delta parameter (in seconds) is optional and defaults to 2.
    • IsInstance(type): Confirms the type of an object.
    • IsPartialDict: Compares a subset of a dictionary.
    • IsStrictDict: Confirms the order in a dictionary.
    • IsList / IsTuple: Compares partial lists or tuples, with or without order constraints.