I am trying to write some FastAPI tests.
To be more specific, I am trying to write tests which tests the FastAPI endpoints which I have defined. (In the FastAPI docs endpoints are refered to as “paths”.)
The endpoints which I have defined call an object. This object (“service”) maintains, in memory, all data related to the application.
This design imposes some requirements. Either threads must not be used or some mechanism such as locks must be used to enforce mutual exclusion. I opted for the former, to make things simple:
- The “service” must be accessed synchronously, which implies the endpoints must be
async def
. This ensures that a thread pool is not used. await
is not called anywhere. This has the effect of causing eachasync
endpoint to behave in a synchronous manner.
I have written a MWE for demonstration purposes. Here is the file containing my “FastAPI related code” including definitions of the endpoints:
from fastapi import FastAPI
from fastapi import Request
from contextlib import asynccontextmanager
from implementation import Implementation
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncClient(app=app) as client:
implementation = Implementation()
yield {'implementation': implementation}
implementation.shutdown() # MUST be called!
app = FastAPI(lifespan=lifespan)
@app.post('/api/increment')
async def api_increment(
request: Request,
):
implementation: Implementation = request.state.implementation
implementation.increment()
return {}
@app.get('/api/get_value')
async def api_get_value(
request: Request,
):
implementation: Implementation = request.state.implementation
value = implementation.get_value()
return {
'value': value,
}
This conforms to the requirements I gave details of above. You can see that I have used the “lifespan” concept to ensure implementation
persists for the lifespan of the application.
Implementation
represents the “service”. There are two implementations of Implementation
, a “real” one and a “fake” one which should be used when running the tests. (I have not yet built a full strategy pattern to present a single interface for both types, but this could be done very easily.)
Here is the “real” implementation of the “service”:
class Implementation():
def __init__(self) -> None:
self.current_value = 0
self._initialized = True
print(f'Implementation starts')
def shutdown(self) -> None:
'''
A function which must be called to cleanup resources before exit
'''
self._initialized = False
print(f'Implementation stops')
def increment(self) -> None:
'''
In reality this would do something like read/write to a file, db etc
'''
self.current_value += 1
def get_value(self) -> None:
return self.current_value
And here is the “fake” implementation, which should be used in tests:
class ImplementationFake():
def __init__(self) -> None:
print(f'Implementation starts')
def shutdown(self) -> None:
'''
A function which must be called to cleanup resources before exit
(at least if the implementation being used is the real implementation
- the fake implementation probably wouldn't do anything here)
'''
print(f'Implementation stops')
def increment(self) -> None:
pass
def get_value(self) -> None:
return 42
Finally, here is the file containing my test code:
import pytest
from fastapi.testclient import TestClient
from fastapi_webserver import app
def test_get_value():
with TestClient(app) as client:
response = client.get('/api/get_value')
assert response.status_code == 200
assert response.json() == {
#'value': 0, # <- using REAL implementation
'value': 42, # <- using FAKE implementation
}
This test fails because the value 0
is returned by the “real” implementation. This would be fine for running in production, but not the expected behaviour when running the “service” from within a test code. The expected behaviour is the “fake” implementation returns the hardcoded value 42
.
I understand that there are two relevant “ideas” relating to this question.
- The concept of lifespan contexts
- The concept of dependencies
My object must exist for the lifespan of the FastAPI application. This seems to suggest it has to be loaded using a lifespan context.
However, if I understand correctly, dependencies are usually expected to be handled with the Dependency
concept. The Dependency
concept can be used to switch between a real and fake implementation of some object for testing purposes.
I do not understand how to combine these two idea. Could someone explain how to do so?