Spring Boot 单元测试

在软件开发过程中,单元测试是确保代码质量和稳定性的重要环节。对于使用Spring Boot框架构建的应用程序,编写单元测试同样重要。本文将介绍如何在Spring Boot中编写单元测试,帮助你更好地进行软件开发和维护。

1. 为什么需要单元测试?

单元测试的主要目的是验证代码的最小可测试部分是否按预期工作。这不仅有助于发现和修复错误,还能提高代码的可维护性和可读性。对于Spring Boot应用,单元测试可以帮助你快速验证业务逻辑,避免在集成测试和生产环境中发现错误。

2. 准备工作

在Spring Boot项目中,单元测试通常使用JUnit和Mockito这两个框架。JUnit是Java平台的单元测试框架,而Mockito则是一个模拟对象框架,用于创建和配置模拟对象。

  • JUnit:Spring Boot默认使用JUnit 5作为单元测试框架。
  • Mockito:用于模拟依赖对象,方便隔离测试。

Spring Boot 提供了一个名为 spring-boot-starter-test 的启动器依赖,其中包含了常用的测试框架和工具,如 JUnit、Mockito、Spring Test、Hamcrest 等。你需要在pom.xml文件中添加以下依赖:


    
        org.springframework.boot
        spring-boot-starter-test
        test
    

3. 单元测试入门

假设你有一个简单的服务类UserService,我们来编写一个单元测试来验证它。

@Service
public class UserService {
    public String getUserById(String id) {
        if ("1".equals(id)) {
            return "User One";
        } else {
            return "Unknown User";
        }
    }
}

对于UserService,我们可以编写一个单元测试类UserServiceTest,在Idea中创建测试类的方式有二:

  • 点击测试类,Ctrl + Shift + T
  • 鼠标右键点击类名 使用 goto-Test 即可实现
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class UserServiceTest {

    @Autowired
    private UserService userService;

    @BeforeAll
    static void setupBeforeAll() {
        // 在所有测试方法运行之前执行一次
        System.out.println("Setup before all tests");
    }

    @BeforeEach
    void setupBeforeEach() {
        // 在每个测试方法运行之前执行
        System.out.println("Setup before each test");
    }

    @Test
    public void testGetUserById() {
        String userId = "1";
        String expected = "User One";
        String actual = userService.getUserById(userId);
        assertEquals(expected, actual, "User should be User One");
    }

    @AfterEach
    void teardownAfterEach() {
        // 在每个测试方法运行之后执行
        System.out.println("Teardown after each test");
    }

    @AfterAll
    static void teardownAfterAll() {
        // 在所有测试方法运行之后执行一次
        System.out.println("Teardown after all tests");
    }
}

在这个测试中,我们使用了@SpringBootTest注解来加载完整的Spring应用上下文,然后通过@Autowired注解注入了UserService。最后,我们使用@Test注解定义了一个测试方法testGetUserById,用来验证UserServicegetUserById方法。

功能测试过程中的几个关键要素及支撑方式如下:

  • 测试运行环境:通过@SpringBootTest启动spring容器。
  • mock能力:Mockito提供了强大mock功能。
  • 断言能力:AssertJ、Hamcrest、JsonPath提供了强大的断言能力。

3.1. @SpringBootTest注解

@SpringBootTest注解的主要作用是告诉Spring Boot测试框架,当前测试类需要加载完整的Spring应用上下文。这意味着所有的配置类、Bean以及相关的组件都会被加载和初始化,以便测试能够在一个与实际运行环境相似的环境中进行。@SpringBootTest注解有一些属性可以配置,以满足不同的测试需求:

  • classes:指定需要加载的具体配置类或启动类。如果不指定,则默认加载主配置类。
  • webEnvironment:指定测试的Web环境模式。可选值包括:
    • MOCK:使用Mock的Servlet环境,不提供真实的Servlet容器。这是默认值。
    • RANDOM_PORT:使用真实的Servlet容器,并随机分配一个端口。
    • DEFINED_PORT:使用真实的Servlet容器,并使用在application.propertiesapplication.yml中定义的端口。
    • NONE:不提供Servlet容器。

3.2. 测试生命周期

JUnit 5 提供了多种注解来控制测试的生命周期,包括 @BeforeAll, @BeforeEach, @AfterEach, 和 @AfterAll。这些注解可用于设置和清理测试环境。

  • @BeforeAll:在所有测试方法运行之前执行一次。这个方法必须是静态的。
  • @BeforeEach:在每个测试方法运行之前执行。
  • @Test:用于标记一个测试方法。
  • @AfterEach:在每个测试方法运行之后执行。
  • @AfterAll:在所有测试方法运行之后执行一次。这个方法必须是静态的。

3.3. 使用断言进行验证

断言是测试中用于验证代码行为的关键部分。JUnit提供了丰富的断言方法来帮助你验证各种条件。以下是一些常用的断言方法:

  • assertEquals(expected, actual, message):验证两个值是否相等。
  • assertNotEquals(expected, actual, message):验证两个值是否不相等。
  • assertTrue(condition, message):验证条件是否为真。
  • assertFalse(condition, message):验证条件是否为假。
  • assertNull(actual, message):验证对象是否为null。
  • assertNotNull(actual, message):验证对象是否不为null。
  • assertSame(expected, actual, message):验证两个对象是否是同一个实例。
  • assertNotSame(expected, actual, message):验证两个对象是否不是同一个实例。
  • assertThrows(expectedType, executable, message):验证是否抛出了预期的异常。
  • assertDoesNotThrow(executable, message):验证是否没有抛出异常。
  • assertTimeout(duration, executable, message):验证执行时间是否在预期时间内。

