Skip to content

22测试集成:响应式Web应用程序如何进行测试? ​

上一讲,我们探讨了针对响应式系统的测试解决方案,也介绍了测试 Reactor 响应式流的系统方法。那么这一讲的内容仍然聚焦于此,我们来看看如何测试 Web 三层架构中的各层组件,即 Repository 层、Service 层和 Controller 层。

与测试单纯的 Reactor 编程组件不同,Web 应用程序不同层的组件之间存在自上而下的依赖关系。因此,我们将从 Repository 层开始自下而上来开展测试工作,并将对这些组件的测试使用不同的方案和技术。

测试响应式 Repository 层组件 ​

在"15 | MongoDB 集成:如何在响应式应用中访问 NoSQL 数据库"中,我完整地介绍了如何构建响应式数据访问层组件,并向你演示了 MongoDB 这款主流的响应式 NoSQL 数据库的使用方法。而这一讲我们先来聊聊如何对响应式数据访问层组件开展测试工作,同样将基于 MongoDB 来设计并执行测试用例。

与传统的关系型数据库一样,MongoDB 的测试也有两种主流的方法,一种是基于内置的嵌入式数据库,一种是基于真实的数据库。我们分别来看一下。

测试内嵌式 MongoDB ​

测试内嵌式 MongoDB 需要引入上一讲提到的 flapdoodle,这是一个内嵌式 MongoDB 数据库,与关系型数据库中所使用的 H2 内嵌式数据库类似,flapdoodle 允许我们在不使用真实 MongoDB 数据库的情况下编写测试用例并执行测试。

首先,在 Maven 中引入 flapdoodle 依赖的方法如下所示。

xml
<dependency>
        <groupId>de.flapdoodle.embed</groupId>
        <artifactId>de.flapdoodle.embed.mongo</artifactId>
        <scope>test</scope>
</dependency>

想要测试 MongoDB,就需要引入一个新的测试注解,即 @DataMongoTest。因为 @DataMongoTest 注解会使用测试配置自动创建与 MongoDB 的连接以及 ReactiveMongoTemplate 工具类,它默认使用的就是基于 flapdoodle 的内嵌式 MongoDB 实例。

接下来,我们对 ReactiveSpringCSS 案例中所实现的 ReactiveAccountRepository 进行测试,使用 Account 作为领域对象。ReactiveAccountRepository 封装了对 MongoDB 的各种操作,代码如下所示。

java
@Repository
public interface ReactiveAccountRepository extends 
    ReactiveMongoRepository<Account, String>, ReactiveQueryByExampleExecutor<Account> {
 
    Mono<Account> findAccountByAccountName(String accountName);
}

现在我们编写测试类 EmbeddedAccountRepositoryTest,测试用例代码如下所示,@DataMongoTest 注解为我们自动嵌入了 flapdoodle 数据库。

java
@RunWith(SpringRunner.class)
@DataMongoTest
public class EmbeddedAccountRepositoryTest {
 
    @Autowired
    ReactiveAccountRepository repository;
 
    @Autowired
    ReactiveMongoOperations operations;
    
    @Before
    public void setUp() {
        operations.dropCollection(Account.class);
 
        operations.insert(new Account("Account1", "AccountCode1", "AccountName1"));
        operations.insert(new Account("Account2", "AccountCode2", "AccountName2"));
        
        operations.findAll(Account.class).subscribe(
               account -> {
                   System.out.println(account.getId()
               );}
        );
    }
    
    @Test
    public void testGetAccountByAccountName() {
        Mono<Account> account = repository.findAccountByAccountName("AccountName1");
 
        StepVerifier.create(account)
           .expectNextMatches(results -> {
               assertThat(results.getAccountCode()).isEqualTo("AccountCode1");
               assertThat(results.getAccountName()).isEqualTo("AccountName1");
               return true;
        });
    }
}

