Skip to content

Live gRPC Server Test Fixture

This was my equivalent of the Django Live Server Test Case but for Python gRPC services.

The only part I am currently unhappy with is the way in which the fixture identifies the functions it needs to monkeypatch. The identification itself might not be needed at all as the currently generated gRPC servicer classes only expose the GRPC functions so you could just monkeypatch them all.

from concurrent import futures
from dataclasses import dataclass

import grpc
import pytest

from rpc.conf import get_config
from rpc.handlers import RPCHandler
from rpc.test import as_test_handler


@dataclass
class TestingServer:
    server: grpc.Server
    channel: grpc.Channel
    port: int


@pytest.fixture
def live_grpc_server(monkeypatch):
    config = get_config()
    with futures.ThreadPoolExecutor(max_workers=1) as executor:
        server = grpc.server(executor)
        for servicer_config in config.service_configs:
            servicer = servicer_config.servicer
            for name, func in servicer.__dict__.items():
                if hasattr(func, "__wrapped__") and issubclass(func.__wrapped__, RPCHandler):
                    monkeypatch.setattr(servicer, name, as_test_handler(func.__wrapped__))
            servicer_config.add_to_server(servicer, server)
        port = server.add_insecure_port("127.0.0.1:0")
        server.start()
        try:
            with grpc.insecure_channel(f"localhost:{port}") as channel:
                yield TestingServer(server=server, channel=channel, port=port)
        finally:
            server.stop(None)

This could then be used as follows:

@pytest.mark.django_db
def test_live_server_cannot_authenticate_with_a_made_up_api_key(live_grpc_server):
    client = user_auth_grpc.UserAuthenticationStub(live_grpc_server.channel)
    request = user_authentication.AuthenticateAPIKeyUserRequest(
        api_key=user_types.APIKey(value="not on your nelly"),
        device_profile=user_types.DeviceProfile(
            device_id="137c4fa9-82a0-4360-b651-ba1e5b5a63c0",
            display_name="the unlikely visitor"
        ),
    )

    with pytest.raises(grpc.RpcError) as execinfo:
        client.AuthenticateAPIKeyUser(request)
    assert execinfo.value.code() == grpc.StatusCode.UNAUTHENTICATED

To me this approach felt like idiomatic Django testing (with pytest), but was felt to be a little too magic.

In this instance my understand is that magic refers to the manner in which the fixture hides away much of the details of the gRPC API itself (not our api, that of the gRPC library). Creating a client and a server and connecting them together is a fairly complicated exercise and by moving it all behind a fixture denies the person writing the test the opportunity to really see how it all does fit together.

Whilst I do believe I understand the point, I don't think I agree in this case that exposing more of the underlying plumbing is a desired thing.

Exposing how one creates a gRPC server with the grpc library directly is not all that helpful as it is already hidden away within the rpc package in mos-sdk-py for the simple reason that it is non-trivial and easy to get wrong. There is no need to re-invent the wheel every time you need a gRPC server.

Yes, having the fixture return the instance of TestingServer is an option. It would then be the testers responsibility to create the client and connect it to the live testing server. This is something a consuming developer is likely to need to do many times and is not currently abstracted away in any manner (perhaps it should be with a client registry in the rpc package?). So I can see some value in exposing it. It just feels less ergonomic for the consumer. Perhaps a compromise? The TestingServer is what is yielded but a second fixture, or even just a regular function could be used to create the client. Still feels a little unnecessary.