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}