可以看到上述代码实际上是由两个部分组成,首先使用 ReactiveMongoOperations 进行数据的初始化操作,你同样已经在第 15 讲中看到过类似的操作。然后,我们调用 ReactiveAccountRepository 中的 findAccountByAccountName() 方法获取数据,并通过 StepVerifier 工具类执行测试,这里使用了 expectNextMatches() 方法来执行断言。

以上测试用例的编写和执行都比较简单,为了验证 @DataMongoTest 注解是否生效以及 flapdoodle 数据库中具体生成的数据,我们有必要对测试用例执行过程中产生的日志进行分析,相关日志如下所示(为了方便查看,我只选取了日志中的一部分内容)。

xml
	...
	[localhost:63506] org.mongodb.driver.cluster               : Monitor thread successfully connected to server with description ServerDescription{address=localhost:63506, type=STANDALONE, state=CONNECTED, ok=true, version=ServerVersion{versionList=[3, 2, 2]}, minWireVersion=0, maxWireVersion=4, maxDocumentSize=16777216, roundTripTimeNanos=1720260}
	 [localhost:63506] org.mongodb.driver.cluster               : Discovered cluster type of STANDALONE
	[main] c.t.p. EmbeddedAccountRepositoryTest: Started EmbeddedAccountRepositoryTestin 16.732 seconds (JVM running for 19.935)
	 [Thread-6] o.s.b.a.mongo.embedded.EmbeddedMongo     : 2018-06-28T11:58:18.870+0800 I NETWORK  [initandlisten] connection accepted from 127.0.0.1:63520 #3 (3 connections now open)
	 [main] org.mongodb.driver.connection            : Opened connection [connectionId{localValue:3, serverValue:3}] to localhost:63506
	 [Thread-6] o.s.b.a.mongo.embedded.EmbeddedMongo     : 2018-06-28T11:58:18.898+0800 I COMMAND  [conn3] CMD: drop test.account
	Account(id= Account1, accountCode= AccountCode1, accountName= AccountName1)
	Account(id= Account2, accountCode= AccountCode2, accountName= AccountName2)
	...

从上述日志中,我们可以清晰看到 flapdoodle 正在运作,并且成功完成了数据初始化操作。

下面再来看看另一种,测试基于真实的 MongoDB 数据库。

测试真实 MongoDB ​

测试真实 MongoDB 时我们不需要引入 flapdoodle 依赖,但同样需要使用 @ DataMongoTest注解。

接下来我们编写 LiveAccountRepositoryTest 类来对 ReactiveAccountRepository 进行测试,LiveAccountRepositoryTest 使用了真实的 MongoDB 数据库环境,代码如下所示。

java
@RunWith(SpringRunner.class)
@DataMongoTest(excludeAutoConfiguration = EmbeddedMongoAutoConfiguration.class)
public class LiveAccountRepositoryTest {
 
    @Autowired
    ReactiveAccountRepository repository;
 
    @Autowired
    ReactiveMongoOperations operations;
 
    @Before
    public void setUp() {
        operations.dropCollection(Account.class);
 
        operations.insert(new Account("Account1", "AccountCode1", "AccountName1"));
        operations.insert(new Account("Account2", "AccountCode2", "AccountName2"));
        
        operations.findAll(Account.class).subscribe(
               account -> {
                   System.out.println(account.getId()
               );}
        );
    }
    
    @Test
    public void testGetAccountByAccountName() {
        Mono<Account> account = repository.findAccountByAccountName("AccountName1");
 
        StepVerifier.create(account)
           .expectNextMatches(results -> {
               assertThat(results.getAccountCode()).isEqualTo("AccountCode1");
               assertThat(results.getAccountName()).isEqualTo("AccountName1");
               return true;
        });
    }
}

相较于 EmbeddedAccountRepositoryTest 类,LiveAccountRepositoryTest 类只有一个地方不同,即如下语句。

java
@DataMongoTest(excludeAutoConfiguration = 
	EmbeddedMongoAutoConfiguration.class)

