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 typing import Dict

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 typing import Any, Mapping

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, Dict, List

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
from notif.config import FastConfig
from notif.views import fastapi

import blacksmith

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/unittesting/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 !