本文棣属于 Spring API 服务开发系列。

  1. Spring Boot API 服务开发指南
  2. Spring Boot API 服务测试指南
  3. Spring Cloud 微服务开发指南
  4. Spring Cloud OAuth2 微服务认证授权

Spring Boot 测试支持

自动化测试的重要性不言而喻,尤其是对于业务复杂的企业应用,单靠人工测试很难保障对业务逻辑的全覆盖。如果项目采用敏捷开发,那么人工测试的工作量将非常大。Spring Boot 自然不会忽视对应用测试的支持,与应用开发一样,只需引入少量依赖,即可自动开启相关测试工具的支持。

Spring Boot 提供了 spring-boot-testspring-boot-test-autoconfigure 两个测试相关的模块,前者提供了核心组件,后者提供了自动配置。只需引入 spring-boot-starter-test 这个 Starter,即可自动引入这两个模块,以及 JUnit 5(Jupiter)、AssertJ、Hamcrest、Mockito、JSONassert、JsonPath 等其它常用的测试工具。

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>

上面的 exclusions 里排除了对 JUnit 4 的支持,如果你的项目里需要使用 JUnit 4,那么可以删除。

接下来将分别讲解如何测试仓库、用例和 API 等应用各层级的代码,完整代码可从 GitHub 获取 Spring Boot in Practice

测试仓库(Repository)

在我们的 API 服务里,仓库就是 DAO(数据访问对象)。如果仓库只是进行简单的增删改查操作,那么测试的必要性不大,但如果里面使用了复杂的自定义 SQL,那还是有必要对其进行测试的。测试仓库依赖数据库服务,数据库服务不好 Mock,并且 Mock 的意义也不大。但如果直接使用外部的数据库服务,测试又会变得很重。因此可以采取折中的方式,使用一个内嵌的内存数据库服务,比如 H2。它支持标准 SQL,资源消耗少,启动和销毁都非常快。

首先需要引入 H2 数据库依赖。

		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>test</scope>
		</dependency>

然后准备 H2 数据库的迁移脚本 db/migration/h2/V1__Initial_create_tables.sql。因为 MySQL 对标准 SQL 进行了许多扩展,因此无法直接使用 MySQL 的迁移脚本来迁移 H2 数据库。Flyway 会自动根据当前连接的数据类型(vendor)来到对应的目录中去寻找迁移脚本。有关迁移脚本的具体内容这里就不展开了,无非就是按照标准 SQL 语法把 MySQL 的迁移脚本修改一下。运行测试用例之前,Flyway 会自动完成数据库的迁移,创建好相关表。

最后来编写测试用例。

package net.jaggerwang.sbip.adapter.repository;

...

@DataJpaTest
@ContextConfiguration(classes = {CommonConfig.class, JpaConfig.class})
@EnabledIfSystemProperty(named = "test.repository.enabled", matches = "true")
public class UserRepositoryTests {
    @Autowired
    private JPAQueryFactory jpaQueryFactory;

    @Autowired
    private UserJpaRepository userJpaRepository;

    @Autowired
    private UserFollowJpaRepository userFollowJpaRepository;

    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository =
                new UserRepositoryImpl(jpaQueryFactory, userJpaRepository, userFollowJpaRepository);
    }

    @Test
    void save() {
        var userEntity = UserEntity.builder().username("jaggerwang").password("123456").build();
        var savedUser = userRepository.save(userEntity);
        assertThat(savedUser).hasFieldOrPropertyWithValue("username", userEntity.getUsername())
                .hasFieldOrPropertyWithValue("password", userEntity.getPassword());
    }
}

其中需要注意的点如下:

  • 最重要的是使用 @DataJpaTest 来注解测试类,这个注解会启用 JPA 相关的自动配置,其它的自动配置不会启用,以免影响测试性能。
  • 默认会从当前包开始往上搜寻 @SpringBootConfiguration 注解的应用配置,以便从中找到应用里的 JPA Entities 和 Repositories,也可以使用 @ContextConfiguration 注解来显示指定应用配置。
  • @EnabledIfSystemProperty 注解用来控制是否启用该测试,以方便外部通过系统属性来开启或关闭该测试。
  • @DataJpaTest 不会自动配置组件扫描,因此需要自己创建要被测试的 UserRepositoryImpl 对象,它是底层 JPA Repository 的包装,实现了用例层里定义的接口 UserRepository
  • 被测试方法返回的结果使用 AssertJ 提供的 assertThat() 方法转化成了 ObjectAssert 对象,以方便验证结果对象的属性及其值是否符合预期。

测试用例(Usecase)

