Teo
Teo

Reputation: 21

Injecting Mock of Repository into Service doesn't inject the proper mocked method (Spring, JUnit and Mockito)

tl;dr: Seems like the Mock of the repository I created with custom behavior regarding the save method when injected loses the custom behavior.


Problem Description

I've been trying to test a Service in Spring. The method of interest in particular takes some parameters and creates a User that is saved into a UserRepository through the repository method save.

The test I am interest in making is comparing these parameters to the properties of the User passed to the save method of the repository and in this way check if it is properly adding a new user.

For that I decided to Mock the repository and save the param passed by the service method in question to the repository save method.

I based myself on this question to save the User.

private static User savedUser;

public UserRepository createMockRepo() {
   UserRepository mockRepo = mock(UserRepository.class);
   try {
      doAnswer(new Answer<Void>() {
            @Override
            public Void answer(InvocationOnMock invocation) throws Throwable {
                savedUser= (User) invocation.getArguments(0);
                return null;
            }
        }).when(mockRepo).save(any(User.class));
   } catch( Exception e) {}
   return mockRepo;
}

private UserRepository repo = createMockRepo();

Two notes:

I then created a test function to check if it had the desired behavior and all was good.

@Test 
void testRepo() {
   User u = new User();
   repo.save(u);
   assertSame(u, savedUser);
}

Then I tried doing what I saw recommended across multiple questions, that is, to inject the mock into the service as explained here.

@InjectMocks
private UserService service = new UserService();

@Before
public void setup() {
   MockitoAnnotations.initMocks(this);
}

This is where the problems arise, the test I created for it throws a null exception when I try to access savedUser properties (here I simplified the users properties since that doesn't seem to be the cause).

@Test 
void testUser() {
   String name = "Steve";
   String food = "Apple";
   
   service.newUser(name, food);

   assertEquals(savedUser.getName(), name);
   assertEquals(savedUser.getFood(), food);
}

Upon debugging:

I decided to log the function with System.out.println for demonstrative purposes.

A print of my logging of the tests, demonstrating that the user test doesn't call the answer method


What am I doing wrong here?

Thank you for the help in advance, this is my first stack exchange question any tips for improvement are highly appreciated.

Upvotes: 0

Views: 2245

Answers (2)

Darren Forsythe
Darren Forsythe

Reputation: 11411

You should not need Spring to test of this. If you are following Spring best practicies when it comes to autowiring dependencies you should be able just create the objects yourself and pass the UserRepository to the UserService

Best practices being,

  • Constructor injection for required beans
  • Setter injection for optional beans
  • Field injection never unless you cannot inject to a constructor or setter, which is very very rare.

Note that InjectMocks is not a dependency injection framework and I discourage its use. You can see in the javadoc that it can get fairly complex when it comes to constructor vs. setter vs. field.

Note that working examples of the code here can be found in this GitHub repo.

A simple way to clean up your code and enable it to be more easily tested would be to correct the UserService to allow you to pass whatever implementation of a UserRepository you want, this also allows you to gaurentee immuability,

public class UserService {

  public UserService(final UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  public final UserRepository userRepository;

  public User newUser(String name, String food) {
    var user = new User();
    user.setName(name);
    user.setFood(food);
    return userRepository.save(user);
  }
}

and then your test would be made more simple,

class UserServiceTest {

  private UserService userService;
  private UserRepository userRepository;

  private static User savedUser;

  @BeforeEach
  void setup() {
    userRepository = createMockRepo();
    userService = new UserService(userRepository);
  }

  @Test
  void testSaveUser(){
    String name = "Steve";
    String food = "Apple";

    userService.newUser(name, food);

    assertEquals(savedUser.getName(), name);
    assertEquals(savedUser.getFood(), food);
  }

  public UserRepository createMockRepo() {
    UserRepository mockRepo = mock(UserRepository.class);
    try {
      doAnswer(
              (Answer<Void>) invocation -> {
                savedUser = (User) invocation.getArguments()[0];
                return null;
              })
          .when(mockRepo)
          .save(any(User.class));
    } catch (Exception e) {
    }
    return mockRepo;
  }
}

However, this doesn't add a lot of benefit in my opinion as you are interacting with the repository directly in the service unless you fully understand the complexity of a Spring Data Repository, you are after all also mocking networking I/O which is a dangerous thing to do

  • How do @Id annotations work?
  • What about Hibernate JPA interact with my Entitiy?
  • Do my column definitions on my Entitiy match what I would deploy against when using something like Liquibase/Flyway to manage the database migrations?
  • How do I test against any constraints the database might have?
  • How do I test custom transactional boundaries?

You're baking in a lot of assumptions, to that end you could use the @DataJpaTest documentation annotation that Spring Boot provides, or replicate the configuration. A this point I am assuming a Spring Boot application, but the same concept applies to Spring Framework applications you just need to setup the configurations etc. yourself.

@DataJpaTest
class BetterUserServiceTest {

  private UserService userService;

  @BeforeEach
  void setup(@Autowired UserRepository userRepository) {
    userService = new UserService(userRepository);
  }

  @Test
  void saveUser() {
    String name = "Steve";
    String food = "Apple";

    User savedUser = userService.newUser(name, food);

    assertEquals(savedUser.getName(), name);
    assertEquals(savedUser.getFood(), food);
  }
}

In this example we've went a step further and removed any notion of mocking and are connecting to an in-memory database and verifying the user that is returned is not changed to what we saved.

Yet there are limitations with in-memory databases for testing, as we are normally deploying against something like MySQL, DB2, Postgres etc. where column definitions (for example) cannot accurately be recreated by an in-memory database for each "real" database.

We could take it a step further and use Testcontainers to spin up a docker image of a database that we would connecting to at runtime and connect to it within the test

@DataJpaTest
@Testcontainers(disabledWithoutDocker = true)
class BestUserServiceTest {

  private UserService userService;

  @BeforeEach
  void setup(@Autowired UserRepository userRepository) {
    userService = new UserService(userRepository);
  }

  @Container private static final MySQLContainer<?> MY_SQL_CONTAINER = new MySQLContainer<>();

  @DynamicPropertySource
  static void setMySqlProperties(DynamicPropertyRegistry properties) {
    properties.add("spring.datasource.username", MY_SQL_CONTAINER::getUsername);
    properties.add("spring.datasource.password", MY_SQL_CONTAINER::getPassword);
    properties.add("spring.datasource.url", MY_SQL_CONTAINER::getJdbcUrl);
  }

  @Test
  void saveUser() {
    String name = "Steve";
    String food = "Apple";

    User savedUser = userService.newUser(name, food);

    assertEquals(savedUser.getName(), name);
    assertEquals(savedUser.getFood(), food);
  }
}

Now we are accurately testing we can save, and get our user against a real MySQL database. If we took it a step further and introduced changelogs etc. those could also be captured in these tests.

Upvotes: 1

Danilo Rodrigues
Danilo Rodrigues

Reputation: 23

Instead of instanciating your service in the test class like you did, use @Autowired and make sure your UserRepository has @MockBean in the test class

@InjectMocks
@Autowired
private UserService service

@MockBean
private UserRepository mockUserRepo

With this, you can remove your setup method

But make sure your UserRepository is also autowired insider your Service

Upvotes: 1

Related Questions