The more you work with a codebase, the more patterns start to emerge. Some of these patterns paint a quite daunting picture of what the code might look like if, for example, you expand it with another 50 modules over time. At such moments, it’s worth pausing for a moment and reconsidering your existing code design decisions.

In the following code snippet, I already sensed in the previous iteration that there would be issues scaling this approach, so I quickly applied a builder pattern for safety. However, I then continued with the current train of thought.

 1fn test_create_managed_success() {
 2    // ...
 3    let app_state = Arc::new(
 4        AppStateBuilder::new()
 5            .users_module(Arc::new(UsersModule {}))
 6            .config_module(config.clone())
 7            .tenants_module(Arc::new(TenantsModule {
 8                pool_manager: pool_manager_mock.clone(),
 9                config: config.clone(),
10                repo_factory,
11                migrator_factory,
12            }))
13            .auth_module(Arc::new(AuthModule {
14                pool_manager: pool_manager_mock.clone(),
15                password_hasher: Arc::new(Argon2Hasher),
16                config: config.clone(),
17            }))
18            .build()
19            .unwrap(),
20    );
21    // ...
22}

Then today, as I started refining the tests for the auth module, I realized that, builder-pattern or not, it wouldn’t be very sustainable long-term if I had to reinitialize the entire AppState with defaults and mocks for each test, especially since each endpoint only uses a small subset of modules. Before this pattern completely took over the code, I needed to find some more sensible solution.

Default, the Savior Link to heading

I started by implementing the Default trait for all modules. I neatly added the #[cfg(test)] attribute because I only want to use them in tests. I wrote the code, and the IDE imported what it needed to. Everything went smoothly until I tried to compile the program. The compiler pointed out that I should also annotate my new imports with #[cfg(test)] because they only compile when #[cfg(test)] is enabled. So I did that. I went on like that for a while. But then I realized that this approach is cumbersome and not very elegant.

There’s a Better Solution Link to heading

It’s much prettier to implement the Default trait inside the tests module itself. This way, I don’t mix the test imports with the actual build imports, and I no longer need to annotate each import with #[cfg(test)].

And the final result?

 1#[cfg(test)]
 2pub(crate) mod tests {
 3    use super::*;
 4    use crate::app::database::MockPgPoolManagerTrait;
 5    use crate::auth::repository::MockAuthRepository;
 6
 7    impl Default for AuthModule {
 8        fn default() -> Self {
 9            AuthModule {
10                pool_manager: Arc::new(MockPgPoolManagerTrait::new()),
11                config: Arc::new(AppConfig::default()),
12                repo_factory: Box::new(|| Box::new(MockAuthRepository::new())),
13            }
14        }
15    }
16}
 1fn test_create_managed_success() {
 2    // ...
 3    let app_state = AppStateBuilder::default()
 4        .tenants_module(Arc::new(TenantsModule {
 5            pool_manager: pool_manager_mock.clone(),
 6            config: config.clone(),
 7            repo_factory,
 8            migrator_factory,
 9        }))
10        .build()
11        .unwrap();
12    // ...
13}