Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unit Testing Flask Route Handlers #7

Closed
snewell92 opened this issue Nov 22, 2023 · 4 comments
Closed

Unit Testing Flask Route Handlers #7

snewell92 opened this issue Nov 22, 2023 · 4 comments
Labels
question Further information is requested

Comments

@snewell92
Copy link

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 maldoinc added the question Further information is requested label Nov 22, 2023
@maldoinc
Copy link
Owner

maldoinc commented Nov 22, 2023

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
Copy link
Author

snewell92 commented Nov 29, 2023

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
Copy link
Author

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
Copy link
Owner

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants