A reusable test harness for shared trait implementations

When you implement the same trait in several ways, you usually want to exercise every implementation with the same test suite. The challenge in Rust is that the unit of testing is the #[test]-tagged function, which requires concrete types at compile time. Writing a test function per type quickly produces boilerplate, especially as the test suite grows.

Consider a Calculator trait with implementations Foo and Bar. The naive approach — a generic test helper invoked by concrete #[test] functions like test_foo and test_bar — works but has real drawbacks. For each feature you test, you'd need a separate helper, and then a thin #[test] wrapper per type. Those wrappers are the only functions the test runner sees, so you cannot select tests by feature across types, and test parallelization is limited to the wrapper level.

The fundamental issue is that you want #[test] applied to generic test code, but the attribute is resolved at compile time. Macros give you a way to generate the necessary concrete test functions.

Macros: two strategies

One macro approach encodes the implementation name into the generated function name. A macro like calculator_tests can expand to multiple #[test] functions, one per type. The test runner sees them all and runs them correctly. But extending this to multiple test functions per type is awkward: you'd want names like foo_feature1 and bar_feature1, which requires dealing with macro hygiene or reaching for a procedural macro such as paste.

A cleaner design uses a sub-module per type. Instead of naming functions differently, every generated test is named test, but lives in a module whose name is the type's name, passed to the macro:

  • The macro generates a module (e.g. foo and bar), each containing the full set of #[test] functions.
  • Adding a new test function to the macro automatically runs it for every type, with no repeated wrappers.
  • Each test gets a unique full path, so you can run subsets from the command line — e.g. only the tests for Bar, or all feature1 tests across types.

The result is minimal boilerplate per implementation: essentially one line that lists the type. The tradeoff is that macros add indirection, which surfaces in diagnostics — a failed assertion points to the macro instantiation site rather than the specific assert. Re-running the failed test with RUST_BACKTRACE=1 locates the actual failure in the trace.

For a project where the same trait is implemented multiple ways, the module-based macro pattern gives you a single, shared test suite with per-type namespace separation and full integration with cargo test's filtering and parallelism.