Rust Advanced Testing Workshop

repository·main·Indexed 42 days ago

https://github.com/mainmatter/rust-advanced-testing-workshop

A workshop for expanding Rust testing toolkits beyond basic built-in techniques. Focuses on complex scenarios including third-party APIs, databases, and external systems. Includes guidance on using the googletest crate for advanced assertions, implementing custom matchers, and utilizing snapshot testing. Features automated verification via the ctr tool and wr workshop runner.

Tokens
23.7K
Snippets
81
Records
119
Agent score
51%

What's inside Rust Advanced Testing Workshop

  1. Explore the Rust Advanced Testing Workshop curriculum

    main

    This workshop provides a structured learning path for advanced testing techniques in Rust. The curriculum is divided into several key modules:

    • Better Assertions: Moving beyond standard assertions using googletest for matchers (Eq, Option, Result, Enums, Collections), custom matchers, and expect_that!.
    • Snapshot Testing: Using insta for snapshot testing, managing storage locations, and implementing redactions.
    • Mocks: Implementing mocks using mockall, including handling traits, call sequences, checkpoints, and mocking foreign traits.
    • Filesystem Isolation: Using tempfile and named_tempfile to manage temporary files and directories safely during tests.
    • Database Isolation: Using sqlx::test for isolated database testing.
    • HTTP Mocking: Using wiremock to mock HTTP services, including the use of matchers and checkpoints.
    • Macros: Learning how to write test-related macros, from no-op macros to parsing tokens and arguments.
    • Test Harnesses: Creating custom test harnesses and using libtest_mimic for CLI-based testing.

    The workshop concludes with a Capstone project to apply these integrated skills.

  2. What is snapshot testing and when to use it

    main

    Snapshot testing is an alternative to manually defining expected values in tests. Instead of hardcoding a complex expected value (like a large JSON document), you capture a "snapshot" of the actual output and compare it against a previously saved version.

    Use snapshot testing when:

    • The expected value is complex or large.
    • The expected value changes frequently (e.g., responses from a downstream API under active development).
    • Manually maintaining expected values becomes cumbersome.
  3. Implement attribute detection in procedural macros

    main

    When creating custom test macros (like a #[vanilla_test] replacement), you should implement logic to detect if the target function is already annotated with the standard #[test] attribute.

    To avoid conflicts and ensure compatibility with the Rust test runner, your macro should follow these rules:

    • If the function is already annotated with #[test], emit the code unchanged.
    • If the function is NOT annotated with #[test], automatically add the #[test] attribute to the function.

    This pattern allows your macro to act as a wrapper or an enhancement without breaking the standard test discovery mechanism, similar to how #[googletest::gtest] operates.

  4. Refactor to an interface using Traits to enable test doubles

    main

    To use test doubles (mocks/stubs) in Rust, you must decouple your functions from concrete implementations. Because Rust is statically typed, a function expecting a specific type like Auth0Client cannot accept a mock object.

    To solve this, you must refactor to an interface by using Traits. A trait defines a collection of methods that can be implemented by both your real production types and your test doubles. This allows the function to remain polymorphic, accepting any type that implements the required trait.

    // 1. Define the interface (Trait)
    trait Authenticator {
        async fn verify(&self, jwt: &str) -> Result<UserId, VerificationError>;
    }
    
    // 2. Implement the trait for the real production type
    impl Authenticator for Auth0Client {
        async fn verify(&self, jwt: &str) -> Result<UserId, VerificationError> {
            // real implementation
        }
    }
    
    // 3. Update the function to use a generic type bounded by the trait
    async fn login<A>(
        request: &HttpRequest,
        database_pool: &DatabasePool,
        authenticator: &A,
        rate_limiter: &RateLimiter,
    ) -> Result<LoginResponse, LoginError>
    where
        A: Authenticator,
    {
        // ...
    }
  5. Approaches to testing HTTP dependencies

    main

    When testing code that relies on external network services, there are three primary strategies to consider:

    1. End-to-End Testing: Run a real test instance of the external service. This provides the highest confidence and realism but can be expensive, complex to orchestrate in microservice architectures, or impossible if the service is inaccessible.
    2. HTTP Interception (Mocking): Use a library to intercept HTTP requests and return pre-determined responses. This exercises your production HTTP client stack while avoiding the overhead of a real service. The main risk is 'mock drift', where mocks become out of sync with the actual service API.
    3. Abstraction/Test Doubles: Hide the network dependency behind a trait or interface and inject a test double (mock/stub) instead of the production implementation. This was covered in the workshop's mocking section.

    This workshop focuses on Option #2 using the wiremock crate.

  6. Handle error cases in macro argument parsing

    main

    When writing macros that accept arguments like before or after, you must implement robust error handling to provide meaningful messages to users. Common failure modes to account for include:

    • The provided item is not a function.
    • The provided item is a function that requires arguments (when the macro expects a zero-argument function).
    • The provided item is a function that is not currently in scope.

    Focusing on these edge cases is critical when designing macros intended for a broad audience rather than personal use.

  7. How attribute procedural macros work as token transformers

    main

    Custom test macros (like #[tokio::test] or #[sqlx::test]) are implemented as attribute procedural macros. These act as token transformers that intercept Rust code during compilation.

    An attribute procedural macro receives two inputs:

    1. args: A stream of tokens representing the arguments passed to the macro.
    2. item: A stream of tokens representing the Rust code being annotated.

    The macro processes these and returns a new stream of tokens, which is the actual code that gets compiled.

    Example: #[tokio::test] transformation

    When you write an async test with #[tokio::test], the macro rewrites it into a synchronous function that uses a runtime to block on the async block:

    Input code:

    #[tokio::test(flavor = "multi_thread")]
    async fn it_works() {
        assert!(true);
    }

    Transformed code (what actually compiles):

    #[test]
    fn it_works() {
        tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .unwrap()
            .block_on(async {
                assert!(true);
            })
    }
    use proc_macro::TokenStream;
    
    #[proc_macro_attribute]
    pub fn test(args: TokenStream, item: TokenStream) -> TokenStream {
        // Implementation logic goes here
    }
  8. Use basic matchers in googletest

    main
    To effectively use the googletest library in Rust, you should move beyond standard assert_eq! macros and utilize its built-in matchers. Matchers serve as the fundamental building blocks for assertions, allowing for more expressive and powerful testing logic. Familiarizing yourself with these matchers is essential for writing idiomatic tests with the library.
  9. Understanding the limitations of default Rust testing

    main

    The standard Rust testing toolkit, consisting of cargo test and the #[test] attribute, is powerful but lacks several advanced features common in other ecosystems:

    • No lifecycle hooks: There is no built-in way to execute code before or after a test case (setup/teardown), which is necessary for managing external resources like databases.
    • No fixtures: You cannot inject types into a test function signature and have the framework instantiate them (e.g., automatically providing a PgPool).
    • No parameterised tests: You cannot easily run the same test logic with multiple different inputs where each input is reported as a distinct test case.
    • No first-class async tests: Because Rust does not include a default executor, you cannot write async fn tests without using a third-party crate and a macro to handle the execution.
  10. Use external snapshots with `insta`

    main

    External snapshots store the expected value in a separate file rather than in the source code. This prevents large data structures from cluttering your test files.

    By default, insta stores these in a snapshots folder located in the same directory as the test file. The filename follows the pattern <module>__<name>.snap, where <name> is automatically derived from the test function name.

    To use an external snapshot, call assert_snapshot! with only the value being tested.

    #[test]
    fn snapshot() {
        let m = "The new value I want to save";
        assert_snapshot!(m)
    }