maldoinc / wireup

Performant, concise, and easy-to-use dependency injection container for Python 3.8+.
https://maldoinc.github.io/wireup/
MIT License
97 stars 2 forks source link

Unit Testing Flask Route Handlers #7

Closed snewell92 closed 11 months ago

snewell92 commented 11 months ago

Hiya! Greatly enjoying this lib; we are exploring DI in one of our services and I've got a PR to add wireup and just use the singleton; the only issue the team has run into is we're not able to inject a mock into the flask route handler. Normally, like with services, we just directly use the target object and instantiate it in the unit test with mocks, however this won't work for flask endpoints. Consider the following code

@app.route("/fake/route/<route_param_one>")
@container.autowire
def import_setup(
    route_param_one: str, greeter: GreeterService
) -> Response:
    name = request.args.get("name")
    greeter.greet(name, route_param_one)

    return make_response("OK")

And this unit test with flask's test app mechanism

class TestImportSetup(TestCase):
    def setUp(self) -> None:
        self.app = app.test_client()

    def test_missing_vendor(self) -> None:
        result = self.app.get("/fake/route/fakedata?name=Cody")

        # How to provide a fake GreeterService if app entry point has already wired it up?
        self.assertEqual(200, result.status_code)
        self.assertEqual("Ok", result.text)

I've tried setting up multiple containers to have an empty test one and a normal prod one but it has proven... quite difficult to use.

Could we perhaps have a context manager style solution?

class TestImportSetup(TestCase):
    def setUp(self) -> None:
        self.app = app.test_client()
        self.mock_greeter = Mock()

    def test_missing_vendor(self) -> None:
        with container.override_inject(GreeterService, self.mock_greeter):
            result = self.app.get("/fake/route/fakedata?name=Cody")

            self.mock_greeter.assert_called_once_with("Cody", "fakedata")
            self.assertEqual(200, result.status_code)
            self.assertEqual("Ok", result.text)
maldoinc commented 11 months ago

Hi, thanks for the nice words.

I do agree that at the moment testing autowire targets can be a bit difficult. The idea of temporarily overriding dependencies with custom implementations also sounds good.

When autowiring, the container always refers to __initialized_objects before doing anything else, to see if an instance already exists. We can take advantage of this to trick it into using the mock.

I hope this helps resolving your issue until the feature is (hopefully soon) supported!

Example:

# Set up: our service and a view
@container.register
class GreeterService:
    def greet(self, name) -> str:
        return f"Hi {name}"

@app.get("/greet")
@container.autowire
def home(greeter: GreeterService) -> str:
    name = request.args.get("name")

    return greeter.greet(name)

# In your tests
class GreetViewTest(unittest.TestCase):
    def setUp(self) -> None:
        self.client = create_app().test_client()

    def test_mock_autowired(self):
        res = self.client.get("/greet?name=wireup")
        self.assertEqual("Hi wireup", res.text)

        # Create a mock for the "greet" method.
        mock = MagicMock()
        mock.greet = MagicMock(return_value="It's mocked!")
        # Set the initialized object to the one you want to override
        # The key is a tuple consisting of the class and the qualifier.
        # which is by default to None unless you set something using
        # qualifier="..." during registration.
        container._DependencyContainer__initialized_objects[(GreeterService, None)] = mock

        res = self.client.get("/greet?name=wireup")
        self.assertEqual("It's mocked!", res.text)

We can also create a context manager for this. This is something hacky I threw together you can use to unblock yourself:

class ContainerOverride:
    def __init__(
            self,
            dependency_container: DependencyContainer,
            override: type,
            new_value: Any,
            qualifier: ContainerProxyQualifierValue = None,
    ):
        self.container = dependency_container
        self.override = override
        self.new_value = new_value
        self.qualifier = qualifier
        self.existing_instance = dependency_container._DependencyContainer__initialized_objects.get((override, qualifier))  # noqa

    def set_value(self, value: Any) -> None:
        self.container._DependencyContainer__initialized_objects[(self.override, self.qualifier)] = value  # noqa

    def __enter__(self):
        self.set_value(self.new_value)

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.existing_instance:
            self.set_value(self.existing_instance)
        else:
            del self.container._DependencyContainer__initialized_objects[(self.override, self.qualifier)]

        return True

Which we can use as follows

with ContainerOverride(container, override=GreeterService, new_value=greeter_mock):
    res = self.client.get("/greet?name=wireup")
    self.assertEqual("It's mocked!", res.text)

Adjust as necessary to be able to mock multiple things simoultaneously.

snewell92 commented 11 months ago

This worked perfectly. 💯 Thanks so much! After running with the above hacky solution for a wee while, I'm happy to contribute this back if we tinker with it. I'll put it in a gist so versioning can be seen.

snewell92 commented 11 months ago

This Gist will be where I put any changes to ContainerOverride that I'm able to get working in my project as I tinker with it (we use black/flake8/mypy extensively so it'll be 'squeaky clean' in that regard #soon and I can look at extending it where necessary)

maldoinc commented 10 months ago

13 is a draft pr implementing this. Any feedback from your usage so far is welcome.