0

I am trying to figure out how to use the @patch.object to mock a __init__ and, log.write() for the Logger class, which is imported but isn't used inside the function of a module. The tuorial such as this, https://www.pythontutorial.net/python-unit-testing/python-patch/ , points to the patching needs to be at the target where it is used, not where it comes from. However, every example used shows the target to be mocked inside another function.

In the use case mentioned below, the logger is imported and used to write the log outside the scope of a function. Is there a way to mock the behavior both in main.py and routers.py?

src/apis/main.py

from utils.log import Logger
from routes import route

log = Logger(name="logger-1")
log.write("logger started")

def main():
    log = Logger(name="logger-1")
    log.write("inside main")
    route()

if __name__ == "__main__":
    import logging
    logging.basicConfig(level=logging.INFO)  # for demo
    main()

In src/apis/routers/routes.py

from utils.log import Logger
log = Logger(name="logger-1")
log.write, message=f"Inside route")
def route():
    log.write, message=f"Logging done.")

In utils/log/logging.py

import logging
Class Logger:
     def __init__(self, name):
          # needs to be mocked

     def write(self, message):
          # needs to be mocked to return None
Amogh Mishra
  • 1,088
  • 1
  • 16
  • 25
  • 1
    The problem is that the code is already executed at the time `main` is imported, also if you try to mock it. The usual practice to avoid this (and other problems with that) is to put that initialization code under the `if __name__ == "__main__:"` condition. – MrBean Bremen Dec 06 '22 at 07:16
  • 1
    If you think you need to mock `__init__`, then `__init__` is probably doing too much work, or isn't parameterized enough. – chepner Dec 06 '22 at 17:16
  • @chepner, yes. the `__init__` needs to be mocked. – Amogh Mishra Dec 06 '22 at 17:21
  • I'm saying `Logger.__init__` (and the class `Logger` as a whole) should be refactored so that you do *not* need to mock `__init__`, and can instead simply create a `Logger` instance suitable for testing. – chepner Dec 06 '22 at 17:34
  • What does `Logger.write` *normally* return, and as something that I imagine just writes its message to some file, why would that be anything *other* than `None` in the first place? – chepner Dec 06 '22 at 17:35
  • Even if I do that, I still have to mock the `write()` which is still getting executed in the `routes.py` – Amogh Mishra Dec 06 '22 at 20:27

1 Answers1

1

When asking a question, it is very convenient to offer a Minimal Reproducible Example. So remove the unnecessary fastapi and starlette, and provide the code for the test you are trying to write.

Here it is :

# file: so74695297_main.py
from so74695297_log import Logger
from so74695297_routes import my_route

log = Logger(name="logger-1")
log.write("logger started")


def main():  # FAKE IMPL
    log.write(message=f"in main()")
    my_route()


if __name__ == "__main__":
    import logging
    logging.basicConfig(level=logging.INFO)  # for demo
    main()
# file: so74695297_routes.py
from so74695297_log import Logger

log = Logger(name="logger-1")


def my_route():  # FAKE IMPL
    log.write(message=f"route")
# file: so74695297_log.py
import logging


class Logger:
    def __init__(self, name):
        self._logger = logging.getLogger(name)  # FAKE IMPL

    def write(self, message):
        self._logger.info(message)  # FAKE IMPL

when ran (the main.py file does something) :

INFO:logger-1:in main()
INFO:logger-1:route

Which is the expected output when the loggers don't fave any formatter.

Then adding a test :

# file: so74695297_test.py
import unittest
import unittest.mock as mock

from so74695297_routes import my_route


class TestMyRoute(unittest.TestCase):
    def test__my_route_write_a_log(self):
        spy_logger = mock.Mock()
        with mock.patch("so74695297_log.Logger", new=spy_logger):
            my_route()
        assert spy_logger.assert_called()


if __name__ == "__main__":
    unittest.main()  # for demo
Ran 1 test in 0.010s

FAILED (failures=1)

Failure
Traceback (most recent call last):
  File "/home/stack_overflow/so74695297_test.py", line 12, in test__my_route_write_a_log
    assert spy_logger.assert_called()
  File "/usr/lib/python3.8/unittest/mock.py", line 882, in assert_called
    raise AssertionError(msg)
AssertionError: Expected 'mock' to have been called.

Now we have something to work with !

As @MrBeanBremen indicated, the fact that your logger is configured at import time (even when not being the "main" module) complicates things.

The problem is that, by the time the mock.patch line runs, the modules have already been imported and created their Logger. What we could do instead is mock the Logger.write method :

    def test__my_route_writes_a_log(self):
        with mock.patch("so74695297_log.Logger.write") as spy__Logger_write:
            my_route()
        spy__Logger_write.assert_called_once_with(message="route")
Ran 1 test in 0.001s

OK

If you prefer using the decorator form :

    @mock.patch("so74695297_log.Logger.write")
    def test__my_route_writes_a_log(self, spy__Logger_write):
        my_route()
        spy__Logger_write.assert_called_once_with(message="route")

Because we mocked the class's method, each Logger instance has a mock version of write :

#                           vvvv
    @mock.patch("so74695297_main.Logger.write")
    def test__main_writes_a_log(self, spy__Logger_write):
        main()
        # assert False, spy__Logger_write.mock_calls
        spy__Logger_write.assert_any_call(message="in main()")

In the end, main.Logger.write is essentially the same thing as routes.Logger.write and as log.Logger.write, just a reference to the same "method" object. Mock from one way, mock for all the others too.

Lenormju
  • 4,078
  • 2
  • 8
  • 22
  • You are right. I made the edits to make it more understandable. Thanks for your response. However, is there a way to mock the `__init__` and `write` both ? – Amogh Mishra Dec 06 '22 at 17:23
  • Updated my question. The `write()` will still get executed in the `routes.py` because it is outside the scope of the function. – Amogh Mishra Dec 06 '22 at 20:28
  • What is important is "what gets mocked, and when". Here it was simple to mock (to spy on) the calls when the route gets called, because the test triggers them. But it would be much harder to mock the log `logger started` because it is done at import time (before the test function runs). You could also mock the `__init__` function, but that would be difficult because it is called when the loggers are instantiated, which is in the code you showed done at import time. If you want to do so, consider posting another question :) – Lenormju Dec 07 '22 at 06:53