Testing with Pydantic

Note

To ensure you are using compatible versions, install with the testfixtures[pydantic] extra.

When pydantic is installed, a comparer for BaseModel is automatically registered with ignore_eq=True. It compares models field-by-field using their declared attributes, so differences are shown clearly:

>>> from pydantic import BaseModel
>>> from testfixtures import compare
>>> class Point(BaseModel):
...     x: int
...     y: int
>>> compare(Point(x=1, y=2), expected=Point(x=1, y=3))
Traceback (most recent call last):
 ...
AssertionError: Point not as expected:

attributes same:
['x']

attributes differ:
'y': 3 (expected) != 2 (actual)

The ignore_eq=True registration is also needed whenever a model contains attributes whose type has a custom __eq__ that compare() has a registered comparer for, such as Polars or Pandas DataFrames. Without it, pydantic’s __eq__ calls == on each attribute value before testfixtures can intercept it, which for many such types raises an error or gives a misleading result.

For example, consider a model with a DataFrame attribute:

import polars as pl
from pydantic import BaseModel, ConfigDict

class Report(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)
    name: str
    data: pl.DataFrame

r1 = Report(name='sales', data=pl.DataFrame({'x': [1, 2], 'y': [3, 4]}))
r2 = Report(name='sales', data=pl.DataFrame({'x': [1, 2], 'y': [3, 5]}))

Without the BaseModel registration, pydantic’s __eq__ fires first and raises a TypeError:

>>> compare(r1, expected=r2)
Traceback (most recent call last):
 ...
TypeError: the truth value of a DataFrame is ambiguous

Hint: to check if a DataFrame contains any values, use `is_empty()`.

With the registration in place, compare() hands off to the Polars comparer and produces a clear diff:

>>> compare(r1, expected=r2)
Traceback (most recent call last):
 ...
AssertionError: Report not as expected:

attributes same:
['name']

attributes differ:
'data': shape: (2, 2)
┌─────┬─────┐
│ x   ┆ y   │
│ --- ┆ --- │
│ i64 ┆ i64 │
╞═════╪═════╡
│ 1   ┆ 3   │
│ 2   ┆ 5   │
└─────┴─────┘ (expected) != shape: (2, 2)
┌─────┬─────┐
│ x   ┆ y   │
│ --- ┆ --- │
│ i64 ┆ i64 │
╞═════╪═════╡
│ 1   ┆ 3   │
│ 2   ┆ 4   │
└─────┴─────┘ (actual)

While comparing .data: DataFrames are different (value mismatch for column "y")
[left]: shape: (2,)
Series: 'y' [i64]
[
        3
        5
]
[right]: shape: (2,)
Series: 'y' [i64]
[
        3
        4
]

Testing validation errors

When a BaseModel is given invalid data, pydantic raises pydantic.ValidationError. This type has no public constructor that takes a plain message, so building an instance to hand to ShouldRaise for a full comparison is impractical.

Even setting aside how hard it is to construct one, comparing full instances would not catch anything useful anyway. Pydantic stores the details of what went wrong outside the args and __dict__ that compare() inspects for exceptions, so any two naturally raised pydantic.ValidationError instances compare equal regardless of what actually failed:

from pydantic import ValidationError

try:
    Point(x='not-an-int', y=2)
except ValidationError as e:
    exception_1 = e

try:
    Point(x=2, y='also-not-an-int')
except ValidationError as e:
    exception_2 = e
>>> compare(exception_1, exception_2)

The way to check a raised pydantic.ValidationError is therefore to check its str rendering with str_like(). match is a regular expression, so literal square brackets in pydantic’s rendering need escaping:

>>> from testfixtures import ShouldRaise, str_like
>>> with ShouldRaise(
...     str_like(
...         ValidationError,
...         match=(
...             "Input should be a valid integer, "
...             "unable to parse string as an integer "
...             r"\[type=int_parsing, input_value='not-an-int', input_type=str\]"
...         )
...     )
... ):
...     Point(x='not-an-int', y=2)

A short match pattern is easy to write but easy to get wrong too: it only has to be found somewhere in the rendering, so it can still pass even when a part of the message you care about isn’t what you expect. Where that matters, compare the whole rendering instead:

with ShouldRaise(
    str_like(
        ValidationError,
        "1 validation error for Point\n"
        "x\n"
        "  Input should be a valid integer, unable to parse string as an integer "
        "[type=int_parsing, input_value='not-an-int', input_type=str]\n"
        "    For further information visit https://errors.pydantic.dev/2.13/v/int_parsing"
    )
):
    Point(x='not-an-int', y=2)

The URL on the last line of the rendering is specific to the installed version of pydantic, so expect to update assertions like this when upgrading.