# tonic-server-mock
`tonic-server-mock` is a utility for quickly creating mock gRPC servers based on [tonic](https://github.com/hyperium/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`:
```toml
tonic-server-mock = "1.0"
```
## Example
```rust
// 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:
```rust
// 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:
```rust
#[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:
```rust
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:
```rust
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:
```rust
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:
```rust
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.