10

Original address: https://reflectoring.io/unit-testing-spring-boot/

Well-written unit tests can be seen as a difficult art to master. But the good news is that the mechanisms that support unit testing are easy to learn.

This article provides you with a mechanism for writing a good unit test in a Spring Boot application, and in-depth technical details.

We will take you to learn how to create a Spring Bean instance in a testable way, and then discuss how to use Mockito and AssertJ , both of which are referenced by default in Spring Boot for testing.

This article only discusses unit testing. As for integration testing, testing the web layer and testing the persistence layer will be discussed in the next series of articles.

Code example

The code sample address attached to this article: spring-boot-testing

Using Spring Boot for testing series articles

This tutorial is a series:

  1. Use Spring Boot for unit testing (this article)
  2. Use Spring Boot and @WebMvcTest to test the SpringMVC controller layer
  3. Use Spring Boot and @DataJpaTest to test JPA persistence query
  4. Integration test through @SpringBootTest

If you like watching video tutorials, you can take a look at the Philip course: Testing Spring Boot Application Course

Dependency

In this article, for unit testing, we will use JUnit Jupiter(Junit 5) , Mockito and AssertJ . In addition, we will quote Lombok to reduce some template code:

dependencies{
  compileOnly('org.projectlombok:lombok')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile 'org.junit.jupiter:junit-jupiter-engine:5.2.0'
  testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}

Mockito and AssertJ will be spring-boot-test dependency, but we need to quote Lombok .

Don't use Spring in unit tests

If you have used Spring or Spring Boot write unit tests before, you might say that we should not use Spring when writing unit tests. But why?

Consider the following unit test class, which tests a single method of class RegisterUseCase

@ExtendWith(SpringExtension.class)
@SpringBootTest
class RegisterUseCaseTest {

  @Autowired
  private RegisterUseCase registerUseCase;

  @Test
  void savedUserHasRegistrationDate() {
    User user = new User("zaphod", "zaphod@mail.com");
    User savedUser = registerUseCase.registerUser(user);
    assertThat(savedUser.getRegistrationDate()).isNotNull();
  }

}

This test class takes about 4.5 seconds to execute an empty Spring project on my computer.

But a good unit test only takes a few milliseconds. Otherwise it will hinder the TDD (Test Driven Development) process, which advocates "test/development/test".

But even if we don't use TDD, waiting for a unit test for too long will spoil our attention.

It actually only takes a few milliseconds to execute the above-mentioned test method. The remaining 4.5 seconds is because @SpringBootTest told Spring Boot to start the entire Spring Boot application context.

So we start the entire application just because we want to RegisterUseCase instance into our test class. It may take longer to start the entire application. Assuming that the application is larger and Spring needs to load more instances into the application context.

So, this is why you should not use Spring in your unit tests. Frankly speaking, most tutorials for writing unit tests do not use Spring Boot .

Create a testable class instance

Then, in order to make the Spring instance more testable, there are several things we can do.

Property injection is bad

Let's start with a counterexample. Consider the following classes:

@Service
public class RegisterUseCase {

  @Autowired
  private UserRepository userRepository;

  public User registerUser(User user) {
    return userRepository.save(user);
  }

}

This class Spring , because it does not provide a method to pass UserRepository instances. Therefore, we can only use the method discussed earlier in the article-let Spring create UserRepository instance of @Autowired inject it into it through the 060c16b2c1d342 annotation.

The lesson here is: don't use attribute injection.

Provide a constructor

In fact, we don’t need to use the @Autowired annotation at all:

@Service
public class RegisterUseCase {

  private final UserRepository userRepository;

  public RegisterUseCase(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  public User registerUser(User user) {
    return userRepository.save(user);
  }

}

This version allows constructor injection by providing a UserRepository In this unit test, we can now create such an instance (or the Mock instance we will discuss later) and inject it through the constructor.

When creating the application context, Spring will automatically use this constructor to initialize the RegisterUseCase object. Note that before Spring 5, we need to add the @Autowired annotation to the constructor so that Spring can find the constructor.

Also note that the UserRepository attribute is final modified by 060c16b2c1d414. This is very important, because in this case, the content of this property will not change during the life of the application. In addition, it can also help us avoid becoming an error, because if we forget to initialize the property, the compiler will report an error.

Reduce template code

By using Lombok of @RequiredArgsConstructor notes, we can make the constructor automatically generated:

@Service
@RequiredArgsConstructor
public class RegisterUseCase {

  private final UserRepository userRepository;

  public User registerUser(User user) {
    user.setRegistrationDate(LocalDateTime.now());
    return userRepository.save(user);
  }

}

Now, we have a very concise class, without boilerplate code, which can be easily instantiated in ordinary java test cases:

class RegisterUseCaseTest {

