pythonpytestpython-decoratorssanic

Unit test a listener in Sanic without starting the app server


Assuming I have this listener defined in my Sanic app:

@app.before_server_start
async def db_setup(*args):
    # ... set up the DB as I wish for the app

If I want to unit test this function (with pytest) and I import it in a unit test file with from my.app import db_setup, it seems the test actually starts serving the app, as pytest outputs:

[INFO] Goin' Fast @ http://0.0.0.0:8000
[INFO] Starting worker [485]

Now, I know that I can remove the effects of the decorator by doing db_setup = db_setup.__wrapped__, but in order to do this I actually need to import db_setup, which is where the Sanic server fires up.

Is there a way of removing the effects of the decorator at import time?

LE: I've tried patching the Sanic app as follows:

async def test_stuff(mocker):
    mocker.patch('myapp.app.app')  # last '.app' being `app = Sanic('MyApp')`
    imp = importlib.import_module('myapp.app')
    db_setup = getattr(imp, 'db_setup')
    await db_setup()

but now I get a RuntimeError: Cannot run the event loop while another loop is running for the mocker.patch('myapp.app.app') line.


Solution

  • I am going to make a few assumptions here, so I may need to amend this answer if there are some clarifications.

    Before starting, it should be noted that the decorator itself will not start your web server. That will run in one of two scenarios:

    1. You are running app.run() somewhere in the global scope
    2. You are using the Sanic TestClient, which specifically operates by running your application's web server

    Now, from what I can understand, you are trying to run db_setup in a test manually by calling it as a function, but you do not want it to attach as a listener to the application in your tests.

    You can get access to all of your application instance's listeners in the app.listeners property. Therefore one solution would be something like this:

    # conftest.py
    import pytest
    from some.place import app as myapp
    
    @pytest.fixture
    def app():
        myapp.listeners = {}
        return myapp
    

    Like I said earlier, this will just empty out your listeners. It does not actually impact your application starting, so I am not sure it has the utility that you are looking for.


    You should be able to have something like this:

    from unittest.mock import Mock
    
    import pytest
    from sanic import Request, Sanic, json
    
    app = Sanic(__name__)
    
    
    @app.get("/")
    async def handler(request: Request):
        return json({"foo": "bar"})
    
    
    @app.before_server_start
    async def db_setup(app, _):
        app.ctx.db = 999
    
    
    @pytest.mark.asyncio
    async def test_sample():
        await db_setup(app, Mock())
    
        assert app.ctx.db == 999
    

    For the sake of ease, it is all in the same scope, but even if the test functions, the application instance, and the listener are spread across different modules, the end result is the same: You can run db_setup as any other function and it does not matter if it is registered as a listener or not.