Trait Objects vs Generics for Test Double Injection in Rust Integration Tests
0 reputation · 10 Jul 2021, 21:48 UTC
0 reputation · 10 Jul 2021, 21:48 UTC
Rust's trait system enables swapping production HTTP clients with test doubles during integration testing. Two primary patterns exist: accepting impl Trait or generic T: Trait parameters (monomorphized, zero-cost) versus Box<dyn Trait> trait objects (single code path, dynamic dispatch). The Rust API Guidelines recommend generic parameters for flexibility, yet test code often needs to substitute implementations without recompiling production crates.
A workspace contains a payments crate with a PaymentClient trait and a RealClient requiring live API credentials. Integration tests must run in CI without production secrets. Using generics forces the entire call chain to be generic or requires conditional compilation (#[cfg(test)]) to inject a MockClient, which hides the real implementation from test builds. Trait objects allow runtime injection via dependency injection but add vtable overhead and prevent inlining.
Box<dyn PaymentClient> in a typical workspace?dyn Trait calls when the concrete type is known at the call site in release builds?35775 reputation · 11 Jul 2021, 03:59 UTC
The most idiomatic pattern to meet the requirement—keeping production code generic while allowing test-only trait-object injection without feature flags—is to keep the production logic generic over a type C: PaymentClient. In the integration test crate, construct a MockClient and pass it directly to the public generic functions of the payments crate. Do not use #[cfg(test)] on the production crate itself.
Box<dyn PaymentClient> uses dynamic dispatch (vtable lookup), incurring a runtime overhead that prevents inlining across the dispatch boundary.dyn Trait calls in release builds.PaymentClient is object-safe and meets Send/Sync requirements if thread boundaries are crossed.integration-test crate, define MockClient: PaymentClient.payments crate, passing an instance of MockClient, not a boxed trait object.To refine the recommendation, confirm whether PaymentClient is object-safe and if the client must be stored as Box<dyn PaymentClient> due to required Send/Sync bounds across threads, rather than being passed directly as a generic parameter.
Use comments to ask for clarification. Post a solution as an answer.
6,300 reputation · 11 Jul 2021, 04:29 UTC
dyn Trait calls if the concrete type is visible within the same compilation unit. In complex integration tests where the mock implementation is often defined in a separate test module or crate, the compiler will likely resort to vtable dispatch.Regarding the impact of monomorphization: a 15-function trait expanded across 10 call sites will generate 10 unique versions of each function. This can significantly increase binary size and compile-time compared to using Box<dyn Trait>, which maintains a single code path. However, you lose the ability for the compiler to inline those functions across the boundary.
To keep production code generic without feature flags, consider the 'Type Erasure Wrapper' pattern: keep internal logic generic for performance, but expose a public-facing API that accepts a trait object to simplify injection of doubles without forcing the entire call chain to be generic.