“Mocking where it's defined” in python mock?

2019-05-31 07:38发布

问题:

I want to test an evolving SQLite database application, which is in parallel used "productively". In fact I am investigating a pile of large text files by importing them to the database and fiddling around with it. I am used to develop test-driven, and I do not want to drop that for this investigation. But running tests against the "production" database feels somewhat strange. So my objective is to run the tests against a test database (a real SQLite database, not a mock) containing a controlled, but considerable amount of real data showing all kinds of variability I have met during the investigation.

To support this approach, I have a central module myconst.py containing a function returning the name of the database that is used like so:

import myconst
conn = sqlite3.connect(myconst.get_db_path())

Now in the unittest TestCases, I thought about mocking like so:

@patch("myconst.get_db_name", return_value="../test.sqlite")
def test_this_and_that(self, mo):
    ...

where the test calls functions that will, in nested functions, access the database using myconst.get_db_path().

I have tried to do a little mocking for myself first, but it tends to be clumsy and error prone so I decided to dive into the python mock module as shown before.

Unfortunately, I found warnings all over, that I am supposed to "mock where it's used and not where it's defined" like so:

@patch("mymodule.myconst.get_db_name", return_value="../test.sqlite")
def test_this_and_that(self, mo):
    self.assertEqual(mymodule.func_to_be_tested(), 1)

But mymodule will likely not call database functions itself but delegate that to another module. This in turn would imply that my unit tests have to know the call tree where the database is actually access – something I really want to avoid because it would lead to unnecessary test refactoring when the code is refactored.

So I tried to create a minimal example to understand the behavior of mock and where it fails to allow me to mock "at the source". Because a multi module setup is clumsy here, I have provided the original code also on github for everybody's convenience. See this:

myconst.py
----------
# global definition of the database name
def get_db_name():
    return "../production.sqlite"
# this will replace get_db_name()
TEST_VALUE = "../test.sqlite"
def fun():
    return TEST_VALUE

inner.py
--------
import myconst
def functio():
    return myconst.get_db_name()
print "inner:", functio()

test_inner.py
-------------
from mock import patch
import unittest
import myconst, inner
class Tests(unittest.TestCase):
    @patch("inner.myconst.get_db_name", side_effect=myconst.fun)
    def test_inner(self, mo):
        """mocking where used"""
        self.assertEqual(inner.functio(), myconst.TEST_VALUE)
        self.assertTrue(mo.called)

outer.py
--------
import inner
def functio():
    return inner.functio()
print "outer:", functio()

test_outer.py
-------------
from mock import patch
import unittest
import myconst, outer
class Tests(unittest.TestCase):
    @patch("myconst.get_db_name", side_effect=myconst.fun)
    def test_outer(self, mo):
        """mocking where it comes from"""
        self.assertEqual(outer.functio(), myconst.TEST_VALUE)
        self.assertTrue(mo.called)

unittests.py
------------
"""Deeply mocking a database name..."""
import unittest
print(__doc__)
suite = unittest.TestLoader().discover('.', pattern='test_*.py')
unittest.TextTestRunner(verbosity=2).run(suite)

test_inner.py works like the sources linked above say, and so I was expecting it to pass. test_outer.py should fail when I understand the caveats right. But all the tests pass without complaint! So my mock is drawn all the time, even when the mocked function is called from down the callstack like in test_outer.py. From that example I would conclude that my approach is safe, but on the other hand the warnings are consistent across quite some sources and I do not want to recklessly risk my "production" database by using concepts that I do not grok.

So my question is: Do I misunderstand the warnings or are these warnings just over-cautious?

回答1:

Finally I sorted it out. Maybe this will help future visitors, so I will share my findings:

When changing the code like so:

inner.py
--------
from myconst import get_db_name
def functio():
    return get_db_name()

test_inner.py
-------------
@patch("inner.get_db_name", side_effect=myconst.fun)
def test_inner(self, mo):
    self.assertEqual(inner.functio(), myconst.TEST_VALUE)

test_inner will succeed, but test_outer will break with

AssertionError: '../production.sqlite' != '../test.sqlite'

This is because mock.patch will not replace the referenced object, which is function get_db_name in module myconst in both cases. mock will instead replace the usages of the name "myconst.get_db_name" by the Mock object passed as the second parameter to the test.

test_outer.py
-------------
@patch("myconst.get_db_name", side_effect=myconst.fun)
def test_outer(self, mo):
    self.assertEqual(outer.functio(), myconst.TEST_VALUE)

Since I mock only "myconst.getdb_name" here and inner.py accesses get_db_name via "inner.get_db_name", the test will fail.

By using the proper name, however, this can be fixed:

@patch("outer.inner.get_db_name", return_value=myconst.TEST_VALUE)
def test_outer(self, mo):
    self.assertEqual(outer.functio(), myconst.TEST_VALUE)

So the conclusion is that my approach will be safe when I make sure that all modules accessing the database include myconst and use myconst.get_db_name. Alternatively all modules could from myconst import get_db_name and use get_db_name. But I have to draw this decision globally.

Because I control all code accessing get_db_name I am safe. One can argue whether this is good style or not (assumingly the latter), but technically it's safe. Would I mock a library function instead, I could hardly control access to that function and so mocking "where it's defined" becomes risky. This is why the sources cited are warning.