Title: How to test exceptions in mock unittests for non-deterministic functions?
Tags: python,unit-testing,mocking,monkeypatching

Question: 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?

Answer: What you have looks fine to me.  Unit tests usually break down and test each component in isolation and run through all the possible paths of execution.  Essentially, you want to make sure each line of code is run at least once to bring out any issues (instead of bringing out the issues when running in production).
With that said, there are tools in the various test suites to aid with common test cases.  One is pytest.raises() or unittest's assertRaisesRegex to assert that the code within that context 100% raises the exception you expect.
In your code, the test will still pass even if the exception isn't raised when it should fail.  You could fix that manually, but using the framework tools makes for easier to read and more concise code.