The ability to mutate objects means that we have to be careful when writing functions that accept mutable types as parameters. In general, if a function’s documentation does not specify that an object will be mutated, then it must not be mutated. How can we test that no mutation occurred? And, for functions that intend to mutate an object, how can we test that the correct change occurred? In this section, we will extend our study of writing tests to answer both of these questions.
Consider the squares function we introduced at the
beginning of the chapter:
def squares(numbers: list[int]) -> list[int]:
"""Return a list of the squares of the given numbers."""
squares_so_far = []
for number in numbers:
squares_so_far.append(number * number)
return squares_so_farThere are two lists in squares: the numbers
parameter, which is an input to the function; and the
squares_so_far variable, which is an output of the
function. Because squares_so_far is created by the function
squares, it is okay that it is mutated (i.e., the call to
list.append inside the for loop). However, the
numbers list is passed as an argument to
squares. Because the docstring does not indicate that
numbers will be mutated, it is expected that the
squares function will not mutate the list object referred
to by numbers.
We can contrast this with how we would document and implement a similar function that does mutate its input:
def square_all(nums: list[int]) -> None:
"""Modify nums by squaring each of its elements."""
for i in range(0, len(nums)):
nums[i] = nums[i] * nums[i]Let us write a test that ensures the squares function
does not mutate the list referred to by numbers:
def test_squares_no_mutation() -> None:
"""Test that squares does not mutate the list it is given.
"""
lst = [1, 2, 3]
squares(lst)
# TODO: complete the testIn order to test that a list is not mutated, we first create a list
lst. Second, we call the squares function on
lst; note that this function call returns a list of
squares, but we do not assign the result to a variable because we don’t
actually care about the returned value for the purpose of this
test. This might seem a bit strange, as all of our tests so
far have been about checking the return value of the function being
tested. In practice, we would have such unit/property-based tests for
squares as well, we just aren’t showing them here.
We can now add an assertion that ensures lst has not been
mutated:
def test_squares_no_mutation() -> None:
"""Test that squares does not mutate the list it is given.
"""
lst = [1, 2, 3]
squares(lst)
assert lst == [1, 2, 3]The variable lst originally had value
[1, 2, 3]. So our assertion checks that after the
call to squares, lst still has value
[1, 2, 3]. Another way to accomplish this, without
re-typing the list value, is by creating a copy of lst
before the call to squares. We can do this using the
list.copy method:
def test_squares_no_mutation() -> None:
"""Test that squares does not mutate the list it is given.
"""
lst = [1, 2, 3]
lst_copy = lst.copy() # Create a copy of lst (not an alias!)
squares(lst)
assert lst == lst_copyNote that the order of statements is very important when testing for
mutation. We need to create the list and its copy before the call to
squares. And we need to test for mutation (i.e., the
assertion) after the call to squares.
You might notice that the above test_squares_no_mutation
test function doesn’t actually use the specific elements of the list
lst. That is, if we replaced lst’s value with
another list, the test would behave in the exact same way. That makes
this test very suitable to be generalized into a property-based
test, representing the following property:
For all lists of integers
lst, callingsquares(lst)does not mutatelst.
Here is how we could implement such a property-based test using the
technique we learned in 4.4
Testing Functions II:
hypothesis. We’ve included the import statements to remind you
about the ones from hypothesis you need for property-based
tests.
from hypothesis import given
from hypothesis.strategies import lists, integers
@given(lst=lists(integers()))
def test_squares_no_mutation_general(lst: list[int]) -> None:
"""Test that squares does not mutate the list it is given.
"""
lst_copy = lst.copy() # Create a copy of lst (not an alias!)
squares(lst)
assert lst == lst_copyNow let’s consider testing the square_all function from
above. One common error students make when writing tests for mutating
functions is to check the return value of the function.
def test_square_all() -> None:
"""Test that square_all mutates the list it is given correctly.
"""
lst = [1, 2, 3]
result = square_all(lst)
assert result == [1, 4, 9]This test fails because square_all returns
None, and None == [1, 4, 9] is False. Using
result in our assertion is not useful for testing if
lst was mutated. Instead, we must test if the value of
lst has
changed: Like test_squares_no_mutation, this test
does not store the return value of the function being tested. But the
reason is quite different!
def test_square_all_mutation() -> None:
"""Test that square_all mutates the list it is given correctly.
"""
lst = [1, 2, 3]
square_all(lst)
assert lst == [1, 4, 9]We can again generalize this test into a property-based test by storing a copy of the original list and verifying the relationship between corresponding elements. We’ll leave it as an exercise for you to read through and understand the following property-based test:
@given(lst=lists(integers()))
def test_square_all_mutation_general(lst: list[int]) -> None:
"""Test that square_all mutates the list it is given correctly.
"""
lst_copy = lst.copy()
square_all(lst)
assert all({lst[i] == lst_copy[i] ** 2 for i in range(0, len(lst))})