Search code examples
pythonunit-testingmockingmonkeypatching

How to test exceptions in mock unittests for non-deterministic functions?


I've a chain of functions in my library that looks like this, from myfuncs.py

import copy
import random

def func_a(x, population=[0, 0, 123, 456, 789]):
    sum_x = 0
    for _ in range(x):
        pick = random.choice(population)
        if pick == 0: # Reset the sum.
            sum_x = 0
        else:
            sum_x += pick
    return {'input': sum_x}

def func_b(y):
    sum_x = func_a(y)['input']
    scale_x = sum_x * 1_00_000
    return {'a_input': sum_x, 'input': scale_x}


def func_c(z):
    bz = func_b(z)
    scale_x = bz['b_input'] = copy.deepcopy(bz['input'])
    bz['input']  = scale_x / (scale_x *2)**2
    return bz

Due to the randomness in func_a, the output of fun_c is non-deterministic. So sometimes when you do:

>>> func_c(12)
{'a_input': 1578, 'input': 1.5842839036755386e-09, 'b_input': 157800000}

>>> func_c(12)
{'a_input': 1947, 'input': 1.2840267077555213e-09, 'b_input': 194700000}

>>> func_c(12)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-121-dd3380e1c5ac> in <module>
----> 1 func_c(12)

<ipython-input-119-cc87d58b0001> in func_c(z)
     21     bz = func_b(z)
     22     scale_x = bz['b_input'] = copy.deepcopy(bz['input'])
---> 23     bz['input']  = scale_x / (scale_x *2)**2
     24     return bz

ZeroDivisionError: division by zero

Then I've modified func_c to catch the error and explain to users why ZeroDivisionError occurs, i.e.

def func_c(z):
    bz = func_b(z)
    scale_x = bz['b_input'] = copy.deepcopy(bz['input'])
    try:
        bz['input']  = scale_x / (scale_x *2)**2
    except ZeroDivisionError as e:
        raise Exception("You've lucked out, the pick from func_a gave you 0!")
    return bz

And the expected behavior that raises a ZeroDivisionError now shows:

---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-123-4082b946f151> in func_c(z)
     23     try:
---> 24         bz['input']  = scale_x / (scale_x *2)**2
     25     except ZeroDivisionError as e:

ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Exception                                 Traceback (most recent call last)
<ipython-input-124-dd3380e1c5ac> in <module>
----> 1 func_c(12)

<ipython-input-123-4082b946f151> in func_c(z)
     24         bz['input']  = scale_x / (scale_x *2)**2
     25     except ZeroDivisionError as e:
---> 26         raise Exception("You've lucked out, the pick from func_a gave you 0!")
     27     return bz

Exception: You've lucked out, the pick from func_a gave you 0!

I could test the func_c in a deterministic way to avoid the zero-division without iterating func_c multiple times and I've tried:

from mock import patch
from myfuncs import func_c

with patch("myfuncs.func_a", return_value={"input": 345}):
    assert func_c(12) == {'a_input': 345, 'input': 7.246376811594203e-09, 'b_input': 34500000}

And when I need to test the new exception, I don't want to arbitrarily iterate func_c such that I hit the exception, instead I want to mock the outputs from func_a directly to return the 0 value.

Q: How do I get the mock to catch the new exception without iterating multiple time through func_c?

I've tried this in my testfuncs.py file in the same directory as myfuncs.py:

from mock import patch
from myfuncs import func_c

with patch("myfuncs.func_a", return_value={"input": 0}):
    try:
        func_c(12)
    except Exception as e:
        assert str(e).startswith("You've lucked out")
Is how I'm checking the error message content the right way to check Exception in the mock test?

Solution

  • First of all - yes, to test non-deterministic function you should ideally mock all values that result in different scenarios. Packages like hypothesis can help you with that.

    Few things that I noticed and would recommend to change

    • You wrote you add testfuncs.py file to the same directory. It's good to have all tests under different directory (usually tests) so you have easier controll over which tests to run or can prepare a deployment package without tests.
    • Althought I found the practice of checking logging messages in few places, I recommend to not do so. We don't expect to have tests fail after changing some typo in the message. In your case you could create your own exception and check if it's raised
    class BadLuckException(Exception):
       def __init__(self):
           super().__init__("You've lucked out")
    
    # test_som.py
    with pytest.raises(BadLuckException):
        func_c(12)