DEV Community

Cover image for How to Use Pytest for Exception Testing: Insights from Open Source Projects
Michael Interface
Michael Interface

Posted on • Originally published at hashnode.com

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 using pytest:

  • 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Takeaway: 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',
        },
    ]
Enter fullscreen mode Exit fullscreen mode

⚠️ NOTE: Always assert exception attributes outside the pytest.raises() context manager to ensure proper exception handling.

Takeaway: 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)
Enter fullscreen mode Exit fullscreen mode

Takeaway: 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)
Enter fullscreen mode Exit fullscreen mode

Takeaway: 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

Enter fullscreen mode Exit fullscreen mode

Takeaway: 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.

Quadratic AI

Quadratic AI – The Spreadsheet with AI, Code, and Connections

  • AI-Powered Insights: Ask questions in plain English and get instant visualizations
  • Multi-Language Support: Seamlessly switch between Python, SQL, and JavaScript in one workspace
  • Zero Setup Required: Connect to databases or drag-and-drop files straight from your browser
  • Live Collaboration: Work together in real-time, no matter where your team is located
  • Beyond Formulas: Tackle complex analysis that traditional spreadsheets can't handle

Get started for free.

Watch The Demo 📊✨

Top comments (0)

👋 Kindness is contagious

DEV shines when you're signed in, unlocking a customized experience with features like dark mode!

Okay