Mock Module

Indices and tables

Introduction

One very important aspect of testing is the ability to selectively replace functions with something we can control. This allows the tester to inject data (via a controlled return value), verify data passed into the mock (via input parameters) and to verify the number of times the mock was called.

Note

Mocks are controllable replacements for functions or methods in the design.

This section will cover testing with mock. In order to use mock, you must install it:

$ pip3 install mock

Mocks are useful for simplifying test scenarios since they allow you to simulate situations in a reliable and predictable manner. You don’t want to retest all the code in every test, so it is often beneficial to mock out items you are not trying to verify in a particular test.

Mocks really show their power when testing code involving external dependencies. For instance, if your python code involves system calls or I/O, it may be difficult to cover certain scenarios (such as all possible exceptions). Suppose we have the following source code:

def write_result_to_file(result):
   filename = input("Enter a filename to write the result to: ")
   f = open(filename, 'w+')
   f.write(result)
   f.close()

def my_adder(a, b):
   return a + b

def my_subtractor(a, b):
   return a - b

def get_input():
   operation = input("Enter a math function (add/subtract):")
   a = input("Enter the first number:")
   b = input("Enter the second number:")
   if not a.isdigit() or not b.isdigit():
      raise SyntaxWarning("One of the inputs was not a number.")
   elif operation.tolower() != "add" and operation.tolower() != "subtract":
      raise SyntaxWarning("Invalid operation supplied.")
   return operation, a, b

def my_math_helper():
   operation, a, b = get_input()
   if operation == "add":
      result = my_adder(a, b)
   elif operation == "subtract":
      result = my_subtractor(a, b)
   write_result_to_file(result)

There are several dependencies here that we will have to break in order to fully test this code: 1. There are interdependencies between the functions in our code. 2. There is a dependency on user input. 3. There is a dependency on the file system.

Breaking Internal Dependencies

my_math_helper relies on four helper functions. It would be possible to write a test involving all of these moving parts but that would over complicate things unnecessarily and make the test harder to maintain. Instead, each of these helper functions can be tested in isolation, and my_math_helper can then be tested using mocks of the helper functions.

We know that my_math_helper does the following: 1. Calls the get_input helper to retrieve three inputs from the user (operation, and two values). 2. Calls one of the math helpers (my_adder/my_subtractor) based on the chosen operation. 3. Writes the result returned from the helper to a file using write_result_to_file

The syntax for mocking a function in the code under test is to use the decorator @mock.patch('mymodule.myfunction'). Since our code and tests are currently in the same source file, we will use the __name__ variable in place of the module name.

Using the @mock.patch decorator, we will get a mock object as input to the test function. There are several things we can do with the mock object:

  • Set the return value using mock.return_value.
  • Check for calls to the mock using:
    • assert_called_once_with if we want to check for a single call with arguments
    • assert_called_once if we want to check for a single call without arguments or if the arguments are of no consequence
    • assert_has_calls if we want to check for a multiple calls with arguments
@mock.patch(__name__ + '.get_input')
@mock.patch(__name__ + '.write_result_to_file')
@mock.patch(__name__ + '.my_adder')
@mock.patch(__name__ + '.my_subtactor')
def test_main_add(mock_sub, mock_add, mock_writefile, mock_getinput):
   #Arrange; set return values to come back from the mocks
   mock_getinput.return_value = "add", 42, 37
   mock_add.return_value = 1234

   #Act; call the function under test
   my_math_helper()

   #Assert; check the input arguments and calls to the mocks
   mock_getinput.assert_called_once()
   mock_sub.assert_not_called()
   mock_add.assert_called_once_with(42, 37)
   mock_writefile.assert_called_once_with(1234)

Breaking External Dependencies

Mocking external dependencies is almost identical to mocking internal dependencies: you use the @mock.patch decorator, and specify the target to mock.

If we want to test get_input, we will want to mock the input() function so we can automatically feed in values in our test script. The input() method is part of builtins, so that will be the module name. Since the get_input method involves multiple calls to input(), we must use the side_effect attribute of the mock instead of return_value.

Tip

mock.side_effect can be used to return a sequence of values for multiple calls.

We will also use parametrize to write two tests for the add and subtract cases.

@pytest.mark.parametrize("operation_in,a_in,b_in", [
   ("subtract", "12", "34"),
   ("add", "75", "61")
])
@mock.patch('builtins.input')
def test_get_input_valid(mock_input, operation_in, a_in, b_in):
   mock_input.side_effect = [operation_in, a_in, b_in]
   operation_out, a_out, b_out = get_input()
   assert (operation_in, a_in, b_in) == (operation_out, a_out, b_out)

Finally, we will want to test failure cases for invalid operations and cases where the operands are not numbers:

@pytest.mark.parametrize("operation_in,a_in,b_in", [
    ("divide", "12", "34"),
    ("add", "xx", "61"),
   ("add", "75", "yy")
], ids = [
   'Invalid operation type',
   'First operand invalid',
   'Second operand invalid',
])
@mock.patch('builtins.input')
def test_get_input_invalid(mock_input, operation_in, a_in, b_in):
   mock_input.side_effect = [operation_in, a_in, b_in]
   with pytest.raises(SyntaxWarning):
      operation_out, a_out, b_out = get_input()

Lastly, we want to test write_result_to_file, but we will break the dependency on the file system so we do not have to manually inspect an output file to verify the code works correctly. File operations are a bit special, in that they involve an open, write, and a close call. Mock actually provides a built in function called mock_open, which adds a bunch of hooks to create a proper mock for file operations. You can supply this replacement by using the new_callable option in the mock.patch decorator.

@mock.patch('builtins.open', new_callable=mock.mock_open())
@mock.patch('builtins.input')
def test_write_to_file_success(mock_input, mock_fileio):
mock_input.return_value = "foo.txt"

write_result_to_file(42)

mock_fileio.assert_called_once_with("foo.txt", 'w+')
mock_fileio().write.assert_called_once_with(42)
mock_fileio().close.assert_called_once()

Are we missing any tests for file operations? We certainly are! There are a bunch of possible exceptions that we have not tested for. The open function can throw a variety of OSError exceptions, so it would be a good idea to checkthat our design can handle them. How do we specify our mock should throw an exception? Using the side_effect attribute.

If our attempt to open a file throws an exception, we might gracefully catch the exception and simply print the result to the console instead. Thus, our test would expect the following:

@mock.patch('builtins.print')
@mock.patch('builtins.open', side_effect=OSError())
@mock.patch('builtins.input')
def test_write_to_file_fail(mock_input, mock_fileio, mock_print):
    mock_input.return_value = "foo.txt"

   write_result_to_file(42)

   mock_fileio.assert_called_once_with("foo.txt", 'w+')
   mock_print.assert_called_once_with(42)

We already know our code will fail, as we have not written it to handle file exceptions. But don’t worry, this is actually a core tenet of test driven development (TDD):

Tip

Principles of Test Driven Development

  1. Write a failing test first.
  2. Write only enough code for the test to pass.
  3. Repeat

Now that we have written our failing test, we go back and update just enough code to make it pass:

def write_result_to_file(result):
   filename = input("Enter a filename to write the result to: ")
   try:
      f = open(filename, 'w+')
      f.write(result)
      f.close()
   except OSError:
      print(result)

After this update, our new test passes. From here, we write the next failing test, and so on.