事实上,@DataMongoTest 注解能使 Spring Boot 中默认使用真实 MongoDB 数据库的配置内容失效,而自动采用内嵌式的 flapdoodle 数据库。显然,为了测试真实环境的 MongoDB,我们需要把内嵌式的 flapdoodle 数据库转换到真实的 MongoDB 数据库。上述代码展示了这一场景下的具体做法,即使用 excludeAutoConfiguration 显式排除 EmbeddedMongoAutoConfiguration 配置。

测试响应式 Service 层组件 ​

接下来,我们继续讨论三层架构中的中间层 Service 组件,并基于前面介绍的 ReactiveAccountRepository 类构建 Service 层组件并设计相应的测试用例。

我们首先需要完成 Service 层组件 AccountService 类的实现,代码如下所示。

java
@Service
public class AccountService {
    
    @Autowired
    private ReactiveAccountRepository accountRepository;
        
    public Mono<Account> getAccountById(String accountId) {
        
        return accountRepository.findById(accountId).log("getAccountById");
    }
    
    public Mono<Account> getAccountByAccountName(String accountName) {
        
        return accountRepository.findAccountByAccountName(accountName).log("getAccountByAccountName");
	}
}

对 AccountService 测试的难点在于如何隔离 ReactiveAccountRepository,即我们希望在不进行真实数据访问操作的前提下仍然能够验证 AccountService 中方法的正确性。尽管 AccountService 中的 getAccountByAccountName() 方法逻辑非常简单,只是对 ReactiveAccountRepository 中方法的封装,但从集成测试的角度讲,确保组件之间的隔离性是一条基本测试原则。

以下代码演示了如何使用 Mock 机制完成对 ReactiveAccountRepository 的隔离。我们首先通过 @MockBean 注解注入 ReactiveAccountRepository,然后基于第三方 Mock 框架 mockito 提供的 given/willReturn 机制完成对 ReactiveAccountRepository 中 getAccountByAccountName() 方法的 Mock。

java
@RunWith(SpringRunner.class)
@SpringBootTest
public class AccountServiceTest {
 
    @Autowired
    AccountService service;
    
    @MockBean
    ReactiveAccountRepository repository;
 
    @Test
    public void testGetAccountByAccountName() {
        Account mockAccount = new Account("Account1", "AccountCode1", "AccountName1");
        
     given(repository.findAccountByAccountName("AccountName1")).willReturn(Mono.just(mockAccount));
        
        Mono<Account> account = service.getAccountByAccountName("AccountName1");
        
        StepVerifier.create(account).expectNextMatches(results -> {
            assertThat(results.getAccountCode()).isEqualTo("AccountCode1");
            assertThat(results.getAccountName()).isEqualTo("AccountName1");
           return true;
        }).verifyComplete();
        
    }   
}

在集成测试中,Mock 是一种常用策略。通过上述代码,你可以看到 Mock 的实现一般都会采用类似 mockito 的第三方框架,而具体 Mock 方法的行为则通过模拟的方式来实现。与使用 Stub 机制不同,对于某一个或一些被测试对象所依赖的测试方法而言,编写 Mock 相对简单,只需要模拟被使用的方法即可。


测试响应式 Controller 层组件 ​

最后,我们再来讨论如何对位于最上层的 Controller 组件进行测试。我们同样基于它的下层组件 AccountService 来构建 AccountController,代码如下所示。

java
@RestController
@RequestMapping(value = "accounts")
public class AccountController {
 
    @Autowired
    private AccountService accountService;
 
    @GetMapping(value = "/{accountId}")
    public Mono<Account> getAccountById(@PathVariable("accountId") String accountId) {
 
        Mono<Account> account = accountService.getAccountById(accountId);
        return account;
    }
 
    @GetMapping(value = "accountname/{accountName}")
    public Mono<Account> getAccountByAccountName(@PathVariable("accountName") String accountName) {
 
        Mono<Account> account = accountService.getAccountByAccountName(accountName);
        return account;
    }
}

