SpringBoot + Kubernetes云原生微服务实践 - (6) 微服务测试设计和实践
微服务测试设计和实践
微服务测试的最大挑战:依赖。
解决方案是采用分而治之的策略:
a.先针对每一个微服务进行隔离测试,在对每一个微服务进行测试的时候再按照分层的方式进行隔离测试;测试过程中采用mock等技术来隔离依赖简化测试;
b.在确保每个微服务通过隔离测试后,再进行整个应用的端到端集成测试
微服务测试分类和技术
- Spring(Boot)应用分层
- controller
- 服务的对外接口层,负责接收请求和发送响应
- 中间涉及到消息,一般是json跟对象间的转换,术语叫做序列化,一般由框架封装
- 控制器需要对输入数据进行校验,由开发人员定制
- 控制器一般不包含业务逻辑,而是将业务逻辑代理给服务层去做
- service + domain
- 相互配合,共同实现服务的业务逻辑
- 两种做法:一是业务逻辑主要包含在领域对象内,服务层是领域对象的协调器,称为胖领域模型;一是业务逻辑主要包含在服务层,领域对象除了状态字段基本不包含业务逻辑,称为瘦领域模型
- proxy
- 微服务一般不孤立存在,而是会和其他服务协作实现功能,这时服务会通过服务代理proxy去访问其他服务
- 服务代理其实就是目标服务的一个客户端;如果采用feign这样的框架,可以实现基于接口代理直接访问目标服务,背后是采用反射+httpClient操作的方式来实现
- repository
- 封装领域对象的数据库存取逻辑,底层基于ORM框架,如hibernate、mybatis;hibernate相对较重,抽象层次较高;mybatis相对轻量、灵活
- 领域驱动设计(DDD)中的一种数据访问设计模式
- 在spring data中,直接支持基于repository接口的抽象数据访问层,背后是通过接口反射+调用ORM操作数据库
- 重点
- 每个层次及层次间的交互是测试需要覆盖的重点
- 持久层和服务代理层,涉及到跨越网络边界的调用,较容易出问题
- 控制器是对外的接口,对入参的校验也特别需要关注
- controller
- 单元测试(Unit Test):最细粒度的测试
- 工作在方法、类、若干个协作类级别
- 单元测试的点:
- controller层的测试:入参校验、错误处理逻辑;spring提供MockMVC机制,方便对控制器进行单元测试;对于涉及service层的调用,或proxy调用的地方,采用mock进行隔离
- service和domain的测试:如果有业务规则逻辑计算,需进行充分的单元测试,保证业务逻辑规则的正确性;对于涉及repository层的调用,或proxy调用的地方,采用mock进行隔离
- repository层的测试:repository层涉及领域对象的存储和修改,是微服务应用的底层基础,为了保证数据存取逻辑的正确性,repository层需要充分的测试;因为持久层涉及和数据库进行跨进程网络的交互,为了方便单元测试,在单元测试时使用嵌入式内存数据库,通过隔离外部依赖,让测试做到自包含,这样单元测试更稳定,反馈周期更快;比如在spring进行单元测试时,可使用H2内存数据库暂时替代外部数据库
- 对于proxy层,基本是目标服务的接口,没有特别的业务逻辑,没有必要进行单元测试;对于设计底层通讯和错误处理的部分,由集成测试进行覆盖
- 对于微服务中抽取出来的公共类,由于逻辑相对独立,适合充分的单元测试
- 工具:
- junit
- mockito
- spring提供的MockMVC、@SpringBootTest
- 单元测试即使有充分的覆盖度,最多也只能保证每个层次独立工作的正确性,不能保证层次间协作逻辑的正确性,更不能保证系统工作的正确性
- 集成测试(Integration Test)
- 针对组件之间有交互的地方,保证服务接口和通讯链路的正确性
- 微服务场景下,集成测试的点包括:
- 服务通过proxy访问外部服务的地方:需要测试请求响应交互逻辑,包括成功和失败、底层序列化、http处理、请求参数(包括http头参数)校验、错误处理逻辑;对远端服务集成测试时,为避免状态依赖,可以mock后端或预置一些mock数据,因为集成重点是发现接口交互错误,而不是后台逻辑
- 持久层通过网络存取远程数据库的地方
- 与单元测试互为补充,一个关注交互,一个关注内核模块
- 组件测试(Component Test)
- 讲一个微服务看作一个独立的逻辑组件,不关心内部细节,而是看作一个黑箱,仅对其暴露的公开接口进行测试,这时一般需要把微服务的外部依赖给mock掉,这样才能保证其逻辑独立性
- 对外部依赖mock有两种方式:
- 组件内部Mock:具有更好的自包含性和测试稳定性,是开发人员常用的方法;spring支持mockbean,直接把依赖的proxy给mock掉;wiremock工具也常用来mock外部依赖,比mockbean工作在更低层次的协议层次,可做更细粒度的mock控制
- 组件外部Mock:这种方式对组件无侵入,通过搭建独立的mock service来实现,是测试人员常用的测试方法,可在mock service上定制各种灵活复杂的微服务测试场景;高级的mock service还支持录制回放的功能、模拟网络延迟、随机失败的场景;业界称为API simulation;工具有HoverFly、mbtest
- 契约测试(Contract Test)
- 由来
- 微服务作为业务服务的提供方,需要有消费者使用才能共同产生业务价值,而服务提供方和消费方之所以能够正确交互,是因为他们之间共同约定并遵守一个契约,契约规范了双方交互输入输出所必须的字段和格式;同时服务提供方一直在升级当中
- 如果没有相应的测试手段,服务提供方可能由于某次疏忽而break了某个消费方,现实生产中经常发生
- 所以需要契约测试,来保证提供方和消费方之间的契约没有被违反
- 契约测试一般由消费方开发,根据自己的业务需要定义契约,先验证自己测试通过,然后将契约交给生产方去验证;相当于消费方提供给服务方的接口需求,可以用来驱动服务方的生产设计和开发,所以业界称为消费者驱动契约(Consumer Driven Contract)
- 工具:PACT、Spring-cloud-contract
- 由来
- 契约驱动测试
- Contract is input for Mock Provider
- Contract is input for Mock Consumer
- 端到端测试(End-to-End Test)
- 将整个系统看作一个黑盒,通过其接口(gui或api)对整个应用整体进行业务功能和非功能的测试
- 工具:Selenium、REST-assured
总结
分类 功能 单元测试 确保类、模块功能正确 集成测试 确保组件间接口、交互、链路正确 组件测试 确保微服务作为独立整体,接口功能正确 契约测试 确保服务提供方和消费方都遵循契约规范 端到端测试 确保整个应用满足用户需求 探索测试 手工探索学习系统功能,改进自动化测试
测试金字塔和实践
- 测试金字塔
- Unit -> Integration -> Component -> End-to-End -> Exploratory
- 数量减少,粒度变粗,覆盖面增加,稳定性降低,测试变慢
- 端到端测试实践
- 粒度最粗, 涉及到依赖、状态、异步等可变因素很多,是最不稳定的测试
- 开发投入和维护成本最高
- 最佳实践
- 80/20,聚焦核心业务服务
- 用户使用场景驱动
- 适当使用Mock来覆盖不稳定的测试点
- 规范测试环境和环境自动化:如k8s一键创建微服务测试环境
- 测试数据管理:快速创建测试数据
- 灰度测试:线上测试,一部分新功能让一部分用户使用,如beta测试,通过后再放量
- 生产监控:弥补线下测试的不足
Test Case Review ~ 单元测试
- case1:repository层测试,使用In Memory DB
- application.yml:account-svc\src\test\resources\application.yml
- AccountRepoTest:xyz.staffjoy.account.repo.AccountRepoTest
# ****** H2 In Memory Database Connection Info ******* spring: ... datasource: # use in-memory db for unit testing url: jdbc:h2:mem:staffjoy_account;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MYSQL username: sa password: driver-class-name: org.h2.Driver continue-on-error: false platform: h2 schema: classpath:/db/schema.sql h2: console: enabled: true jpa: hibernate: ddl-auto: validate show-sql: true properties: hibernate: format_sql: true output: ansi: enabled: always staffjoy: common: sentry-dsn: ${SENTRY_DSN:https:///1234888} # mock for test deploy-env: ${DEPLOY:V2} signing-secret: ${SIGNING_SECRET:TEST_SECRET} email-service-endpoint: http://email-service company-service-endpoint: http://company-service bot-service-endpoint: http://bot-service account-service-endpoint: http://localhost:8080 # for testing only intercom-access-token: ${INTERCOM_ACCESS_TOKEN:TEST_INTERCOM_ACCESS_TOKEN}
@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.NONE) @RunWith(SpringRunner.class) public class AccountRepoTest { @Autowired private AccountRepo accountRepo; @Autowired private AccountSecretRepo accountSecretRepo; private Account newAccount; @Before public void setUp() { newAccount = Account.builder() .name("testAccount") .email("") .memberSince(LocalDateTime.of(2019, 1, 20, 12, 50).atZone(ZoneId.systemDefault()).toInstant()) .confirmedAndActive(false) .photoUrl("https://staffjoy.xyz/photo/test.png") .phoneNumber("18001801266") .support(false) .build(); // sanity check accountRepo.deleteAll(); } @Test//(expected = DuplicateKeyException.class) public void createSampleAccount() { accountRepo.save(newAccount); assertTrue(accountRepo.existsById(newAccount.getId())); } @Test public void getAccountById() { accountRepo.save(newAccount); assertEquals(1, accountRepo.count()); Account foundAccount = accountRepo.findById(newAccount.getId()).get(); assertEquals(newAccount, foundAccount); } ... }
case2:controller层测试,校验输入输出是否合法
- xyz.staffjoy.company.controller.ut.CompanyControllerUnitTest
@RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMockMvc public class CompanyControllerUnitTest { @Autowired MockMvc mockMvc; @MockBean CompanyService companyService; @Autowired ObjectMapper objectMapper; CompanyDto newCompanyDto; @Rule public ExpectedException expectedEx = ExpectedException.none(); @Before public void setUp() { newCompanyDto = CompanyDto.builder() .archived(false) .name("test-company") .defaultDayWeekStarts("Monday") .defaultTimezone(TimeZone.getDefault().getID()) .build(); } @Test() public void testCreateCompanyAuthorizeMissing() throws Exception { MvcResult mvcResult = mockMvc.perform(post("/v1/company/create") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(newCompanyDto))) .andExpect(status().isOk()) .andReturn(); GenericCompanyResponse genericCompanyResponse = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), GenericCompanyResponse.class); assertThat(genericCompanyResponse.isSuccess()).isFalse(); assertThat(genericCompanyResponse.getCode()).isEqualTo(ResultCode.UN_AUTHORIZED); } @Test public void testCreateCompanyPermissionDeniedException() throws Exception { MvcResult mvcResult = mockMvc.perform(post("/v1/company/create") .contentType(MediaType.APPLICATION_JSON) .header(AuthConstant.AUTHORIZATION_HEADER, AuthConstant.AUTHORIZATION_COMPANY_SERVICE) .content(objectMapper.writeValueAsBytes(newCompanyDto))) .andExpect(status().isOk()) .andReturn(); GenericCompanyResponse genericCompanyResponse = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), GenericCompanyResponse.class); assertThat(genericCompanyResponse.isSuccess()).isFalse(); assertThat(genericCompanyResponse.getCode()).isEqualTo(ResultCode.UN_AUTHORIZED); } ... }
Test Case Review ~ 集成测试
- repository访问外部数据库,涉及到跨进程调用
- 代码略
- proxy调用外部服务,涉及到跨进程调用
- accountClient去调用accountController
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) @EnableFeignClients(basePackages = {"xyz.staffjoy.account.client"}) @Import(TestConfig.class) @Slf4j public class AccountControllerTest { @Autowired AccountClient accountClient; @Autowired EnvConfig envConfig; @MockBean MailClient mailClient; @MockBean BotClient botClient; @Autowired private AccountRepo accountRepo; @Autowired private AccountSecretRepo accountSecretRepo; private Account newAccount; @Before public void setUp() { // sanity check accountRepo.deleteAll(); // clear CURRENT_USER_HEADER for testing TestConfig.TEST_USER_ID = null; } @Test public void testUpdateAccount() { // arrange mock when(mailClient.send(any(EmailRequest.class))).thenReturn(BaseResponse.builder().message("email sent").build()); when(botClient.sendSmsGreeting(any(GreetingRequest.class))).thenReturn(BaseResponse.builder().message("sms sent").build()); // first account String name = "testAccount001"; String email = ""; String phoneNumber = "18001801236"; String subject = "Activate your Staffjoy account"; CreateAccountRequest createAccountRequest = CreateAccountRequest.builder() .name(name) .email(email) .phoneNumber(phoneNumber) .build(); // create GenericAccountResponse genericAccountResponse = accountClient.createAccount(AuthConstant.AUTHORIZATION_WWW_SERVICE, createAccountRequest); assertThat(genericAccountResponse.isSuccess()).isTrue(); AccountDto accountDto = genericAccountResponse.getAccount(); // update accountDto.setName("testAccountUpdate"); accountDto.setConfirmedAndActive(true); accountDto.setPhoneNumber("18001801237"); GenericAccountResponse genericAccountResponse1 = accountClient.updateAccount(AuthConstant.AUTHORIZATION_WWW_SERVICE, accountDto); log.info(genericAccountResponse1.toString()); assertThat(genericAccountResponse1.isSuccess()).isTrue(); AccountDto updatedAccountDto = genericAccountResponse1.getAccount(); assertThat(updatedAccountDto).isEqualTo(accountDto); // capture and verify ArgumentCaptor<GreetingRequest> argument = ArgumentCaptor.forClass(GreetingRequest.class); verify(botClient, times(1)).sendSmsGreeting(argument.capture()); GreetingRequest greetingRequest = argument.getValue(); assertThat(greetingRequest.getUserId()).isEqualTo(accountDto.getId()); } }
Test Case Review ~ 组件测试
- 示例:测试WWW服务
- xyz.staffjoy.web.controller.LoginControllerTest
@RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMockMvc @Slf4j public class LoginControllerTest { @Autowired MockMvc mockMvc; @MockBean AccountClient accountClient; @Autowired EnvConfig envConfig; @Autowired PageFactory pageFactory; @Autowired LoginController loginController; @Test public void testAleadyLoggedIn() throws Exception { MvcResult mvcResult = mockMvc.perform(post("/login") .header(AuthConstant.AUTHORIZATION_HEADER, AuthConstant.AUTHORIZATION_AUTHENTICATED_USER)) .andExpect(status().is3xxRedirection()) .andExpect(view().name("redirect:" + HelperService.buildUrl("http", "myaccount." + envConfig.getExternalApex()))) .andReturn(); } @Test public void testGet() throws Exception { MvcResult mvcResult = mockMvc.perform(get("/login")) .andExpect(status().isOk()) .andExpect(view().name(Constant.VIEW_LOGIN)) .andExpect(content().string(containsString(pageFactory.buildLoginPage().getDescription()))) .andReturn(); log.info(mvcResult.getResponse().getContentAsString()); } @Test public void testLoginAndLogout() throws Exception { String name = "test_user"; String email = ""; Instant memberSince = Instant.now().minus(100, ChronoUnit.DAYS); String userId = UUID.randomUUID().toString(); AccountDto accountDto = AccountDto.builder() .id(userId) .name(name) .email(email) .memberSince(memberSince) .phoneNumber("18001112222") .confirmedAndActive(true) .photoUrl("http://www.staffjoy.xyz/photo/test_user.png") .build(); when(accountClient.verifyPassword(anyString(), any(VerifyPasswordRequest.class))) .thenReturn(new GenericAccountResponse(accountDto)); when(accountClient.trackEvent(any(TrackEventRequest.class))) .thenReturn(BaseResponse.builder().message("event tracked").build()); when(accountClient.syncUser(any(SyncUserRequest.class))) .thenReturn(BaseResponse.builder().message("user synced").build()); MvcResult mvcResult = mockMvc.perform(post("/login") ).andExpect(status().is3xxRedirection()) .andExpect(view().name("redirect:" + HelperService.buildUrl("http", "app." + envConfig.getExternalApex()))) .andReturn(); Cookie cookie = mvcResult.getResponse().getCookie(AuthConstant.COOKIE_NAME); assertThat(cookie).isNotNull(); assertThat(cookie.getName()).isEqualTo(AuthConstant.COOKIE_NAME); assertThat(cookie.getPath()).isEqualTo("/"); assertThat(cookie.getDomain()).isEqualTo(envConfig.getExternalApex()); assertThat(cookie.isHttpOnly()).isTrue(); assertThat(cookie.getValue()).isNotBlank(); assertThat(cookie.getMaxAge()).isEqualTo(Sessions.SHORT_SESSION / 1000); // remember-me mvcResult = mockMvc.perform(post("/login") .param("remember-me", "true")) .andExpect(status().is3xxRedirection()) .andExpect(view().name("redirect:" + HelperService.buildUrl("http", "app." + envConfig.getExternalApex()))) .andReturn(); cookie = mvcResult.getResponse().getCookie(AuthConstant.COOKIE_NAME); assertThat(cookie).isNotNull(); assertThat(cookie.getName()).isEqualTo(AuthConstant.COOKIE_NAME); assertThat(cookie.getPath()).isEqualTo("/"); assertThat(cookie.getDomain()).isEqualTo(envConfig.getExternalApex()); assertThat(cookie.isHttpOnly()).isTrue(); assertThat(cookie.getValue()).isNotBlank(); assertThat(cookie.getMaxAge()).isEqualTo(Sessions.LONG_SESSION / 1000); // redirect-to mvcResult = mockMvc.perform(post("/login") .param("return_to", "ical." + envConfig.getExternalApex() + "/test")) .andExpect(status().is3xxRedirection()) .andExpect(view().name("redirect:http://ical." + envConfig.getExternalApex() + "/test")) .andReturn(); // redirect-to invalid mvcResult = mockMvc.perform(post("/login") .param("return_to", "signalx." + envConfig.getExternalApex() + "/test")) .andExpect(status().is3xxRedirection()) .andExpect(view().name("redirect:" + HelperService.buildUrl("http", "myaccount." + envConfig.getExternalApex()))) .andReturn(); // logout mvcResult = mockMvc.perform(get("/logout")) .andExpect(status().is3xxRedirection()) .andExpect(view().name("redirect:/")) .andReturn(); cookie = mvcResult.getResponse().getCookie(AuthConstant.COOKIE_NAME); assertThat(cookie).isNotNull(); assertThat(cookie.getName()).isEqualTo(AuthConstant.COOKIE_NAME); assertThat(cookie.getPath()).isEqualTo("/"); assertThat(cookie.getDomain()).isEqualTo(envConfig.getExternalApex()); assertThat(cookie.getValue()).isBlank(); assertThat(cookie.getMaxAge()).isEqualTo(0); } }
测试补充
- Mock vs. Spy
- Mock针对接口场景,Spy针对类的场景,部分mock
- 如 xyz.staffjoy.account.service.helper.ServiceHelperTest
- BDD:行为驱动测试
- 面向用户的接受测试,通常由产品、开发、QA协作开发
- 使用贴近用户产品的语言
- faraday项目里使用的spock BDD框架
- REST-assured也支持BDD
- 性能测试
- JMeter:UI操作,可编程性不好
- Gatling:基于脚本,CI/CD集成能力好