How to Use Pytest for Exception Testing: Insights from Open Source Projects

Testing for exceptions is a crucial part of writing reliable code. Pytest provides powerful tools to check whether a function raises the expected exceptions under specific conditions.

In this post, we'll explore real-world examples of exception testing from popular Python libraries like Celery, Pydantic, Requests, and Jinja. By studying these patterns, you'll learn how to effectively use Pytest to catch and validate exceptions in your own projects.

TLDR

Here are the key exception testing techniques covered in this post:

  • Match → Asserting exception messages (Celery)

  • Inspecting raised exception attributes → Checking detailed validation errors (Pydantic)

  • Parameterized exception testing → Testing multiple failure cases efficiently (Requests)

  • Conditional parameterization → Dynamically controlling expected failures (Jinja)

  • BONUS: Marking expected failures (@pytest.mark.xfail) → Indicating known issues that are expected to fail (Pydantic)

Using match to Assert Exception Messages (Celery)

Sometimes, it's not enough to check if an exception is raised—you also want to verify that the exception message matches a specific pattern. Using match inside pytest.raises() lets you validate error messages.

This approach is useful when a function might raise multiple types of exceptions, and you need to ensure that a specific error condition is being met.

Example from Celery:

Celery raises an ImproperlyConfigured error when AWS credentials are missing. Here’s how the test ensures the error message matches expectations:

@patch('botocore.credentials.CredentialResolver.load_credentials')
def test_with_missing_aws_credentials(self, mock_load_credentials):
    self.app.conf.s3_access_key_id = None
    self.app.conf.s3_secret_access_key = None
    self.app.conf.s3_bucket = 'bucket'

    mock_load_credentials.return_value = None

    with pytest.raises(ImproperlyConfigured, match="Missing aws s3 creds"):
        S3Backend(app=self.app)

Another example from Celery:

In this test, Celery raises a ClientError if an operation is attempted on a non-existing S3 bucket. The test uses match with a regular expression to confirm that the error message contains the expected pattern:

def test_with_a_non_existing_bucket(self):
    self._mock_s3_resource()

    self.app.conf.s3_access_key_id = 'somekeyid'
    self.app.conf.s3_secret_access_key = 'somesecret'
    self.app.conf.s3_bucket = 'bucket_not_exists'

    s3_backend = S3Backend(app=self.app)

    with pytest.raises(ClientError, match=r'.*The specified bucket does not exist'):
        s3_backend._set_with_state('uuid', 'another_status', states.SUCCESS)
💡
Use match when you need to ensure the raised exception contains a specific error message. You can also use regular expressions for more flexible matching.

Inspecting Raised Exception Attributes for Validation Details (Pydantic)

Pydantic enforces strict validation rules when parsing input data into models. When an exception is raised, it's often necessary to inspect its attributes to verify the exact validation errors.

Rather than just checking that a ValidationError occurred, you can extract specific error details from the exception. This is especially useful when dealing with multiple potential validation errors.

Example from Pydantic:

In this test, Pydantic is configured to forbid extra fields in the input. The test ensures that passing unexpected fields (bar and spam) triggers the correct validation errors.

def test_forbidden_extra_fails():
    class ForbiddenExtra(BaseModel):
        model_config = ConfigDict(extra='forbid')
        foo: str = 'whatever'

    with pytest.raises(ValidationError) as exc_info:
        ForbiddenExtra(foo='ok', bar='wrong', spam='xx')

    # Extract validation errors from the exception
    assert exc_info.value.errors(include_url=False) == [
        {
            'type': 'extra_forbidden',
            'loc': ('bar',),
            'msg': 'Extra inputs are not permitted',
            'input': 'wrong',
        },
        {
            'type': 'extra_forbidden',
            'loc': ('spam',),
            'msg': 'Extra inputs are not permitted',
            'input': 'xx',
        },
    ]

When using pytest.raises as a context manager, it’s worthwhile to note that normal context manager rules apply and that the exception raised must be the final line in the scope of the context manager. Lines of code after that, within the scope of the context manager will not be executed

