I've run into the following (edge?) case that I don't know how to handle properly. The general problem is that
- I have a function that I want to test
- in that function I call an external function with a generator comprehension as its argument
- in my tests, I mock the external function away
- now the prod code and the tested code differ: in prod, the generator is consumed, the mock doesn't do that
Here is a reduced example of what it looks like in my codebase:
import itertools
import random
def my_side_effects():
# imaginge itertools.accumulate was some expensive strange function
# that consumes an iterable
itertools.accumulate(random.randint(1, 5) for _ in range(10))
def test_my_side_effects(mocker):
my_mocked_func = mocker.patch('itertools.accumulate')
my_side_effects()
# make sure that side-effects took place. can't do much else.
assert my_mocked_func.call_count == 1
The test runs just fine, and is good enough for all I care. But when I run coverage
on the code, the situation that I described in the abstract becomes apparent:
----------- coverage: platform linux, python 3.8.0-final-0 -----------
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------------------------------
[...]
my_test_case.py 5 0 2 1 86% 6->exit
[...]
----------------------------------------------------------------------------------
# something like this, the ->exit part on the external call is the relevant part
Explanation of the ->exit
syntax in coverage.py.
Given that the comprehension could execute relevant business logic that I actually do want to run, the missed coverage is relevant. It's just calling random.randint
here, but it could do anything.
Workarounds:
- I can just use a list comprehension instead. The code is called and everybody is happy. Except me, who has to modify their backend in order to mollify tests.
- I can reach into the mock during the test, grab the call arg, and unroll it by hand. This will probably look godawful.
- I can monkeypatch the function instead of using a magicmock, something like
monkeypatch.setattr('itertools.accumulate', lambda x: [*x])
would be quite descriptive. But I would lose the ability to make call assertions like in my example.
What I would consider a good solution would be something like this, which sadly doesn't exist:
def test_my_side_effects(mocker):
my_mocked_func = mocker.patch('itertools.accumulate')
# could also take "await", and assign treatments by keyword
my_mocked_func.arg_treatment('unroll')
my_side_effects()
# make sure that side-effects took place. can't do much else.
assert my_mocked_func.call_count == 1