SpringBoot + Kubernetes云原生微服务实践 - (6) 微服务测试设计和实践

微服务测试设计和实践

微服务测试的最大挑战:依赖。
解决方案是采用分而治之的策略:
a.先针对每一个微服务进行隔离测试,在对每一个微服务进行测试的时候再按照分层的方式进行隔离测试;测试过程中采用mock等技术来隔离依赖简化测试;
b.在确保每个微服务通过隔离测试后,再进行整个应用的端到端集成测试

微服务测试分类和技术

  1. Spring(Boot)应用分层
    • controller
      • 服务的对外接口层,负责接收请求和发送响应
      • 中间涉及到消息,一般是json跟对象间的转换,术语叫做序列化,一般由框架封装
      • 控制器需要对输入数据进行校验,由开发人员定制
      • 控制器一般不包含业务逻辑,而是将业务逻辑代理给服务层去做
    • service + domain
      • 相互配合,共同实现服务的业务逻辑
      • 两种做法:一是业务逻辑主要包含在领域对象内,服务层是领域对象的协调器,称为胖领域模型;一是业务逻辑主要包含在服务层,领域对象除了状态字段基本不包含业务逻辑,称为瘦领域模型
    • proxy
      • 微服务一般不孤立存在,而是会和其他服务协作实现功能,这时服务会通过服务代理proxy去访问其他服务
      • 服务代理其实就是目标服务的一个客户端;如果采用feign这样的框架,可以实现基于接口代理直接访问目标服务,背后是采用反射+httpClient操作的方式来实现
    • repository
      • 封装领域对象的数据库存取逻辑,底层基于ORM框架,如hibernate、mybatis;hibernate相对较重,抽象层次较高;mybatis相对轻量、灵活
      • 领域驱动设计(DDD)中的一种数据访问设计模式
      • 在spring data中,直接支持基于repository接口的抽象数据访问层,背后是通过接口反射+调用ORM操作数据库
    • 重点
      • 每个层次及层次间的交互是测试需要覆盖的重点
      • 持久层和服务代理层,涉及到跨越网络边界的调用,较容易出问题
      • 控制器是对外的接口,对入参的校验也特别需要关注
  2. 单元测试(Unit Test):最细粒度的测试
    • 工作在方法、类、若干个协作类级别
    • 单元测试的点:
      • controller层的测试:入参校验、错误处理逻辑;spring提供MockMVC机制,方便对控制器进行单元测试;对于涉及service层的调用,或proxy调用的地方,采用mock进行隔离
      • service和domain的测试:如果有业务规则逻辑计算,需进行充分的单元测试,保证业务逻辑规则的正确性;对于涉及repository层的调用,或proxy调用的地方,采用mock进行隔离
      • repository层的测试:repository层涉及领域对象的存储和修改,是微服务应用的底层基础,为了保证数据存取逻辑的正确性,repository层需要充分的测试;因为持久层涉及和数据库进行跨进程网络的交互,为了方便单元测试,在单元测试时使用嵌入式内存数据库,通过隔离外部依赖,让测试做到自包含,这样单元测试更稳定,反馈周期更快;比如在spring进行单元测试时,可使用H2内存数据库暂时替代外部数据库
      • 对于proxy层,基本是目标服务的接口,没有特别的业务逻辑,没有必要进行单元测试;对于设计底层通讯和错误处理的部分,由集成测试进行覆盖
      • 对于微服务中抽取出来的公共类,由于逻辑相对独立,适合充分的单元测试
    • 工具:
      • junit
      • mockito
      • spring提供的MockMVC、@SpringBootTest
    • 单元测试即使有充分的覆盖度,最多也只能保证每个层次独立工作的正确性,不能保证层次间协作逻辑的正确性,更不能保证系统工作的正确性
  3. 集成测试(Integration Test)
    • 针对组件之间有交互的地方,保证服务接口和通讯链路的正确性
    • 微服务场景下,集成测试的点包括:
      • 服务通过proxy访问外部服务的地方:需要测试请求响应交互逻辑,包括成功和失败、底层序列化、http处理、请求参数(包括http头参数)校验、错误处理逻辑;对远端服务集成测试时,为避免状态依赖,可以mock后端或预置一些mock数据,因为集成重点是发现接口交互错误,而不是后台逻辑
      • 持久层通过网络存取远程数据库的地方
    • 与单元测试互为补充,一个关注交互,一个关注内核模块
  4. 组件测试(Component Test)
    • 讲一个微服务看作一个独立的逻辑组件,不关心内部细节,而是看作一个黑箱,仅对其暴露的公开接口进行测试,这时一般需要把微服务的外部依赖给mock掉,这样才能保证其逻辑独立性
    • 对外部依赖mock有两种方式:
      • 组件内部Mock:具有更好的自包含性和测试稳定性,是开发人员常用的方法;spring支持mockbean,直接把依赖的proxy给mock掉;wiremock工具也常用来mock外部依赖,比mockbean工作在更低层次的协议层次,可做更细粒度的mock控制
      • 组件外部Mock:这种方式对组件无侵入,通过搭建独立的mock service来实现,是测试人员常用的测试方法,可在mock service上定制各种灵活复杂的微服务测试场景;高级的mock service还支持录制回放的功能、模拟网络延迟、随机失败的场景;业界称为API simulation;工具有HoverFly、mbtest
  5. 契约测试(Contract Test)
    • 由来
      • 微服务作为业务服务的提供方,需要有消费者使用才能共同产生业务价值,而服务提供方和消费方之所以能够正确交互,是因为他们之间共同约定并遵守一个契约,契约规范了双方交互输入输出所必须的字段和格式;同时服务提供方一直在升级当中
      • 如果没有相应的测试手段,服务提供方可能由于某次疏忽而break了某个消费方,现实生产中经常发生
      • 所以需要契约测试,来保证提供方和消费方之间的契约没有被违反
    • 契约测试一般由消费方开发,根据自己的业务需要定义契约,先验证自己测试通过,然后将契约交给生产方去验证;相当于消费方提供给服务方的接口需求,可以用来驱动服务方的生产设计和开发,所以业界称为消费者驱动契约(Consumer Driven Contract)
    • 工具:PACT、Spring-cloud-contract
  6. 契约驱动测试
    • Contract is input for Mock Provider
    • Contract is input for Mock Consumer
  7. 端到端测试(End-to-End Test)
    • 将整个系统看作一个黑盒,通过其接口(gui或api)对整个应用整体进行业务功能和非功能的测试
    • 工具:Selenium、REST-assured
  8. 总结

    分类功能
    单元测试确保类、模块功能正确
    集成测试确保组件间接口、交互、链路正确
    组件测试确保微服务作为独立整体,接口功能正确
    契约测试确保服务提供方和消费方都遵循契约规范
    端到端测试确保整个应用满足用户需求
    探索测试手工探索学习系统功能,改进自动化测试

