Home > front end >  How to test if a function calls range in python?
How to test if a function calls range in python?

Time:01-31

I'm a Python instructor and I wanted to give my students a task: write a function that computes the average of a list using a for loop and the range object.

I wanted to run a test on their function to see whether it actually uses the range object. How can I do that?

It should be something like this:

def avg(L):
    Pass

def test_range(avg):
    ...

If avg contains range, then test_range should return True.

I tried solutions that utilize func_code, but apparantly range doesn't have that.

CodePudding user response:

You can use Python's unittest.mock module to wrap around the range function from the builtins module, and then let your test assert that the wrapped range was indeed called.

For example, using Python's unittest framework for writing the test:

import builtins
import unittest
from unittest.mock import patch

# I don't know what L is supposed to be, and I know that there 
# are better ways to compute average of a list, but the code
# for calculating the average is not important for this question.
def avg(L):
    total = 0
    for index in range(len(L)):
        total  = L[index]
    return total / len(L)

class TestAverage(unittest.TestCase):
    def test_avg(self):
        with patch("builtins.range", wraps=builtins.range) as wrapped_patch:
            expected = 47
            actual = avg([1,49,91])
            self.assertEqual(expected, actual)
        wrapped_patch.assert_called()

if __name__ == '__main__':
    unittest.main()
$ python -m unittest -v main.py
test_avg (main.TestAverage) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

It uses unittest.mock's patch targetting the builtins.range function. Normally, patch is used in tests to replace the behavior and/or return value of the target, but in this case, you can pass wraps=builtins.range (which gets passed to the underlying Mock object), which means "I just want to spy on the call, but not modify its behavior":

wraps: Item for the mock object to wrap. If wraps is not None then calling the Mock will pass the call through to the wrapped object (returning the real result).

By wrapping it in a Mock object, you can use any of Mock's assert functions to check calls to range, like assert_called which checks whether the target was called at least once. You can be more specific by asserting the number of times range was called:

self.assertTrue(wrapped_patch.call_count == 1)

The assertion would fail if it wasn't called at all:

# Here, `range` wasn't used at all.
def avg(L):
    return sum(L) / len(L)

class TestAverage(unittest.TestCase):
    # same as the code above
$ python -m unittest -v main.py
test_avg (main.TestAverage) ... FAIL

======================================================================
FAIL: test_avg (main.TestAverage)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/main.py", line 15, in test_avg
    wrapped_patch.assert_called()
  File "/usr/local/Cellar/[email protected]/3.10.8/Frameworks/Python.framework/Versions/3.10/lib/python3.10/unittest/mock.py", line 888, in assert_called
    raise AssertionError(msg)
AssertionError: Expected 'range' to have been called.

The most important thing to note when using patch, is to know exactly where to patch. In this case, you can check the docs or use __module__ to know range's module:

>>> range
<class 'range'>
>>> range.__module__
'builtins'

But the test is a bit naive, because it can still pass even though avg didn't really use range:

def avg(L):
    range(len(L))  # Called but really unused. Sneaky!
    return sum(L) / len(L)

class TestAverage(unittest.TestCase):
    # same as the code above
$ python -m unittest -v main.py
test_avg (main.TestAverage) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

A slightly confusing workaround would be a test that "breaks" range such that, if the function was really using range, then it wouldn't work anymore:

def avg(L):
    range(len(L))  # Called but really unused. Sneaky!
    return sum(L) / len(L)

class TestAverage(unittest.TestCase):
    def test_avg(self):
        # same as above

    def test_avg_is_really_using_range(self):
        L = [10,20,90]
        # Is it returning the correct result?
        self.assertEqual(avg(L), 40)

        # OK, but did it really use `range`?
        # Let's try breaking `range` so it always yields 0,
        # so we expect the return value to be *different*
        with patch("builtins.range", return_value=[0,0,0]):
            self.assertNotEqual(avg(L), 40)

So, if avg was sneakily calling but not really using range, then test_avg_is_really_using_range would now fail because avg still yields the correct value even with a broken range:

$ python -m unittest -v main.py
test_avg (main.TestAverage) ... ok
test_avg_really_using_range (main.TestAverage) ... FAIL

======================================================================
FAIL: test_avg_really_using_range (main.TestAverage)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/main.py", line 19, in test_avg_really_using_range
    self.assertNotEqual(avg(L), 40)
AssertionError: 40.0 == 40

Lastly, as a side note, I'm using assertEqual here in all the example because the test for the return value is not the focus, but do read up on proper ways to assert possible float values, ex. How to perform unittest for floating point outputs? - python

  • Related