tonic-server-mock 1.0.1

Mock tonic-based gRPC servers that run in memory without networking
Documentation

tonic-server-mock

tonic-server-mock is a utility for quickly creating mock gRPC servers based on tonic for testing and development.

The mock server and the client communicate over an in-memory duplex stream (tokio::io::duplex) instead of a real TCP socket:

  • no ports to allocate — tests never conflict and can run fully in parallel,
  • no networking flakiness, no firewall/CI surprises,
  • the client side is a regular tonic::transport::Channel, so generated tonic clients work unchanged.

Quick Start

Add the dependency to your Cargo.toml:

tonic-server-mock = "1.0"

Example

// Creates a mock server function.
// The first argument is the function name (e.g., mock_pokemon_server).
// Then list the services to be mocked (explicit function arguments names: auth_svc, create_pokemon_svc, pokemon_fight_svc).
// Optionally: After the last semicolon, you can specify the logging namespace (default is ::tracing, can be changed to ::log).
mock_server_fn!(mock_pokemon_server; auth_svc, create_pokemon_svc, pokemon_fight_svc; ::tracing);

#[tokio::test]
async fn test_grpc_server() {
    // ...
    let auth_service = AuthServiceServer::new(AuthServiceImpl::new(Arc::clone(&auth_protocol)));
    let create_pokemon_svc = CreatePokemonServiceServer::new(CreatePokemonServiceImpl::default());
    let pokemon_fight_svc = PokemonFightServiceServer::new(PokemonFightServiceImpl::default());
    // create server mock instance (the generated function is async)
    let (server_mock_future, endpoint_mock) =
        mock_pokemon_server(auth_service, create_pokemon_svc, pokemon_fight_svc).await;
    let cancellation_token = CancellationToken::new();
    tokio::spawn({
        let cancellation_token = cancellation_token.clone();
        cancellation_token.run_until_cancelled(server_mock_future)
    });
    // create tonic channel that can be used by generated clients
    let tokio_channel = endpoint_mock.connect().await.unwrap();
    let mut auth_client = AuthServiceClient::new(tokio_channel.clone());
    let mut create_pokemon_client = CreatePokemonServiceClient::new(tokio_channel.clone());
    let mut pokemon_fight_client = PokemonFightServiceClient::new(tokio_channel);
    // ...
}

Macro options

mock_server_fn! accepts an optional visibility modifier and an optional logging namespace:

// default visibility is pub(crate), default logger is ::tracing
mock_server_fn!(mock_server; my_svc);

// public function, log via the `log` crate instead of `tracing`
mock_server_fn!(pub mock_server; my_svc; ::log);

The service argument names you list become the parameter names of the generated function. Each parameter accepts anything that implements tonic's service contract (impl Svc) — a plain XxxServer::new(...) as well as an interceptor-wrapped one (see below).

Real-world patterns

The patterns below are distilled from production test suites of a multi-service gRPC system that used this crate for its integration tests.

once() vs connect() — one client or many

EndpointMock is cheap to clone and can hand out any number of channels. Each connect() call opens a fresh in-memory connection to the same mock server:

#[tokio::test]
async fn two_clients_one_server() {
    let (server_future, endpoint_mock) = mock_pokemon_server(auth_svc, fight_svc).await;
    tokio::spawn(server_future);

    // single client, consume the mock:
    let channel = endpoint_mock.clone().once().await;

    // or simulate several independent clients / reconnects:
    let first_connection = endpoint_mock.connect().await;
    let second_connection = endpoint_mock.connect().await;
}

This makes it easy to test multi-client scenarios (e.g. two subscribers on the same streaming endpoint) or reconnection logic — every connect() goes through the server's accept path just like a real TCP connection would.

Testing auth end-to-end with interceptors

Because the generated function accepts any tonic service, interceptor-wrapped services work out of the box. A typical challenge/response auth flow — client requests a challenge, signs it, exchanges it for a JWT, then calls a protected service — can be tested against a single mock server hosting both the auth service and the protected service:

mock_server_fn!(mock_auth_server; auth_svc, protected_svc);

#[tokio::test]
async fn should_authorize() {
    let auth_svc = AuthServiceServer::new(AuthServiceImpl::new(Arc::clone(&auth_protocol)));
    // the protected service checks tokens via a server-side interceptor
    let protected_svc = PokemonFightServiceServer::with_interceptor(
        PokemonFightServiceImpl::default(),
        ServerAuthInterceptor::new(Arc::clone(&auth_protocol)),
    );
    let (server_future, endpoint_mock) = mock_auth_server(auth_svc, protected_svc).await;
    tokio::spawn(server_future);

    let channel = endpoint_mock.once().await;

    // 1. authenticate over the plain channel
    let mut auth_client = AuthServiceClient::new(channel.clone());
    let access_token = obtain_token(&mut auth_client).await;

    // 2. call the protected service with a client-side interceptor attaching the token
    let mut client =
        PokemonFightServiceClient::with_interceptor(channel, ClientAuthInterceptor::new(access_token));
    client.fight(FightRequest { /* ... */ }).await.expect("authorized call must succeed");
}