  private UserRepository userRepository = ...;

  private RegisterUseCase registerUseCase;

  @BeforeEach
  void initUseCase() {
    registerUseCase = new RegisterUseCase(userRepository);
  }

  @Test
  void savedUserHasRegistrationDate() {
    User user = new User("zaphod", "zaphod@mail.com");
    User savedUser = registerUseCase.registerUser(user);
    assertThat(savedUser.getRegistrationDate()).isNotNull();
  }

}

There is also part of it, how to simulate the UserReposity instance that the test class depends on. We don't want to rely on the real class because this class requires a database connection.

Use Mockito to mock dependencies

The current de facto standard analog library is Mockito . It provides at least two ways to create a simulated UserRepository instance to fill the gaps in the aforementioned code.

Use ordinary Mockito to simulate dependencies

The first way is to use Mockito programming:

private UserRepository userRepository = Mockito.mock(UserRepository.class);

This creates an object UserRepository from the outside world. By default, the method will not do anything when it is called. If the method has a return value, it will return null .

Because userRepository.save(user) returns null, now our test code assertThat(savedUser.getRegistrationDate()).isNotNull() will report a NullPointerException.

So we need to tell Mockito to return something when userRepository.save(user) We can use the static when method to achieve:

@Test
void savedUserHasRegistrationDate() {
  User user = new User("zaphod", "zaphod@mail.com");
  when(userRepository.save(any(User.class))).then(returnsFirstArg());
  User savedUser = registerUseCase.registerUser(user);
  assertThat(savedUser.getRegistrationDate()).isNotNull();
}

This will make userRepository.save() return the same object as the incoming object.

Mockito provides many features for simulating objects, matching parameters, and verifying method calls. Want to see more, document

Annotate simulation objects with Mockito of @Mock

Creating a mock object second way is to use Mockito of @Mock notes combined with JUnit Jupiter's MockitoExtension used with:

@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {

  @Mock
  private UserRepository userRepository;

  private RegisterUseCase registerUseCase;

  @BeforeEach
  void initUseCase() {
    registerUseCase = new RegisterUseCase(userRepository);
  }

  @Test
  void savedUserHasRegistrationDate() {
    // ...
  }

}

@Mock annotation indicates which attributes require Mockito injected into the simulation object. Since JUnit will not be implemented automatically, MockitoExtension tells Mockito to evaluate these @Mock annotations.

This result Mockito.mock() method, you can choose according to personal taste. But please note that by using MockitoExtension , our test case is bound to the test framework.

We can use the @InjectMocks RegisterUseCase attribute to inject the instance instead of manually constructing it through the constructor. Mockito will use the specific algorithm to help us create the corresponding instance objects:

@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {

  @Mock
  private UserRepository userRepository;

  @InjectMocks
  private RegisterUseCase registerUseCase;

  @Test
  void savedUserHasRegistrationDate() {
    // ...
  }

}

Use AssertJ to create readable assertions

Another library automatically attached to the Spring Boot AssertJ . We have used it to make assertions in the code above:

assertThat(savedUser.getRegistrationDate()).isNotNull();

However, is it possible to make the assertion more readable? Like this, example:

assertThat(savedUser).hasRegistrationDate();

There are many test cases, and only a small change like this can greatly improve the comprehensibility. So, let's create our custom assertion in test/sources:

class UserAssert extends AbstractAssert<UserAssert, User> {

  UserAssert(User user) {
    super(user, UserAssert.class);
  }

  static UserAssert assertThat(User actual) {
    return new UserAssert(actual);
  }

  UserAssert hasRegistrationDate() {
    isNotNull();
    if (actual.getRegistrationDate() == null) {
      failWithMessage(
        "Expected user to have a registration date, but it was null"
      );
    }
    return this;
  }
}

Now, if we assertThat method from our custom assertion class UserAssert AssertJ library, we can use new and more readable assertions.

Creating such a custom assertion class may seem time-consuming, but it actually takes a few minutes. I believe that it is worthwhile to invest the time in creating readable test code, even if its readability only improves slightly afterwards. We write the test code only once, but after that, many other people (including me in the future) need to read, understand and manipulate these codes many times during the software life cycle.

If you still find it very troublesome, you can take a look at assertion generator

in conclusion

Although there are some reasons to start a Spring application during testing, it is not necessary for general unit testing. Sometimes it is even harmful because of the longer turnaround time. In other words, we should build Spring instances in a way that is easier to support writing ordinary unit tests.

Spring Boot Test Starter comes with Mockito and AssertJ as test libraries. Let's use these test libraries to create expressive unit tests!


程序员伍六七
201 声望597 粉丝