💡
Use the result of the context managerExceptionInfo object to inspect the details of the captured exception.

Using @pytest.mark.parametrize to Test Multiple Exception Cases (Requests)

When testing exceptions, you often need to validate different scenarios that trigger similar failures. The Requests library uses @pytest.mark.parametrize() to efficiently test multiple exception cases.

This approach is useful when the same function can fail for multiple reasons, and you want to test all of them in a single test function.

Example from Requests:

Requests uses the next test to raise certain exceptions based on the provided url schema.

@pytest.mark.parametrize(
    "exception, url",
    [
        (MissingSchema, "hiwpefhipowhefopw"),
        (InvalidSchema, "localhost:3128"),
        (InvalidSchema, "localhost.localdomain:3128/"),
        (InvalidSchema, "10.122.1.1:3128/"),
        (InvalidURL, "http://"),
        (InvalidURL, "http://*example.com"),
        (InvalidURL, "http://.example.com"),
    ],
)
def test_invalid_url(self, exception, url):
    with pytest.raises(exception):
        requests.get(url)
💡
Use @pytest.mark.parametrize() to cover multiple exception cases in a single test, reducing duplication.

Conditional Parameterization for Selective Exception Testing (Jinja)

Some test cases need dynamic expectations—certain inputs should raise exceptions, while others should pass. Jinja handles this by conditionally parameterizing tests.

This technique is useful when inputs behave differently based on conditions, and you want your test logic to adapt accordingly.

Example from Jinja:

In this test Jinja makes use of it by checking the template provided as a string with different values.

@pytest.mark.parametrize(
    ("name", "valid"),
    [
        ("foo", True),
        ("föö", True),
        ("き", True),
        ("_", True),
        ("1a", False),  # invalid ascii start
        ("a-", False),  # invalid ascii continue
        ("\U0001f40da", False),  # invalid unicode start
        ("a🐍\U0001f40d", False),  # invalid unicode continue
        # start characters not matched by \w
        ("\u1885", True),
        ("\u1886", True),
        ("\u2118", True),
        ("\u212e", True),
        # continue character not matched by \w
        ("\xb7", False),
        ("a\xb7", True),
    ],
)
def test_name(self, env, name, valid):
    t = "{{ " + name + " }}"
    if valid:
        # valid for version being tested, shouldn't raise
        env.from_string(t)
    else:
        pytest.raises(TemplateSyntaxError, env.from_string, t)
💡
Use conditional assertions when inputs should selectively raise exceptions.

BONUS: Using @pytest.mark.xfail to Mark Expected Failures (Pydantic)

Sometimes, you may encounter known bugs or limitations in a library that have yet to be fixed. In such cases, Pytest allows you to mark tests as "expected failures" using @pytest.mark.xfail.

That way you have a test that will pass ones the bug is fixed which simplifies the bug tracking process.

Example from Pydantic:

@pytest.mark.xfail(reason='Waiting for union serialization fixes via https://github.com/pydantic/pydantic/issues/9688.')
def smart_union_serialization() -> None:
    class FloatThenInt(BaseModel):
        value: Union[float, int, str] = Field(union_mode='smart')

    class IntThenFloat(BaseModel):
        value: Union[int, float, str] = Field(union_mode='smart')

    float_then_int = FloatThenInt(value=100)
    assert type(json.loads(float_then_int.model_dump_json())['value']) is int

    int_then_float = IntThenFloat(value=100)
    assert type(json.loads(int_then_float.model_dump_json())['value']) is int
💡
Use @pytest.mark.xfail when testing known issues that are expected to fail but still need to be tracked.

Conclusion

Learning from real-world open-source projects gives us insights into how experienced developers structure their test cases. These projects are battle-tested in production environments, making their testing strategies highly valuable.

📌 All code samples are linked to their original sources, so you can explore the full implementations in context.

0
Subscribe to my newsletter

Read articles from Michael Interface directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Michael Interface
Michael Interface