由于项目采用了 干净架构,用例层的所有外部依赖都抽象成为了接口,因此单元测试变得非常容易。单元测试不依赖 Spring Boot 提供的任何功能,包括自动配置和 ApplicationContext 等,就是标准的 JUnit 测试用例。不过需要提供依赖接口的模拟实现,借助于 Mockito 可以很容易地办到。

package net.jaggerwang.sbip.usecase;

...

@ExtendWith(SpringExtension.class)
@EnabledIfSystemProperty(named = "test.usecase.enabled", matches = "true")
public class UserUsecaseTests {
    private UserUsecase userUsecase;

    @MockBean
    private UserRepository userRepository;

    @MockBean
    private RoleRepository roleRepository;

    @MockBean
    private RandomGenerator randomGenerator;

    @MockBean
    private PasswordEncoder passwordEncoder;

    @BeforeEach
    void setUp() {
        userUsecase = new UserUsecase(userRepository, roleRepository, randomGenerator,
                passwordEncoder);
    }

    @Test
    void register() {
        given(passwordEncoder.encode(anyString())).will((invocation) -> invocation.getArgument(0));

        var userEntity = UserEntity.builder().username("jaggerwang").password("123456").build();
        given(userRepository.findByUsername(userEntity.getUsername())).willReturn(Optional.empty());

        var savedUser = UserEntity.builder().username(userEntity.getUsername())
                .password(passwordEncoder.encode(userEntity.getPassword())).build();
        given(userRepository.save(any(UserEntity.class))).willReturn(savedUser);

        var registeredUser = userUsecase.register(userEntity);
        assertThat(registeredUser).hasFieldOrPropertyWithValue("username", userEntity.getUsername())
                .hasFieldOrPropertyWithValue("password",
                        passwordEncoder.encode(userEntity.getPassword()));
    }
}

来看一下其中重要的关注点:

  • 测试类使用了 @ExtendWith(SpringExtension.class) 来注解,以便将 Spring TestContext 框架集成到 JUnit 的模型里,这样就可以在 JUnit 的测试用例里使用 @MockBean 这样的由 Spring TestContext 提供的注解。
  • 通过 @MockBean 注解来自动提供实现了某个接口的模拟对象,包括 UserRepositoryRandomGeneratorPasswordEncoder 等接口,模拟对象里的所有方法默认都是空的。
  • register() 测试用例里,首先需要提供被测试的 UserUsecases.register() 方法里所调用的 PasswordEncoder.encode()UserRepository.findByUsername()UserRepository.save() 等方法的模拟实现,也就是给每个方法指定一组或多组输入及其对应的输出。

测试 API

为了更接近于真实的运行环境,API 测试采用集成测试,使用真实的 MySQL 和 Redis 等外部依赖服务。相比于单元测试,集成测试的运行时间更长,但更能反映真实情况。实际测试中可以结合两者,单元测试可以在每次提交代码时都运行,而集成测试可以每小时或每天运行一次。

初始化和清理数据库

测试有访问数据库的代码需要保证每个测试用例都运行在一个干净的环境中,互不干扰。在每个测试用例执行之前需要初始化数据库,包括创建相关表,插入测试数据等。在执行之后还需要清理掉本次测试产生的数据,可以直接删除表,或者删除测试数据。我们的 API 测试需要模拟用户登录,因此需要准备一个用来登录的用户。

首先编写一个 init-db-test.sql 文件来初始化数据库。

INSERT INTO `user` (`id`, `username`, `password`) VALUES (1, 'jaggerwang', '$2a$10$UOCgLxghU78h4UvlZcjvIup9YrETv6tGmRjPPpMTQ.EjSRUsJzJJS');

这里只是插入了一条用户数据,表的创建交给了 Flyway,它会在启动测试时自动执行数据库迁移任务来创建,类似于应用启动时所做的工作。

然后编写一个 clean-db-test.sql 文件来清理测试数据。

DELETE FROM `user`;

这里只需清理初始化脚本里插入的数据。执行每个测试时默认会开启一个事务,在该事务中产生的数据,在测试用例结束时会自动回滚掉,因此无需清理。

测试 REST API

集成测试要比单元测试做更多的准备工作,每个集成测试用例都可以理解为是一个完整的应用。

package net.jaggerwang.sbip.api;

