1

TL;DR

Can I access the current unittest.TestCase instance from within a method of a mock class without explicitly passing in that instance? If not, what is the proper way to access the assertion helper functions of TestCase (which are instance methods) from there? (Is there a proper way?)

The Szenario: Augmenting MagicMock with my own assertion method that builds upon a TestCase method

I'd like to test that one function (handle_multiple_foobars()) uses another function (handle_one_foobar()) correctly, so I'm mocking handle_one_foobar(). 'Correctly' here means that handle_multiple_foobars() shall call handle_one_foobar() for each of its arguments individually. (One handle_one_foobar() call per handle_multiple_foobars() argument.) I don't care about the order of the calls.

Checking whether all expected calls to the mock have been made

Thus, I've started with this:

import unittest
from unittest import TestCase
from unittest.mock import patch, call

def handle_one_foobar(foobar):
    raise NotImplementedError()

def handle_multiple_foobars(foobars):
    for foobar in reversed(foobars):
        handle_one_foobar(foobar)


class FoobarHandlingTest(TestCase):
    @patch('__main__.handle_one_foobar')
    def test_handle_multiple_foobars_calls_handle_one_foobar_for_each_foobar(
            self, handle_one_foobars_mock):
        foobars = ['foo', 'bar', 'foo', 'baz']

        handle_multiple_foobars(foobars)

        expected_calls = [call(fb) for fb in foobars]
        handle_one_foobars_mock.assert_has_calls(
                expected_calls,
                any_order=True)

if __name__ == '__main__':
    unittest.main()

Checking whether only expected calls to the mock have been made

However, this would also pass if there were more calls to the mocked function, e.g.

def handle_multiple_foobars(foobars):
    handle_one_foobar('begin')
    for foobar in reversed(foobars):
        handle_one_foobar(foobar)
    handle_one_foobar('end')

I don't want that.

I could easily write an additional assertion (or an additional test) to check for the total count of calls. But I'd like this to be treated as a single condition to test. So I've constructed a different assertion:

class FoobarHandlingTest(TestCase):
    @patch('__main__.handle_one_foobar')
    def test_handle_multiple_foobars_calls_handle_one_foobar_for_each_foobar(
            self, handle_one_foobar_mock):
        foobars = ['foo', 'bar', 'foo', 'baz']

        handle_multiple_foobars(foobars)

        expected_calls = [call(fb) for fb in foobars]
        self.assertCountEqual(
                handle_one_foobar_mock.mock_calls,
                expected_calls)

This would catch the additional calls nicely:

F
======================================================================
FAIL: test_handle_multiple_foobars_calls_handle_one_foobar_for_each_foobar (__main__.FoobarHandlingTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/lib/python3.4/unittest/mock.py", line 1136, in patched
    return func(*args, **keywargs)
  File "convenientmock.py", line 23, in test_handle_multiple_foobars_calls_handle_one_foobar_for_each_foobar
    expected_calls)
AssertionError: Element counts were not equal:
First has 1, Second has 0:  call('begin')
First has 1, Second has 0:  call('end')

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

FAILED (failures=1)

Move the new assertion into a helper method of the mock

But for my taste, it's not obvious enough what's being asserted here. Therefore, I decided to extract the assertion into a new function. Because this function serves a very similar purpose as assert_has_calls(), I feel it should be a method of the mock class. It's not hard to extend MagicMock and we can even make the extracted method a bit more generic in allowing to specify whether call order should matter or not:

from unittest.mock import MagicMock

class MyMock(MagicMock):
    def assert_has_exactly_calls(_mock_self, calls, any_order=False):
        tc = TestCase()
        asserter = tc.assertCountEqual if any_order else tc.assertEqual
        asserter(_mock_self.mock_calls, list(calls))

@patch will use this class instead of unittest.mock.MagicMock to create the mock when I change the test method decoration to

    @patch('__main__.handle_one_foobar', new_callable=MyMock)
    def test_handle_multiple_foobars_calls_handle_one_foobar_for_each_foobar( # ...

I can then write my assertion as

        handle_one_foobar_mock.assert_has_exactly_calls(
                expected_calls,
                any_order=True)

... and the ugly

But you might have noticed something very ugly: To be able to use TestCase instance methods assertCountEqual() and assertEqual(), I've created a dummy TestCase instance that isn't the real FoobarHandlingTest instance in which the test runs.

How can I avoid this?

Obviously, I could pass the test's self into the assertion method, but that'd make for a very non-intuitive signature. (Why should I have to tell an assertion about my test case?)

das-g
  • 9,718
  • 4
  • 38
  • 80
  • Why not just add a non-test instance method to `FoobarHandlingTest` that abstracts the code around your assertions? – Brian Cain Oct 24 '15 at 19:21
  • Well, as said: "Because this function serves a very similar purpose as assert_has_calls() [a method of `Mock` and by extension of `MagicMock`], I feel it should be a method of the mock class." thus, **for consistency of interface**. And I really like the signature (calling it on the mock instead of passing in the mock). Also, I might want to use it in other `TestCase`s , too, or even propose it as an addition to `Mock` upstream. – das-g Oct 24 '15 at 19:30
  • It might well be that what I want isn't possible in a non-ugly manner. – das-g Oct 24 '15 at 19:33
  • A workaround would be to replace `TestCase().assertCountEqual` and `TestCase().assertEqual` with [`hamcrest.contains_inanyorder`](https://pyhamcrest.readthedocs.org/en/V1.8.2/sequence_matchers/#module-hamcrest.library.collection.issequence_containinginanyorder) and [`hamcrest.contains`](https://pyhamcrest.readthedocs.org/en/V1.8.2/sequence_matchers/#module-hamcrest.library.collection.issequence_containinginorder) from [`PyHamcrest`](https://pypi.python.org/pypi/PyHamcrest), respectively, which provide similar functionality but are standalone and thus do not depend on a `TestCase` instance. – das-g Nov 13 '15 at 08:47

0 Answers0