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

huangapple go评论72阅读模式
英文:

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

问题

似乎我创建的带有自定义行为的存储库模拟,在注入时丢失了自定义行为。

问题描述

我一直在尝试在Spring中测试一个服务。特别感兴趣的方法接受一些参数,并创建一个通过存储库方法save保存到UserRepository中的User。

我有兴趣进行的测试是将这些参数与传递给存储库save方法的User的属性进行比较,以此方式检查是否正确添加了新用户。

为此,我决定模拟存储库,并将问题服务方法传递的参数保存到存储库save方法中。

我基于这个问题来保存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();

两点说明:

  • 我给出了repo的名称,以防名称必须与服务中的名称匹配。

  • 没有@Mock注释,因为它会导致测试失败,我认为这是因为它将以通常的方式创建模拟(不包括我之前创建的自定义方法)。

然后,我创建了一个测试函数来检查是否具有所需的行为,一切都很好。

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

然后,我尝试按照多个问题中建议的方式执行操作,即将模拟注入到服务中,如此处所述。

@InjectMocks
private UserService service = new UserService();

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

这是问题出现的地方,我为其创建的测试在尝试访问savedUser属性时引发null异常(在这里我简化了用户属性,因为那似乎不是原因)。

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

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

在调试时:

  • 服务似乎已收到模拟:服务的调试属性
  • savedUser确实为null:调试的savedUser属性。

我决定使用System.out.println记录函数,以进行演示。

测试记录的打印,演示用户测试不调用answer方法

我在这里做错了什么?

提前感谢您的帮助,这是我的第一个堆栈交换问题,非常感谢任何改进的建议。

英文:

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&lt;Void&gt;() {
            @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 gave the name repo in case the name had to match the one in the service.

  • There is no @Mock annotation since it starts failing the test, I presume that is because it will create a mock in the usual way (without the custom method I created earlier).

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 = &quot;Steve&quot;;
   String food = &quot;Apple&quot;;
   
   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.

答案1

得分: 1

不要回答我要翻译的问题。以下是要翻译的内容:

"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"

英文:

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

答案2

得分: 0

不需要使用Spring来测试这个功能。如果您遵循Spring的最佳实践来进行依赖注入,您可以直接创建对象并将UserRepository传递给UserService

最佳实践包括:

  • 对于必需的bean,使用构造函数注入
  • 对于可选的bean,使用setter注入
  • 几乎不使用字段注入,除非无法注入到构造函数或setter中,这非常罕见。

请注意,InjectMocks不是依赖注入框架,我不鼓励使用它。您可以在javadoc中看到,当涉及构造函数、setter和字段时,它可能会变得相当复杂。

在这个GitHub仓库中可以找到代码的工作示例。

为了清理代码并使其更易于测试,您可以将UserService进行改进,允许您传递任何UserRepository的实现,这也可以保证不可变性:

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);
  }
}

然后,您的测试会变得更简单:

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;
  }
}

但是,我认为在我个人看来,这并没有带来很多好处,因为您在服务中直接与存储库交互,除非您完全理解Spring Data存储库的复杂性,毕竟您也在模拟网络IO,这是一件危险的事情。

您还可以使用Spring Boot提供的@DataJpaTest注解或者复制配置来模拟Spring应用程序。在这个示例中,我假设您正在使用Spring Boot应用程序,但是相同的概念也适用于Spring Framework应用程序,您只需自己设置配置等。

@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);
  }
}

在这个示例中,我们进一步删除了任何模拟的概念,并连接到一个内存数据库,验证返回的用户与我们保存的用户是否相同。

然而,内存数据库用于测试还是有一些限制的,因为我们通常会部署在像MySQL、DB2、Postgres等实际数据库中,而内存数据库无法为每个“真实”数据库准确地重新创建列定义等。

我们还可以更进一步,使用Testcontainers来启动一个数据库的Docker镜像,这样我们可以在测试中连接到它。

@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);
  }
}

现在,我们可以准确地测试我们是否可以将用户保存并从真实的MySQL数据库中获取。如果我们进一步引入更改日志等,这些也可以在这些测试中捕获。

英文:

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 = &quot;Steve&quot;;
    String food = &quot;Apple&quot;;

    userService.newUser(name, food);

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

  public UserRepository createMockRepo() {
    UserRepository mockRepo = mock(UserRepository.class);
    try {
      doAnswer(
              (Answer&lt;Void&gt;) invocation -&gt; {
                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 = &quot;Steve&quot;;
    String food = &quot;Apple&quot;;

    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&lt;?&gt; MY_SQL_CONTAINER = new MySQLContainer&lt;&gt;();

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

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

    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.

huangapple
  • 本文由 发表于 2020年8月5日 02:10:42
  • 转载请务必保留本文链接:https://go.coder-hub.com/63252777.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定