...

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Sql({"/db/init-db-test.sql"})
@Sql(scripts = {"/db/clean-db-test.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
@EnabledIfSystemProperty(named = "test.api.enabled", matches = "true")
public class RestApiTests {
    @Autowired
    private MockMvc mvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void login() throws Exception {
        var userEntity = UserEntity.builder().username("jaggerwang").password("123456").build();
        mvc.perform(post("/auth/login").contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userEntity))).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.code").value("ok"))
                .andExpect(jsonPath("$.data.user.username").value(userEntity.getUsername()));
    }

    @WithUserDetails("jaggerwang")
    @Test
    void logout() throws Exception {
        mvc.perform(get("/auth/logout")).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.code").value("ok"))
                .andExpect(jsonPath("$.data.user.username").value("jaggerwang"));
    }

    @WithUserDetails("jaggerwang")
    @Test
    void logged() throws Exception {
        mvc.perform(get("/auth/logged")).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.code").value("ok"))
                .andExpect(jsonPath("$.data.user.username").value("jaggerwang"));
    }

    @Test
    void register() throws Exception {
        var userEntity = UserEntity.builder().username("jagger001").password("123456").build();
        mvc.perform(post("/user/register").contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userEntity))).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.code").value("ok"))
                .andExpect(jsonPath("$.data.user.username").value(userEntity.getUsername()));
    }

    @WithUserDetails("jaggerwang")
    @Test
    void info() throws Exception {
        mvc.perform(get("/user/info").param("id", "1")).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.code").value("ok"))
                .andExpect(jsonPath("$.data.user.id").value(1));
    }
}

  • 首先需要使用 @SpringBootTest 注解来标注这是一个 Spring Boot 测试类,类似于 @DataJpaTest, 它会从当前包开始往上搜寻 @SpringBootConfiguration 注解的应用配置,以便开启自动配置和组件扫描。只不过这里会开启所有可用的自动配置和扫描全部组件,而不仅仅是 JPA 相关的。
  • 使用 @AutoConfigureMockMvc 来自动配置 MockMvc,这样不用启动真实的应用容器(比如 Tomcat)就可以进行 MVC 层的测试,以便提升测试速度。
  • 通过 @ActiveProfiles 注解启用了 test 属性配置文件 application-test.yml,可以在这里面去覆盖测试时所用的 MySQL 和 Redis 等服务的连接地址。切忌不可使用真实在用的服务,因为测试时会清理数据。
  • 两次使用 @Sql 注解来分别指定了在每个测试用例执行前后要执行的 SQL 脚本。
  • 使用 MockMvc 对象来发起 HTTP 请求并对返回结果执行验证。
  • 对于有认证要求的 API,可以使用 @WithUserDetails 注解来设置登录用户,使用该注解需要引入 spring-security-test 依赖。

测试 GraphQL API

GraphQL API 的测试跟 REST API 很类似,它们都是通过 HTTP 协议来对外提供服务,只不过 GraphQL API 只使用了一个 Endpoint,而不是多个。

package net.jaggerwang.sbip.api;

...

@SpringBootTest()
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Sql({"/db/init-db-test.sql"})
@Sql(scripts = {"/db/clean-db-test.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
@EnabledIfSystemProperty(named = "test.api.enabled", matches = "true")
public class GraphQLApiTests {
    @Autowired
    private MockMvc mvc;

    @Value("${graphql.url}")
    private String graphqlUrl;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void login() throws Exception {
        var userEntity = UserEntity.builder().username("jaggerwang").password("123456").build();
        var content = new ObjectMapper().createObjectNode();
        content.put("query", "mutation($user: UserInput!) { authLogin(user: $user) { id username } }");
        content.putObject("variables").putObject("user").put("username", userEntity.getUsername())
                .put("password", userEntity.getPassword());
        mvc.perform(post(graphqlUrl).contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(content))).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.errors").doesNotExist())
                .andExpect(jsonPath("$.data.authLogin.username").value(userEntity.getUsername()));
    }

    @WithUserDetails("jaggerwang")
    @Test
    void logout() throws Exception {
        var content = new ObjectMapper().createObjectNode();
        content.put("query", "query { authLogout { id username } }");
        mvc.perform(post(graphqlUrl).contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(content))).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.errors").doesNotExist())
                .andExpect(jsonPath("$.data.authLogout.username").value("jaggerwang"));
    }

    @WithUserDetails("jaggerwang")
    @Test
    void logged() throws Exception {
        var content = new ObjectMapper().createObjectNode();
        content.put("query", "query { authLogged { id username } }");
        mvc.perform(post(graphqlUrl).contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(content))).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.errors").doesNotExist())
                .andExpect(jsonPath("$.data.authLogged.username").value("jaggerwang"));
    }

    @Test
    void register() throws Exception {
        var userEntity = UserEntity.builder().username("jagger001").password("123456").build();
        var content = new ObjectMapper().createObjectNode();
        content.put("query", "mutation($user: UserInput!) { userRegister(user: $user) { id username } }");
        content.putObject("variables").putObject("user").put("username", userEntity.getUsername())
                .put("password", userEntity.getPassword());
        mvc.perform(post(graphqlUrl).contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(content))).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.errors").doesNotExist())
                .andExpect(jsonPath("$.data.userRegister.username").value(userEntity.getUsername()));
    }

    @WithUserDetails("jaggerwang")
    @Test
    void info() throws Exception {
        var content = new ObjectMapper().createObjectNode();
        content.put("query", "query(id: Int!) { userInfo(id: $id) { id username } }");
        content.putObject("variables").put("id", 1);
        mvc.perform(post(graphqlUrl).contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(content))).andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.errors").doesNotExist())
                .andExpect(jsonPath("$.data.userInfo.id").value(1));
    }
}

