Unravelling @TestConfiguration Quirks: Spring Boot

Introduction

Testing is a crucial aspect of software development, ensuring that our code functions as expected under various scenarios. Writing effective tests often involves mocking dependencies to isolate units of code for testing

The Challenge:

Imagine a scenario where you have multiple services in your Spring Boot application, each depending on similar repository calls. Writing test cases for these services involves mocking these repositories, leading to redundant code and maintenance overhead. In this blog post, we'll delve into how to streamline this process and tackle common challenges encountered while testing Spring Boot applications.

Here we have two repositories having has-a relationship with services.

@Repository
public class AddressRepository {
    public String getAddress(){
        return "Original address";
    }
}

@Repository
public class AddressRepository {
    public String getAddress(){
        return "Original address";
    }
}
@Service
public class ServiceOne {

    UserRepository userRepository;

    AddressRepository addressRepository;

    @Autowired
    public ServiceOne(UserRepository userRepository, AddressRepository addressRepository) {
        this.userRepository = userRepository;
        this.addressRepository = addressRepository;
    }

    public String getUserAndAddress(){
        String userData = userRepository.getUser();
        String addressData = addressRepository.getAddress();
        return "service1" + " : " + userData + " : " + addressData;
    }

}
@Service
public class ServiceTwo {

    UserRepository userRepository;

    AddressRepository addressRepository;

    @Autowired
    public ServiceTwo(UserRepository userRepository, AddressRepository addressRepository) {
        this.userRepository = userRepository;
        this.addressRepository = addressRepository;
    }

    public String findData() {
        String userData = userRepository.getUser();
        String addressData = addressRepository.getAddress();
        return "service2" + " : " + userData + " : " + addressData;
    }
}

Initial Approach with @MockBean:

We can use the @MockBean to add mock objects to the Spring application context. The mock will replace any existing bean of the same type in the application context.

@SpringBootTest
public class ServiceTwoTest {
    @MockBean
    UserRepository userRepository;

    @MockBean
    AddressRepository addressRepository;

    @Autowired
    ServiceTwo serviceTwo;

    @Test
    public void shouldFindData(){
        when(userRepository.getUser()).thenReturn("mock user");
        when(addressRepository.getAddress()).thenReturn("mock address");
        String data = serviceTwo.findData();
        assert(data).equals("service2 : mock user : mock address" );
    }
}

In my case this approach leads to repetitive mocking code, making tests verbose and prone to duplication.

Exploring @TestConfiguration:

Upon realising the limitations of the initial approach, you might turn to @TestConfiguration to centralize and streamline bean mocking. So I used this in following way.

@TestConfiguration
public class TestConfig {

    @Bean
    UserRepository userRepository(){
        UserRepository userRepository = mock(UserRepository.class);
        when(userRepository.getUser()).thenReturn("mock user");
        return userRepository;
    }

    @Bean
    AddressRepository addressRepository(){
        AddressRepository addressRepository = mock(AddressRepository.class);
        when(addressRepository.getAddress()).thenReturn("mock address");
        return addressRepository;
    }

}

And then removed it from our service tests.

@SpringBootTest
public class ServiceTwoTest {

    @Autowired
    ServiceTwo serviceTwo;

    @Test
    public void shouldFindData(){
        String data = serviceTwo.findData();
        assert(data).equals("service2 : mock user : mock address" );
    }
}

But in our case It failed. In our example it was using the real beans instead of mocked ones. (With real jpaRepository beans it will return null as we won't be having db connection during tests). Then I again had a thought I should not hurry and I should first go through the documentation. And there I found -

Unlike regular @Configuration classes the use of @TestConfiguration does not prevent auto-detection of @SpringBootConfiguration.

@SpringBootTest
@Import(TestConfig.class)
public class ServiceTwoTest {

    @Autowired
    ServiceTwo serviceTwo;

    @Test
    public void shouldFindData(){
        String data = serviceTwo.findData();
        assert(data).equals("service2 : mock user : mock address" );
    }
}

This solved the problem with spring 3. 🎉🎉

But we were on spring 2 so these are some extra steps we had to do.

The error I got which had direct solution -

#Error
A bean with that name has already been defined in class path resource [com/example/demo/config/TestConfig.class] and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

application.properties
spring.main.allow-bean-definition-overriding=true

Repositories defined in TestConfig didn’t take priority. So tried adding @Primary to tell Spring to take this bean with high priority.

@TestConfiguration
public class TestConfig {

    @Bean
    @Primary
    UserRepository userRepository(){
        UserRepository userRepository = mock(UserRepository.class);
        when(userRepository.getUser()).thenReturn("mock user");
        return userRepository;
    }

    @Bean
    @Primary
    AddressRepository addressRepository(){
        AddressRepository addressRepository = mock(AddressRepository.class);
        when(addressRepository.getAddress()).thenReturn("mock address");
        return addressRepository;
    }

}

Still test was taking the original bean and at this point I was loosing hope. was not able to find single answer for my situation.

After searching alot I found someone on stack overflow saying this was the issue with spring boot older version. And that solution was ...while overriding beans using @TestConfiguration the bean name should be different from actual bean name.

@TestConfiguration
public class TestConfig {

    @Bean
    @Primary
    UserRepository userRepositoryMock(){
        UserRepository userRepository = mock(UserRepository.class);
        when(userRepository.getUser()).thenReturn("mock user");
        return userRepository;
    }

    @Bean
    @Primary
    AddressRepository addressRepositoryMock(){
        AddressRepository addressRepository = mock(AddressRepository.class);
        when(addressRepository.getAddress()).thenReturn("mock address");
        return addressRepository;
    }

}

And this worked for me. Don't know after which version issue got resolved but yes in spring boot 3 we don't have this issue.

Remember, testing isn't just about ensuring code correctness—it's about empowering developers to write better code with confidence.

Keep testing, keep evolving, and keep building amazing things with Spring Boot!