举个栗子:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;

import java.time.Duration;

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
public class UserServiceTest {

    @MockBean
    private UserRepository userRepository;

    @Autowired
    private UserService userService;

    @Test
    public void testGetUserById() {
        // 准备测试数据
        String userId = "1";
        String expectedUserName = "User One";
        User mockUser = new User(userId, expectedUserName);
        when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(mockUser));

        // 调用被测试的方法
        String actualUserName = userService.getUserById(userId);

        // 使用断言进行验证
        assertEquals(expectedUserName, actualUserName, "User name should be User One");
        assertNotNull(actualUserName, "User name should not be null");
        assertSame(mockUser.getName(), actualUserName, "User name should be the same instance");
    }

    @Test
    public void testGetUnknownUserById() {
        // 准备测试数据
        String userId = "2";
        String expectedUserName = "Unknown User";
        when(userRepository.findById(userId)).thenReturn(java.util.Optional.empty());

        // 调用被测试的方法
        String actualUserName = userService.getUserById(userId);

        // 使用断言进行验证
        assertEquals(expectedUserName, actualUserName, "User name should be Unknown User");
        assertNotNull(actualUserName, "User name should not be null");
    }

    @Test
    public void testExceptionHandling() {
        // 准备测试数据
        String userId = "3";
        when(userRepository.findById(userId)).thenThrow(new RuntimeException("User not found"));

        // 使用断言验证异常
        assertThrows(RuntimeException.class, () -> {
            userService.getUserById(userId);
        }, "Should throw RuntimeException");
    }

    @Test
    public void testTimeout() {
        // 准备测试数据
        String userId = "1";

        // 使用断言验证执行时间
        assertTimeout(Duration.ofMillis(100), () -> {
            userService.getUserById(userId);
        }, "Method should execute in less than 100 milliseconds");
    }
}

4. 使用Mockito进行模拟测试

4.1. 模拟测试

在一些复杂场景中,你可能需要模拟某些依赖项以隔离测试。Mockito可以帮助你轻松实现这一目标。下面是一个简单的例子,使用Mockito注解来编写模拟测试类,以验证UserService的行为:

假设你有一个简单的服务类UserService,它依赖于一个UserRepository。我们将使用Mockito来模拟UserRepository,以便在测试中隔离UserService

// UserRepository.java
@Repository
public interface UserRepository extends JpaRepository {
    Optional findById(String id);
}

// UserService.java
@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public String getUserById(String id) {
        User user = userRepository.findById(id).orElse(null);
        return user != null ? user.getName() : "Unknown User";
    }
}

编写测试类:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository; // 模拟UserRepository

    @InjectMocks
    private UserService userService; // 需要测试的UserService

    @Test
    public void testGetUserById() {
        // 准备测试数据
        String userId = "1";
        String expectedUserName = "User One";
        User mockUser = new User(userId, expectedUserName);
        when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser));

        // 调用被测试的方法
        String actualUserName = userService.getUserById(userId);

        // 验证结果
        assertEquals(expectedUserName, actualUserName, "User name should be User One");

        // 验证UserRepository的findById方法是否被调用
        verify(userRepository, times(1)).findById(userId);
    }

    @Test
    public void testGetUnknownUserById() {
        // 准备测试数据
        String userId = "2";
        when(userRepository.findById(userId)).thenReturn(Optional.empty());

        // 调用被测试的方法
        String actualUserName = userService.getUserById(userId);

        // 验证结果
        assertEquals("Unknown User", actualUserName, "User name should be Unknown User");

        // 验证UserRepository的findById方法是否被调用
        verify(userRepository, times(1)).findById(userId);
    }
}

在这个示例中,我们使用了以下Mockito注解:

  • @ExtendWith(MockitoExtension.class):使用Mockito的JUnit 5扩展。
  • @Mock:用于创建UserRepository的模拟对象。
  • @InjectMocks:用于创建UserService的实例,并将模拟的UserRepository注入到UserService中。

4.2. 与参数化结合使用

Mockito也可以与参数化测试结合使用,以测试不同的输入值。

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;

import java.util.Optional;
import java.util.stream.Stream;

import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class UserServiceParameterizedTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @ParameterizedTest
    @MethodSource("provideUserIdsAndNames")
    public void testGetUserById(String userId, String expectedUserName) {
        // 配置模拟对象的行为
        when(userRepository.findById(userId)).thenReturn(Optional.of(new User(userId, expectedUserName)));

        // 调用被测试的方法
        String actualUserName = userService.getUserById(userId);

        // 验证结果
        assertEquals(expectedUserName, actualUserName, "User name should match expected name");

        // 验证UserRepository的findById方法是否被调用
        verify(userRepository, times(1)).findById(userId);
    }

    private static Stream provideUserIdsAndNames() {
        return Stream.of(
            Arguments.of("1", "User One"),
            Arguments.of("2", "User Two"),
            Arguments.of("3", "User Three")
        );
    }
}

5. 使用Spring Boot提供的测试支持

除了上述基本用法外,Spring Boot还提供了一些额外的测试支持,比如@WebMvcTest@DataJpaTest等注解,分别用于测试Web层和数据访问层,帮助你更高效地编写单元测试。

6. 总结

单元测试是软件开发中不可或缺的一部分,它能帮助你快速发现和修复错误,确保代码的质量。Spring Boot框架提供了丰富的测试支持,帮助你轻松编写高效的单元测试。希望本文能对你有所帮助。

你可能感兴趣的:(spring,boot,junit)