测试金字塔和实践

  1. 测试金字塔
    • Unit -> Integration -> Component -> End-to-End -> Exploratory
    • 数量减少,粒度变粗,覆盖面增加,稳定性降低,测试变慢
  2. 端到端测试实践
    • 粒度最粗, 涉及到依赖、状态、异步等可变因素很多,是最不稳定的测试
    • 开发投入和维护成本最高
    • 最佳实践
      • 80/20,聚焦核心业务服务
      • 用户使用场景驱动
      • 适当使用Mock来覆盖不稳定的测试点
      • 规范测试环境和环境自动化:如k8s一键创建微服务测试环境
      • 测试数据管理:快速创建测试数据
      • 灰度测试:线上测试,一部分新功能让一部分用户使用,如beta测试,通过后再放量
      • 生产监控:弥补线下测试的不足

Test Case Review ~ 单元测试

  1. 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);
        }
    
        ...
    }
  2. 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 ~ 集成测试

  1. repository访问外部数据库,涉及到跨进程调用
    • 代码略
  2. 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 ~ 组件测试

  1. 示例:测试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);
        }
    }

测试补充

  1. Mock vs. Spy
    • Mock针对接口场景,Spy针对类的场景,部分mock
    • 如 xyz.staffjoy.account.service.helper.ServiceHelperTest
  2. BDD:行为驱动测试
    • 面向用户的接受测试,通常由产品、开发、QA协作开发
    • 使用贴近用户产品的语言
    • faraday项目里使用的spock BDD框架
    • REST-assured也支持BDD
  3. 性能测试
    • JMeter:UI操作,可编程性不好
    • Gatling:基于脚本,CI/CD集成能力好