在测试 AccountController 类之前,我再给你介绍一个新的注解 @WebFluxTest,该注解是初始化测试 Controller 组件所必需的 WebFlux 基础设施。@WebFluxTest 注解的使用方法如下所示,这里我们构建了 AccountControllerTest 类来测试 AccountController。

java
@RunWith(SpringRunner.class)
@WebFluxTest(controllers = AccountController.class)
public class AccountControllerTest {
  
	@Autowired
	WebTestClient webClient;
	...
}

默认情况下,@WebFluxTest 注解会确保所有包含 @RestController 注解的 JavaBean 生成一个 Mock 的 Web 环境,但我们也可以指定想要使用的具体 Controller 类,例如上述代码中就显式指定了 AccountController 作为我们测试的具体目标类。

同时,@WebFluxTest 注解会自动注入 WebTestClient 工具类。WebTestClient 工具类专门用来测试 WebFlux 组件,在使用上无须启动完整的 HTTP 容器。WebTestClient 工具类提供的常见方法有下面几种。

  • HTTP 请求方法:支持 get()、post() 等常见的 HTTP 方法构造测试请求,并使用 uri() 方法指定路由。

  • exchange() 方法:用于发起 HTTP 请求,返回一个 EntityExchangeResult。

  • expectStatus() 方法:用于验证返回状态,一般可以使用 isOk() 方法来验证是否返回 200 状态码。

  • expectBody() 方法:用于验证返回对象体是否为指定对象,并利用 returnResult() 方法获取对象。

AccountControllerTest 类的完整代码如下所示,我把与测试相关的 import 语句也列在了这里,以便你了解各种工具类的由来。

java
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
 
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.reactive.server.EntityExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
 
import com.springcss.account.controller.AccountController;
import com.springcss.account.domain.Account;
import com.springcss.account.service.AccountService;
 
import reactor.core.publisher.Mono;
 
@RunWith(SpringRunner.class)
@WebFluxTest(controllers = AccountController.class)
public class AccountControllerTest {
 
    @Autowired
    WebTestClient webClient;
 
    @MockBean
    AccountService service;
 
    @Test
    public void testGetAccountById() {
        Account mockAccount = new Account("Account1", "AccountCode1", "AccountName1");       
 
     given(service.getAccountById("Account1")).willReturn(Mono.just(mockAccount));
 
        EntityExchangeResult<Account> result = webClient.get()
               .uri("http://localhost:8082/accounts/{accountId}", "Account1").exchange().expectStatus()
               .isOk().expectBody(Account.class).returnResult();
 
        verify(service).getAccountById("Account1");
        verifyNoMoreInteractions(service);
        
     assertThat(result.getResponseBody().getId()).isEqualTo("Account1");
     assertThat(result.getResponseBody().getAccountCode()).isEqualTo("AccountCode1");
    }
}

上述代码中,我们首先通过 mockito 提供的 given/willReturn 机制完成对 AccountService 中相关方法的 Mock,然后通过 WebTestClient 工具类完成 HTTP 请求的发送和响应的解析。同时,你可以注意到,这里还使用了 mockito 中的 verify() 和 verifyNoMoreInteractions() 方法来验证 AccountService 在测试用例执行过程中的参与情况,这也是非常有用的一种实践技巧。

小结与预告 ​

关于响应式 Web 服务的测试,我们需要考虑分层思想,即从数据流层出发分别对基于响应式的 Repository 层、Service 层以及 Controller 层进行测试。在整个测试过程中,测试注解发挥了核心作用。下表罗列了使用到的主要测试注解及其描述:

最后还是给你留一道思考题:在使用 Spring Boot 测试 Web 应用程序时,常见的测试注解你知道有哪些吗?

至此,测试组件就介绍完了,我们的课程也将告一段落,恭喜你坚持到了最后。在下一讲结束语中,我将对 Spring 中的响应式编程技术进行总结,并对它的后续发展进行展望。

点击链接,获取课程相关代码 ↓↓↓
https://github.com/lagoueduCol/ReactiveProgramming-jianxiang.git