Writing tests with blacksmith¶
In software development, the testability of the code is the key of software quality.
Using blacksmith, no mock are necessary to test the code. The correct way of
testing call is to implement an AbstractTransport (
blacksmith.AsyncAbstractTransport
or blacksmith.SyncAbstractTransport
)
with response the blacksmith.HTTPResponse
.
Implement a FakeTransport¶
The fake transport implementation has to be passed to the ClientFactory
at the instantiation.
Using pytests the fake transport responses can be parametrized.
Example of fake transport:
from blacksmith import AsyncAbstractTransport, HTTPRequest, HTTPResponse, HTTPTimeout
class FakeTransport(AsyncAbstractTransport):
def __init__(self, responses: dict[str, HTTPResponse]):
super().__init__()
self.responses = responses
async def __call__(
self,
req: HTTPRequest,
client_name: str,
path: str,
timeout: HTTPTimeout,
) -> HTTPResponse:
"""This is the next function of the middleware."""
return self.responses[f"{req.method} {req.url}"]
Then, you need a dependency injection to properly initialize the client.
In the example below, we are injecting the transport in a FastAPI service.
from collections.abc import Mapping
from typing import Any
from fastapi import Depends
from blacksmith import AsyncClientFactory, AsyncRouterDiscovery
class AppConfig:
def __init__(self, settings: Mapping[str, Any]):
transport = settings.get("transport")
sd = AsyncRouterDiscovery(
settings["service_url_fmt"],
settings["unversioned_service_url_fmt"],
)
self.get_client = AsyncClientFactory(sd=sd, transport=transport)
class FastConfig:
config: AppConfig
depends = Depends(lambda: FastConfig.config)
@classmethod
def configure(cls, settings: Mapping[str, Any]) -> None:
cls.config = AppConfig(settings)
We can see that if a transport is passed in the setting, then it will be used by the get_client method.
Now that we can bootstrap an application, then we can write the tests.
import json
from textwrap import dedent
from typing import Any
import pytest
from fastapi.testclient import TestClient
from blacksmith.domain.model.http import HTTPResponse
@pytest.mark.parametrize(
"params",
[
{
"request": {"username": "naruto", "message": "Datte Bayo"},
"blacksmith_responses": {
"GET http://user.v1/users/naruto": HTTPResponse(
200,
{},
{
"email": "naruto@konoha.city",
"firstname": "Naruto",
"lastname": "Uzumaki",
},
)
},
"expected_response": {"detail": "naruto@konoha.city accepted"},
"expected_messages": [
dedent(
"""
Subject: notification
From: notification@localhost
To: "Naruto Uzumaki" <naruto@konoha.city>
Datte Bayo
"""
).lstrip()
],
}
],
)
def test_notif(params: dict[str, Any], client: TestClient, mboxes: list[str]):
resp = client.post("/v1/notification", json=params["request"])
assert json.loads(resp.content) == params["expected_response"]
assert mboxes == params["expected_messages"]
Then we can write the view that implement the notification sent.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from notif.config import AppConfig, FastConfig
from notif.resources.user import User
fastapi = FastAPI()
@fastapi.api_route("/v1/notification", methods=["POST"])
async def post_notif(
request: Request,
app: AppConfig = FastConfig.depends,
):
body = await request.json()
api_user = await app.get_client("api_user")
user: User = (await api_user.users.get({"username": body["username"]})).unwrap()
await app.send_email(user, body["message"])
return JSONResponse({"detail": f"{user.email} accepted"}, status_code=202)
The object AppConfig
is retrieved by FastAPI using its dependency
injection, and the concigured get_client
can be consumed directly.
Now to finalize our conftest.py
, some fixture must be written to.
...
from notif.views import fastapi
@pytest.fixture
def settings():
return {
"service_url_fmt": "http://{service}.{version}",
"unversioned_service_url_fmt": "http://{service}",
}
@pytest.fixture
def configure_dependency_injection(params, settings):
settings["transport"] = FakeTransport(params["blacksmith_responses"])
FastConfig.configure(settings)
@pytest.fixture
def client(configure_dependency_injection):
client = TestClient(fastapi)
yield client
Note
To finalize, we need to start the service for real, so we create
an entrypoint.py
file that will configure and serve the service.
Here is an example with hypercorn.
Note that the configure
method is a couroutine in this example,
but it was a simple method before, to simplify the example.
import asyncio
from hypercorn.asyncio import serve
from hypercorn.config import Config
import blacksmith
from notif.config import FastConfig
from notif.views import fastapi
DEFAULT_SETTINGS = {
"service_url_fmt": "http://router/{service}-{version}/{version}",
"unversioned_service_url_fmt": "http://router/{service}",
}
async def main(settings=None):
blacksmith.scan("notif.resources")
config = Config()
config.bind = ["0.0.0.0:8000"]
config.reload = True
await FastConfig.configure(DEFAULT_SETTINGS)
await serve(fastapi, config)
if __name__ == "__main__":
asyncio.run(main())
The full example can be found in the examples directory on github:
https://github.com/mardiros/blacksmith/tree/main/examples/unit_testing/notif
Using whitesmith¶
The whitesmith package generate pytest fixture and handlers with fake implementations, its at an early stage but can be a great way to create api fixtures.
Usage:
# install the deps ( use `pip install whitesmith` if you use pip)
poetry add --group dev whitesmith
poetry run whitesmith generate -m my_package.resources --out-dir tests/
This command generates a folder tests/whitesmith
with a conftest.py
and a tests/whitesmith/handlers
containing fake api routes implemented
but that should be overriden for your needs.
Example:
poetry run whitesmith generate -m tests
Generating mocks from blacksmith registry...
Processing client notif...
Writing tests/whitesmith/handlers/notif.py
Check it out !