相比于 REST API 测试,需要额外注意的点如下:

  • 通过 @Value 注解来得到 GraphQL API Endpoint 的路径,而不是写死,这样测试用例的适应性更强。
  • 使用 ObjectMapper 来构造请求体,因为 GraphQL API 的请求体比较复杂,除了业务数据,还包含了路由等信息。

运行测试

本地运行

每种类型的测试用例默认都是关闭的,需要通过对应的系统属性来开启。

./mvnw -Dtest.repository.enabled=true test
./mvnw -Dtest.usecase.enabled=true test
./mvnw -Dtest.api.enabled=true test

对于 API 测试,需要先在测试属性文件 application-test.yml 里配置测试用的 MySQL 和 Redis 服务的地址。切忌使用真实在用的服务,以免数据被清除。

在 Docker 容器里运行

为了减轻准备测试环境的工作量,尤其是 API 集成测试,还可以使用 Docker 容器来运行测试用例。

docker-compose -p spring-boot-in-practice-usecase-test -f docker-compose.usecase-test.yml up
docker-compose -p spring-boot-in-practice-repository-test -f docker-compose.repository-test.yml up
docker-compose -p spring-boot-in-practice-api-test -f docker-compose.api-test.yml up

对于 API 集成测试,会自动通过 Docker 容器来启动一个临时的 MySQL 和 Redis 服务。下面是 API 集成测试的 Docker Compose 配置文件:

version: '3'
services:
  server:
    image: maven:3-jdk-11
    command: bash -c "cd /app && cp sources.list /etc/apt/ && cp settings.xml /root/.m2/ && mvn -Dtest.api.enabled=true test"
    environment:
      TZ: Asia/Shanghai
      SBIP_DEBUG: 'false'
      SBIP_LOGGING_LEVEL_REQUEST: INFO
      SBIP_SPRING_DATASOURCE_URL: jdbc:mysql://mysql/sbip
      SBIP_SPRING_DATASOURCE_USERNAME: sbip
      SBIP_SPRING_DATASOURCE_PASSWORD: 123456
      SBIP_SPRING_REDIS_HOST: redis
      SBIP_SPRING_REDIS_PORT: 6379
      SBIP_SPRING_REDIS_PASSWORD:
    volumes:
      - ~/.m2:/root/.m2
      - ./:/app
    depends_on:
      - mysql
      - redis
  mysql:
    image: mysql:8.0
    environment:
      TZ: Asia/Shanghai
      MYSQL_ROOT_PASSWORD: 123456
      MYSQL_DATABASE: sbip
      MYSQL_USER: sbip
      MYSQL_PASSWORD: 123456
  redis:
    image: redis:5.0
    environment:
      TZ: Asia/Shanghai
    

其中包含了三个容器,server、mysql 和 redis,分别用来执行测试、运行 MySQL 服务和运行 Redis 服务。Server 容器基于 Maven 镜像,它提供了一个运行测试的环境,通过 command 指令我们指定了执行测试的具体命令。Server 配置里还通过环境变量指定测试时使用运行在容器里的 MySQL 和 Redis 服务。为了避免每次执行测试都要去重新下载所有依赖包,这里把宿主机上的本地仓库挂载到了 Server 容器内,这样能大大降低执行测试的时间。

测试结束之后,可以使用下面的对应命令来获取测试结果。如果容器进程退出结果状态为 0 则表示测试通过,其它为失败。

docker inspect spring-boot-in-practice-usecase-test_server_1 --format='{{.State.ExitCode}}'
docker inspect spring-boot-in-practice-repository-test_server_1 --format='{{.State.ExitCode}}'
docker inspect spring-boot-in-practice-api-test_server_1 --format='{{.State.ExitCode}}'

测试结束后可以使用下面的对应命令来删除测试时创建的容器、网络等资源。

docker-compose -p spring-boot-in-practice-usecase-test -f docker-compose.usecase-test.yml down
docker-compose -p spring-boot-in-practice-repository-test -f docker-compose.repository-test.yml down
docker-compose -p spring-boot-in-practice-api-test -f docker-compose.api-test.yml down

参考资料

  1. Spring Boot Testing
  2. JUnit 5
  3. Mockito
  4. AssertJ
  5. Flyway