Negative cases (wrong role, expired token, missing header) are tested the same way — the interceptor rejects the call and the client observes a real tonic::Status, exactly as in production.

Injecting the mock into the code under test

Production code usually connects via tonic::transport::Endpoint. To point it at a mock instead, abstract the connection behind a small trait and implement it for both:

pub trait GrpcTryConnect {
    fn try_connect(&self) -> impl Future<Output = Result<Channel, tonic::transport::Error>> + Send;
}

impl GrpcTryConnect for tonic::transport::Endpoint {
    fn try_connect(&self) -> impl Future<Output = Result<Channel, tonic::transport::Error>> + Send {
        tonic::transport::Endpoint::connect(self)
    }
}

// only compiled for tests / the `testing` feature
#[cfg(any(test, feature = "testing"))]
impl GrpcTryConnect for tonic_server_mock::EndpointMock {
    fn try_connect(&self) -> impl Future<Output = Result<Channel, tonic::transport::Error>> + Send {
        futures::FutureExt::map(tonic_server_mock::EndpointMock::connect(self), Ok)
    }
}

Any component written against impl GrpcTryConnect (a client wrapper, a background worker that reconnects on failure, etc.) now runs unmodified against either a real endpoint or a mock server.

Testing a gRPC proxy: chaining mock servers

A proxy service (one that terminates gRPC and forwards calls upstream) is tested by standing up two mock servers: one for the upstream, one for the proxy itself. The proxy is constructed with the upstream's EndpointMock, and the test client connects to the proxy's mock:

mock_server_fn!(mock_upstream; upstream_svc);
mock_server_fn!(mock_proxy; proxy_svc);

#[tokio::test]
async fn should_forward_through_proxy() {
    // real upstream service behind mock #1
    let upstream_svc = PokemonServiceServer::new(PokemonServiceImpl::default());
    let (upstream_future, upstream_endpoint) = mock_upstream(upstream_svc).await;
    tokio::spawn(upstream_future);

    // the proxy under test dials the upstream via its EndpointMock (see the trait above)
    let proxy_svc = PokemonServiceServer::new(PokemonProxy::new(upstream_endpoint));
    let (proxy_future, proxy_endpoint) = mock_proxy(proxy_svc).await;
    tokio::spawn(proxy_future);

    // the client talks to the proxy; the whole chain is in-memory
    let mut client = PokemonServiceClient::new(proxy_endpoint.once().await);
    // ... assert that requests, streams and errors propagate through the proxy
}

The same approach scales to longer pipelines (client → proxy → aggregator → upstream), all inside a single test process with no ports.

Reusable test fixtures

For a service mocked in many tests, wrap the setup into a fixture in a shared testing module: build the services, call the generated mock function, and return the server future together with the EndpointMock. A CancellationToken gives deterministic shutdown:

mock_server_fn!(pub mock_engine_server; auth_svc, engine_svc);

pub struct EngineMock<S> {
    pub endpoint: Arc<EndpointMock>,
    pub cancellation_token: CancellationToken,
    pub server: S,
}

impl EngineMock<impl Future<Output = ()> + Send> {
    pub async fn start() -> Self {
        let auth_svc = AuthServiceServer::new(AuthServiceImpl::default());
        let engine_svc = EngineServiceServer::new(EngineServiceImpl::default());
        let (server, endpoint) = mock_engine_server(auth_svc, engine_svc).await;
        Self {
            endpoint: Arc::new(endpoint),
            cancellation_token: CancellationToken::new(),
            server,
        }
    }

    /// Spawn the server; it stops when the token is cancelled.
    pub fn run(self) -> EngineMock<tokio::task::JoinHandle<()>> {
        let token = self.cancellation_token.clone();
        EngineMock {
            endpoint: self.endpoint,
            cancellation_token: self.cancellation_token,
            server: tokio::spawn(async move {
                token.run_until_cancelled_owned(self.server).await;
            }),
        }
    }
}

Tests then read as three lines of setup: let mock = EngineMock::start().await.run();, get a channel from mock.endpoint, and cancel the token at the end. Fixtures can also expose ready-made helpers such as mock.authenticated_client().await that perform the auth handshake and return a client with the token interceptor already attached.

Streaming

Server-streaming and bidirectional-streaming RPCs work over the in-memory transport with no special handling — subscriptions, long-lived packet streams and backpressure behave the same as over TCP, which makes the crate suitable for testing streaming pipelines (subscribe, push N messages, drop the server or cancel the token, assert the client observes the stream ending).

License

MIT License. See LICENSE file for details.