Exceptions

Testing functions that are expected to raise exceptions for some inputs is a very common task. The usual way to parametrize tests for such functions is to write separate tests for the valid and invalid inputs, but this adds boilerplate and often means duplicating the code that helps setup the test. Instead, this tutorial will show an elegant way to use the same test function for all inputs. The key is the error_or function, which:

  • Produces a schema that accepts either an expected value or an expected error.

  • When that schema is evaluated, creates a context manager that can be used to check whether or not the expected exception was raised.

To give a concrete example, we’ll extend the Vector class from the Getting started tutorial with a normalize() method, which should raise a NullVectorError exception when called on a null vector:

vector.py
import math

class Vector:

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def normalize(self):
        m = math.sqrt(self.x**2 + self.y**2)

        if m == 0:
            raise NullVectorError
        else:
            self.x /= m
            self.y /= m

class NullVectorError(Exception):
    pass

Each test case for this method specifies either an expected parameter or an error parameter. The expected parameter is just a value, the same as all the other test parameters we’ve seen in these tutorials. The error parameter is special, in that it should specify the exception to expect. There are a few ways to do this (see error for details), but the simplest is to give a string that will evaluate to an exception type:

test_vector.nt
test_normalize:
  -
    given: Vector(1, 0)
    expected: Vector(1, 0)
  -
    given: Vector(0, 2)
    expected: Vector(0, 1)
  -
    given: Vector(0, 0)
    error: NullVectorError

To write the test function, we’ll make use of error_or. This function sets up a schema that will expect every set of test parameters to specify either an error parameter or whatever “expected” parameters are listed as arguments (just expected in this case). Either way, the test function will receive arguments corresponding to the error and every “expected” parameter. The error argument will be a context manager that will either check that the expected error was raised (if an error was specified) or do nothing (otherwise). The “expected” arguments will be either be passed directly through to the test function (if no error was specified) or be replaced with MagicMock objects (otherwise). The purpose of replacing the “expected” arguments with MagicMock objects is to help avoid the intended exception from getting preempted by some other exception caused by an unspecified expected value.

This sounds complicated, but in practice it’s not bad. Hopefully the following example code will help make everything clear:

test_vector.py
import parametrize_from_file as pff
from pytest import approx

with_vec = pff.Namespace('from vector import *')

@pff.parametrize(
        schema=[
            pff.cast(
                given=with_vec.eval,
                expected=with_vec.eval,
            ),
            pff.error_or('expected', globals=with_vec),
        ],
)
def test_normalize(given, expected, error):
    with error:
        given.normalize()

        assert given.x == approx(expected.x)
        assert given.y == approx(expected.y)

A shortcoming of this example is that it does not show how to check that the exception has the expected error message. For more information on that, consult the documentation for the error function.