0

I have a class C which is used by X, for which I will write unit tests.

class C(metaclass=Singleton):  # To be mocked
    def __init__(self, a, b):
        """..."""
    def f(self, x):
        return ...

class X:  # to be tested
    def __init__(self, c: C=C(...)):  # do I need parameter c for testability?
        self.c = c

x = X()  # self.c will be ready

To keep the same testability, do I need constructor parameter c: C=C(...)? How about just

class X:  # to be tested
    def __init__(self):  # No parameter c
        self.c = C(...)

    def g(self):
        x = self.c.f(...)

And use some approaches to "patch" C.f()? How are the difference of test code looks? I will use pytest.

ca9163d9
  • 27,283
  • 64
  • 210
  • 413

1 Answers1

1

Beware, instantiating an object as default value for a parameter will share the same object between deifferent calls (see this question).

As for your question, if you really to want the call to X the same, as in x = X() # self.c will be ready then you will need to patch the Config reference that your X.__init__ uses.
If you relax this constraint, meaning that you accept to do x = X(c=Config()) instead, it will be very easy to provide the "test double" of your choice (typically a "mock"), and ease testing. It is called dependency injection and facilitating testing is one of its main advantages.

EDIT: as you need to mock C.f, here is an example :

class C:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def f(self, z):  # <-- renamed to `z` to avoid confusion with the `x` instance of class `X`
        print(f"method F called with {z=}, {self.a=} and {self.b=}")
        return z + self.a + self.b


class X:
    def __init__(self, c: C = C(a=1, b=2)):
        self.c = c


# /!\ to do some mocking, I had to `pip install pytest_mock` (version 3.6.1) as `pytest` provides no mocking support
def test_my_class_X_with_C_mock(mocker):  # the `mocker` parameter is a fixture, it is automatically provided by the pytest runner
    x = X()  # create the X as always

    assert x.c.f(z=3) == 6  # prints "method F called with z=3, self.a=1 and self.b=2"

    def my_fake_function_f(z):  # create a function to call instead of the original `C.f` (optional)
        print(f"mock intercepted call, {z=}")
        return 99
    my_mock = mocker.Mock(  # define a mock
        **{"f.side_effect": my_fake_function_f})  # which has an `f`, which calls the fake function when called (side effect)
    mocker.patch.object(target=x, attribute="c", new=my_mock)  # patch : replace `x.c` with `my_mock` !

    assert x.c.f(z=3) == 99  # prints "mock intercepted call, z=3"


def main():
    # do the regular thing
    x = X()
    print(x.c.f(z=3))  # prints "method F called with z=3, self.a=1 and self.b=2" and the result "6"

    # then do the tests
    import pytest
    pytest.main(["-rP", __file__])  # run the test, similar to the shell command `pytest <filename.py>`, and with `-rP` show the print
    # it passes with no error !


if __name__ == "__main__":
    main()

Because C already exists (it is in the same file) in your example, it was not possible to simply mock.patch, it was required to mock.patch.object.
Also, it is possible to write simpler mocks, or just let patch create a plain Mock, but for the example I preferred to be explicit.

Lenormju
  • 4,078
  • 2
  • 8
  • 22
  • Sorry, the question was updated. `Config` should be `C` in the question. I added a function `C.f()`. The test code will need to mock `C.f()`. – ca9163d9 Jan 13 '22 at 15:00
  • @ca9163d9 I updated my answer – Lenormju Jan 13 '22 at 16:17
  • @ca9163d9 if it answers your updated question, then don't forget to mark my answer as "accepted" ;-) – Lenormju Jan 13 '22 at 16:27
  • I actually want to test `X` instead of `C`. `C` should be a mock object. And I want to write the code without using constructor parameter (simpler?) if it doesn't affect any testability. – ca9163d9 Jan 13 '22 at 16:41
  • @ca9163d9 look at the example I wrote : `x.c` is mocked, so when you do something with `x` in your tests you are using the mock, not the actual `C` class. You can see that in the `test_my_class` function, in which the first assert uses the actual C (for example purpose) then mock it, and the second assert uses the mock. This way, you can test your `X` class being assured that no `C` method will get called. – Lenormju Jan 13 '22 at 17:05
  • @ca9163d9 if what you want is to completely mock away the `C` class, it is not possible in a simple way according to the example you wrote. Either you don't set a default value in `X.__init__` (and do **dependency injection**), either you can put `X` and `C` in different files, so that your test can `mock.patch` the `import C` from the `X` definition file, so that it uses a mock `C`class. The latter is less robust and less clear. – Lenormju Jan 13 '22 at 17:10
  • It's good just mock `C.f()`. Let it return a particular value given some parameters. – ca9163d9 Jan 13 '22 at 18:07
  • @ca9163d9 so did it solve your problem ? If so, mark the answer as "accepted" :) – Lenormju Jan 18 '22 at 14:07
  • 1
    It's marked as accepted a few days ago. Thanks. – ca9163d9 Jan 18 '22 at 21:23