Skip to main content

自动化单元测试优秀实践

测试验收标准

测试代码覆盖率要求

至少需要达到85%(可以根据团队做调整)。

开发人员自测覆盖率

完成测试代码的编写后,不仅要查看测试代码是否通过,还应该在提交代码之前本地查看覆盖率是否达到团队要求。开发时,使用jacoco生成HTML格式的测试报告,并在浏览器中查看。

maven构建报告路径

project
└── target
└── jacoco

gradle构建报告路径

project
└── build
└── jacoco

测试原则(前10个确定实施

  1. 一个测试函数,只测试一个CASE(不是一个断言)
  2. 单元测试之间相互独立,如果数据需要多个测试用例使用,可以使用@BeforeEach或@AfterEach方法来设置用例的必要条件
  3. 在测试过程中,如果业务代码中存在静态变量,每次测试代码执行后应该重置静态变量的值,以免影响后续测试用例的测试结果
  4. 使用given、when、then的方式为测试函数命名,不要根据类名和函数名进行命名,而是应该使用基于逻辑或操作。这样如果函数名或类名做出了修改则不需要对测试代码做任何修改
  5. 在测试类和测试方法上添加@DisplayName("测试对象描述")注解,提高测试代码的可读性
  6. 在做异常断言时,不仅要判断异常类型,还要判断异常返回的message是否匹配

反例:

@Test
void givenNotExistsParentId_whenCreate_thenThrowParentNotFoundError() {
OrganizationCreateDTO createDTO = new OrganizationCreateDTO();
createDTO.setParentId("NOT_EXISTS_ORG_ID");
doReturn(Optional.empty()).when(organizationCommandRepository).findById(any(String.class));

assertThrows(BusinessError.class, () -> organizationCommandService.create(operator, createDTO));
}

正例:

@Test
void givenNotExistsParentId_whenCreate_thenThrowParentNotFoundError() {
OrganizationCreateDTO createDTO = new OrganizationCreateDTO();
createDTO.setParentId("NOT_EXISTS_ORG_ID");
doReturn(Optional.empty()).when(organizationCommandRepository).findById(any(String.class));

BusinessError error = assertThrows(BusinessError.class, () -> organizationCommandService.create(operator, createDTO));
assertEquals("error.organization.ParentNotFound", error.getCode());
}
  1. 不对非公有方法进行单独测试,放在公有方法中进行测试。对于私有方法中的逻辑,放在其中一个测试CASE中覆盖即可
  2. 打桩传入的参数尽量不用any,any在语义上更为宽泛,不能更好的体现测试行为
  3. 在使用maven或gradle对项目进行构建的时候,不要跳过test步骤,保证业务代码一直处于可测试的状态
  4. 测试代码和业务代码一样,需要定期进行重构,避免重复代码等问题
  5. 测试代码中如果需要抛出异常,尽量使用throws而不是try/catch,显示的表明程序出错
  6. 如果一个测试类中的测试方法过多(超过500行),可以拆分成多个测试类

测试代码的命名规范

测试包名命名规范

测试类所在包名与被测试所在包名一致

测试类名命名规范

测试命名为被测试类+Tests,如:OrganizationCommandServiceImplTests

测试函数命名规范

测试函数命名采用 given_when_then 命名法。

  • given 指定上下文,预设测试条件
  • when 当某些条件,所要执行的操作
  • then 期望的输出,需要检测的断言

当存在多个场景时用 And 连接。 例如 givenUsernameAndPassword_whenLogin_thenReturnToken

示例
  • 通常情况下,given描述给定的参数,when描述被测试方法及条件,then描述预期结果。

待测试的方法:

public Entity get(String id, Long revision) {
Entity entity = repository.findByIdAndDeletedIs(id, false).orElse(null);
if (entity == null) {
throw new NotFoundError("error.not-found");
}
if (!entity.getRevision().equals(revision)) {
throw new ConflictError();
}
return templateEntity;
}

测试用例:

givenwhenthen
1正确的 id 和 revision调用 get 方法返回查询到的实体
2正确的 id 和 错误的 revision调用 get 方法抛出 ConflictError
3错误的 id 与 任意revision调用 get 方法抛出 NotFoundError

测试函数:

@Test
void givenCorrectIdAndCorrectRevision_whenGet_thenReturnEntity() {
...
}

@Test
void givenCorrectIdAndWrongRevision_whenGet_thenThrowConflictError() {
...
}

@Test
void givenWrongIdAndRevision_whenGet_thenThrowNotFoundError() {
...
}
  • 测试函数名需要尽可能的描述清楚对应的测试用例,given中可以省略与测试路径无关的参数。

待测试的方法:

public void createCredentials(String userId, UserCreateDTO createDTO) {
if (createDTO.isDisabled()) {
return;
}
if (!ObjectUtils.isEmpty(createDTO.getEmail())) {
userCredentialCommandApi.createEmailCredential(userId, new EmailCredentialCommandDTO(createDTO));
}
if (!ObjectUtils.isEmpty(createDTO.getMobile())) {
userCredentialCommandApi.createMobileCredential(userId, new MobileCredentialCommandDTO(createDTO));
}
}

测试用例:

givenwhenthen
1disabled 为 True调用 createCredentials 方法return
2disabled 为 False、Email调用 createCredentials 方法仅创建Email凭证
3disabled 为 False、Mobile调用 createCredentials 方法仅创建Mobile凭证
4disabled 为 False、Email、Mobile调用 createCredentials 方法创建Email凭证与Mobile凭证

测试函数:

@Test
void givenDisabledIsTrue_whenCreateCredentials_thenReturn() {
...
}

@Test
void givenEmailAndDisabledIsFalse_whenCreateCredentials_thenCreateEmailCredential() {
...
}

@Test
void givenMobileAndDisabledIsFalse_whenCreateCredentials_thenCreateMobileCredential() {
...
}

@Test
void givenEmailAndMobileAndDisabledIsFalse_whenCreateCredentials_thenCreateEmailCredentialAndCreateMobileCredential() {
...
}

BDD风格代码结构

BDD 使用given when then的风格编写代码,通过定义用例和期望,从而描述程序的行为。BDD建议针对行为进行测试,考虑的是被测试对象在什么场景下,做出什么行为,产生什么结果,而不是实现细节,有利于提高代码的可维护性。

通过Mockito编写BDD风格的测试代码

在Mockito中通过使用BDDMockito类提供的功能来编写BDD风格的测试代码。Mockito中通过doReturn().when().invoke()或when().thenReturn()的形式来完成打桩,但是这两种方式在语义上都无法与BDD风格保持一致,BDDMockito提供了given()来代替when(),两者在功能上没有任何区别,只是given在语义上符合BDD风格。 不符合BDD风格:

@Test
@DisplayName("停用职员 - 成功停用")
void givenDisabledEmployeeId_whenDisable_thenDisableEmployee() {
EmployeeCommandEntity employeeCommandEntity = new EmployeeCommandEntity();
employeeCommandEntity.enable(operator);
String employeeId = "ENABLED_EMPLOYEE_ID";
doReturn(employeeCommandEntity)
.when(employeeCommandRepository)
.findByTenantIdAndIdAndRevisionAndDeletedIsFalse(TENANT_ID, employeeId, REVISION);
employeeCommandService.disable(operator, TENANT_ID, employeeId, REVISION);
verify(employeeCommandRepository, description("save方法未被调用")).save(argThat(EmployeeEntity::isDisabled));
verify(eventPublisher, description("职员停用事件未被触发")).publish(eq(employeeCommandService), eq(EntityCommandType.DISABLE),
argThat(EmployeeEntity::isDisabled));
}

符合BDD风格:

@Test
@DisplayName("停用职员 - 成功停用")
void givenDisabledEmployeeId_whenDisable_thenDisableEmployee() {
EmployeeCommandEntity employeeCommandEntity = new EmployeeCommandEntity();
employeeCommandEntity.enable(operator);
String employeeId = "ENABLED_EMPLOYEE_ID";
willReturn(employeeCommandEntity)
.given(employeeCommandRepository)
.findByTenantIdAndIdAndRevisionAndDeletedIsFalse(TENANT_ID, employeeId, REVISION);
employeeCommandService.disable(operator, TENANT_ID, employeeId, REVISION);
verify(employeeCommandRepository, description("save方法未被调用")).save(argThat(EmployeeEntity::isDisabled));
verify(eventPublisher, description("职员停用事件未被触发")).publish(eq(employeeCommandService), eq(EntityCommandType.DISABLE),
argThat(EmployeeEntity::isDisabled));
}

测试流程中各阶段的经验总结

数据mock

当被测试的单元具有外部依赖性时,需要用到 mock。 目的是隔离并专注于要测试的代码,而不是外部依赖项的行为或状态。

mock的两种方式(@mock&@spy

@mock: 模拟被标注的对象,里面的所有的方法都是假的,且返回值都为NULL。

@Mock
private MailConfigurationCommandService mailConfigurationCommandService;

@Test
void givenMailConfiguration_whenCreate_thenCreatedConfiguration() {
// 此处调用create方法并不会真正运行create方法内的代码
mailConfigurationCommandService.create(operator, createDTO);
}

@spy: 部分模拟,实际方法被调用,但仍然可以验证和打桩.

带有@Spy注解的字段可以在声明点显式初始化。如果不提供实例,Mockito将尝试找到零参数构造函数(甚至是私有的)创建一个实例。但是Mockito不能实例化内部类、局部类、抽象类和接口。

mockito并不会委托调用传递的真正实例,而是创建一个它的副本。因此,如果对一个spy对象调用了未打桩的方法,对真正的实例是没有影响的。

@Spy
private MailConfigurationUpdatedEventPublisher eventPublisher = new MailConfigurationUpdatedEventPublisher();

@Test
void givenExistingMailConfiguration_whenCreate_thenReturnModifiedConfiguration(){
// 此处调用publish方法会真正运行publish方法内的代码,但并不会真正的发布事件
eventPublisher.publish(mailConfigurationCommandService,EntityCommandType.UPDATE,updatedConfigEntity);
}

工程中使用@mock和@spy的原则

  • XXXXXXX
  • XXXXXXX
  • XXXXXXX
  • XXXXXXX

@InjectMocks

@InjectMocks 注解用于标记一个应该执行注入的字段,通常是单元测试中被测试的类。

Mockito将尝试按如下顺序注入mock: 通过构造函数注入、setter注入、属性注入。

@InjectMocks将只注入使用@Spy或@Mock注释创建的对象。

示例
// 被测试的类
@Component
public class NotificationTemplateCommandServiceImpl implements NotificationTemplateCommandService {

private final NotificationTemplateCommandRepository notificationCommandRepository;
private final NotificationTemplateContentCommandRepository notificationTemplateContentCommandRepository;

@Autowired
public NotificationTemplateCommandServiceImpl(
NotificationTemplateCommandRepository notificationCommandRepository,
NotificationTemplateContentCommandRepository notificationTemplateContentCommandRepository
) {
this.notificationCommandRepository = notificationCommandRepository;
this.notificationTemplateContentCommandRepository = notificationTemplateContentCommandRepository;
}

}

// 单元测试类
@ExtendWith(MockitoExtension.class)
class NotificationTemplateCommandServiceImplTestsTemplate {

@Mock
private NotificationTemplateCommandRepository notificationCommandRepository;

@Mock
private NotificationTemplateContentCommandRepository notificationTemplateContentCommandRepository;

@InjectMocks
private NotificationTemplateCommandServiceImpl notificationTemplateCommandService;

}

基础数据准备

对于测试用例中独立的部分写在各自的测试方法中,对于多个用例都需要的共通数据使用@BeforeAll或@BeforeEach做数据初始化

@BeforeEach与@BeforeAll如何选择

  • @BeforeEach:多个测试用例使用相同的数据时,且有数据修改,为了避免测试用例之间的相互影响。
  • @BeforeAll:对于引用数据类型,且多个测试用例都需要的数据,且不会变更的数据,应该使用BeforeAll进行初始化(对于简单的基本数据类型,可以在声明变量时进行赋值)。

应用示例

private static OperatorDTO operator;

private static AccessLogConfigUpdateDTO updateDTO;

private AccessLogConfigCommandEntity existingConfigEntity;

@BeforeAll
static void buildParameter(){
// 操作者信息
operator = new OperatorDTO();
operator.setId("000000000000");
// 配置更新表单数据
updateDTO = new AccessLogConfigUpdateDTO();
updateDTO.setRetentionHours(10);
}

@BeforeEach
void buildEntity(){
// 既有配置数据
existingConfigEntity = new AccessLogConfigCommandEntity();
existingConfigEntity.setRetentionHours(1);
existingConfigEntity.setRevision(1L);
}


@Test
void givenRevision_whenNotEquals_thenThrowException(){

// 既有配置数据
existingConfigEntity.setRevision(2L);

doReturn(Optional.of(existingConfigEntity)).when(accessLogConfigCommandRepository).findOneByDeletedIsFalse();
assertThrows(ConflictError.class, () -> accessLogConfigCommandService.set(operator, 1L, updateDTO));
}

/**
* 测试成功设置访问日志配置信息
*/
@Test
void givenExistingAccessLogConfig_whenUpdate_thenReturnModifiedConfig() {

// 更新后配置数据
AccessLogConfigCommandEntity updatedConfigEntity = new AccessLogConfigCommandEntity();
updatedConfigEntity.setRetentionHours(10);

doReturn(Optional.of(existingConfigEntity)).when(accessLogConfigCommandRepository).findOneByDeletedIsFalse();
AtomicReference<AccessLogConfigCommandEntity> accessLogConfigCommandEntity = new AtomicReference<>();
when(accessLogConfigCommandRepository.save(any(AccessLogConfigCommandEntity.class))).then((invocationOnMock) -> {
accessLogConfigCommandEntity.set(invocationOnMock.getArgument(0));
return accessLogConfigCommandEntity.get();
});

assertEquals(updatedConfigEntity.getRetentionHours(), accessLogConfigCommandService.set(operator, 1L, updateDTO).getRetentionHours());
}

执行优先级

无论书写顺序如何@BeforeAll的执行优先级都高于@BeforeEach,单书写规范建议@BeforeAll@BeforeEach之前

   /**
* 此用例只是用来展示@BeforeAll优先级高,书写时建议@BeforeAll在@BeforeEach前面
*/
// TODO例子待更新,目前没有比较贴合的例子
private static OperatorDTO operator;

@BeforeEach
void buildParameter2(){
// 操作者信息
operator.setId("000000000000");
}

@BeforeAll
static void buildParameter(){
// 操作者信息
operator = new OperatorDTO();
}

参数化测试

概念

一些函数式的接口,给定输入、期待特定输出,没有太多副作用,特别适合参数化测试(Parameterized Test)。 JUnit5提供了多种参数化测试的形式。

  • 参数化测试的注解有@CsvSource@CsvFileSource@ValueSource@MethodSource,我们常用的一般为@CsvSource@CevFileSource
  • CsvSourceCsvFileSource,虽然细节上有些差异,但本质上是一回事。 在实际使用中,CsvFileSource可能更实用些。 单独的csv文件,更容易用规范的方法来写入(如Excel)或生成(如Python)。
  • 而类似CsvSource的形式,优点是简单易用。 但还有更简单易用的办法,如ValueSource,直接把类型明确的测试case写在注解中。
  • 凡是需要参数化测试的地方,说明测试case数量不少,而且会需要随时增加。 比较下来,CsvFileSource才是最合适的。

依赖

CsvSource等参数化测试注解,都属于额外的junit-jupiter-params这个Group。 使用前,需要在build.gradle中添加依赖。

dependencies {
...
testImplementation("org.junit.jupiter:junit-jupiter-params:5.7.1")
}

应用场景

CsvSource

单元测试过程中,除了分支条件需要测试外,枚举的类型同样需要被注意。不同的参数类型的业务逻辑完全相同,此时如果写多个测试用例会使得测试代码变得臃肿,可读性下降。此时可以使用参数化测试的方式,将不同类型的数据都准备好,执行相同的测试用例。不仅可读性提高,同时也增强了可维护性。对于数据量不超过10条的场景,建议直接使用@CsvSource注解。

使用示例如下:

@ParameterizedTest
@DisplayName("组织认证 - 尚未认证的组织")
@CsvSource({
"00000000000000001,1619417905692, BUSINESS_LICENSE, 11010605882235, 'photo1;photo2;photo3', 申请商户认证",
"00000000000000002,1619417905692, IDENTITY_CARD, 110102202104135815, 'photo', 申请个人认证"
})
void givenUnSubmittedOrg_whenSubmit_thenSaveSubmittedOrg(String orgId, Long revision, @CsvToSubmitDTO OrganizationSubmitDTO submitDTO) {
}

通过@CsvSource传入的每一项,就相当于一个csv文件的一行,是一个String。 每一行中,可以有若干列,这里是一列,代表测试函数的四个输入参数的一组值。 为了让值中包含分隔符,,需要用单引号''包含。

CsvFileSource

既然可以用csv结构的方式来输入测试参数,那么可以不可以用csv文件来作为数据源? 当然可以。 JUnit5通过CsvFileSource,支持直接输入一个csv文件。

@ParameterizedTest
@CsvFileSource(resources = "/givenOperatorAndTenantIdAndAppIdParameterNameAndRevision_whenEntityIsEmpty_thenThrowConflictError.csv", numLinesToSkip = 1)
void givenOperatorAndTenantIdAndAppIdParameterNameAndRevision_whenEntityIsEmpty_thenThrowConflictError(
String tenantId, String appId, String parameterName, Long revision
){
// 操作者信息
OperatorDTO operator = new OperatorDTO();
operator.setId("000000000000");

when(parameterCommandRepository.findOne(any(Specification.class))).then((invocationOnMock) -> Optional.empty());

assertThrows(ConflictError.class, () -> parameterCommandService.delete(operator, tenantId, appId, parameterName, revision));
}

其中,resources = "/givenOperatorAndTenantIdAndAppIdParameterNameAndRevision_whenEntityIsEmpty_thenThrowConflictError.csv"是指定csv文件,这个文件在测试代码的resources目录下。 按照默认的Gradle目录结构(如下),givenOperatorAndTenantIdAndAppIdParameterNameAndRevision_whenEntityIsEmpty_thenThrowConflictError.csv需要放在src/test/resources/目录下。

src
├── main
│ ├── java
│ └── resources
└── test
├── java
└── resources

numLinesToSkip = 1是略过文件第1行,因为第一行通常是csv列名。 以下为csv文件内容示例,与CsvSource那边略有不同:

tenantId,appId,parameterName,revision
"000000000000000000001","50709049c2e849ddabbc2f443702b487","用户名","1599399143000"
"000000000000000000002","f4c842a3c440400fad64-cf0620b0d653","用户类型","1619399189000"
"000000000000000000003","f5c7d9c6c08841dfa845-944ed5399d26","用户基本信息","1639399143000"

无论是CsvSource还是CsvFileSource,如果某一个单元格的内容为空,比如三个参数都为空的一行,,,在参数转换时会得到null。 如果为了避免null而使用空字符串"",则需要在CsvSource中用"'','',''"、在CsvFileSource中用"","",""

参数类型转换

用于将单个参数转化成自定义数据格式,常见于List、Set、Map的转换。

  1. 定义自定义转换器
public class StringSetConverter extends SimpleArgumentConverter {

/**
* 字符串参数根据‘;’符号转换成Set
*/
@Override
protected Object convert(Object source, Class<?> targetType) throws ArgumentConversionException {
if (source instanceof String && Set.class.isAssignableFrom(targetType)) {
return new HashSet<>(Arrays.asList(((String) source).split("\\s*;\\s*")));
} else {
throw new IllegalArgumentException("Conversion from " + source.getClass() + " to "
+ targetType + " not supported.");
}
}
}
  1. 在测试用例中使用自定义类型转换器
@ParameterizedTest
@DisplayName("组织认证 - 尚未认证的组织")
@CsvSource({
"00000000000000001,1619417905692, 'photo1;photo2;photo3'",
"00000000000000002,1619417905692, 'photo'"
})
void givenUnSubmittedOrg_whenSubmit_thenSaveSubmittedOrg(String orgId, Long revision, @ConvertWith(StringSetConverter.class) Set<String> credentialList) {}

参数聚合

方式一:直接在测试代码中组装数据,适用于组装逻辑简单的场景。这种方式测试代码逻辑与数据依然耦合,可读性不高

// TODO
void xxxxx(ArgumentsAccessor arguments) {
XXX xxx = new XXX();
xxx.setX(arguments.getInt(0));
...
}

方式二:自定义聚合器,将组装自定义结构的逻辑提到自定义的聚合器中,并在测试方法的形参上通过@AggregateWith(Class clazz)来修饰对应的结构参数

  1. 自定义参数聚合器(将指定的参数组装成自定义结构并返回)
public class OrganizationSubmitDTOAggregator implements ArgumentsAggregator {

@Override
public Object aggregateArguments(ArgumentsAccessor arguments, ParameterContext context) throws ArgumentsAggregationException {
return new OrganizationSubmitDTO(
CredentialType.valueOf(arguments.getString(2)),
arguments.getString(3),
new HashSet<>(Arrays.asList(arguments.getString(4).split(";"))),
arguments.getString(5)
);
}
}
  1. 使用@AggregateWith注解修饰submitDTO参数,注解参数对应类中需要重写aggregateArguments方法,并将方法中返回的值映射到对应的参数中。
@ParameterizedTest
@DisplayName("组织认证 - 尚未认证的组织")
@CsvSource({
"00000000000000001, 1619417905692, BUSINESS_LICENSE, 11010605882235, 'photo1;photo2;photo3', 申请商户认证",
"00000000000000002, 1619417905692, IDENTITY_CARD, 110102202104135815, 'photo', 申请个人认证"
})
void givenUnSubmittedOrg_whenSubmit_thenSaveSubmittedOrg(String orgId, Long revision, @AggregateWith(OrganizationSubmitDTOAggregator.class) OrganizationSubmitDTO submitDTO) { // ... }

方式三:对于多个都需要使用@AggregateWith(Class clazz)的地方,可以自定义注解,让名称更简介,语义更明确

  1. 声明参数聚合器对应注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AggregateWith(OrganizationSubmitDTOAggregator.class)
public @interface CsvToSubmitDTO {
}
  1. 使用自定义注解修饰对应参数
@ParameterizedTest
@DisplayName("组织认证 - 尚未认证的组织")
@CsvSource({
"00000000000000001, 1619417905692, BUSINESS_LICENSE, 11010605882235, 'photo1;photo2;photo3', 申请商户认证",
"00000000000000002, 1619417905692, IDENTITY_CARD, 110102202104135815, 'photo', 申请个人认证"
})
void givenUnSubmittedOrg_whenSubmit_thenSaveSubmittedOrg(String orgId, Long revision, @CsvToSubmitDTO OrganizationSubmitDTO submitDTO) { // ... }

打桩

打桩就是告诉模拟对象当与之交互时执行何种行为过程。通过打桩,可以根据我们的需要返回一个特殊的值、抛出一个错误、触发一个事件,或者自定义方法在不同参数下的不同行为。

常见的打桩方式有两种 doReturn(...).when(...)when(...).thenReturn(...)

建议使用when(Object)进行打桩,因为它是参数类型安全的,而且更具可读性(特别是在打桩连续调用时)。

doReturn方法的参数类型是Object,因此在编译时不会进行类型检查

// 这段代码会在运行时抛出 WrongTypeOfReturnValue 异常
doReturn(true).when(operatorDTO).getId());

mock: 对于mock对象,打桩不会进行真实方法调用,返回打桩时设置的返回值。两种方式没有任何区别。

方式一:

// 数据准备
OrganizationCommandEntity result = new OrganizationCommandEntity();
// ....
doReturn(result).when(organizationRepository).save(any(OrganizationCommandEntity.class));

方式二:

// 数据准备
OrganizationCommandEntity result = new OrganizationCommandEntity();
// ....
when(organizationRepository.save(any(OrganizationCommandEntity.class))).thenReturn(result);

spy: 对于spy对象,打桩会进行真实方法调用,仅在返回时返回打桩时设置的返回值。

// TODO 补充代码示例

验证

状态验证

最常见的验证方式,通过assertEquals assertTrue assertFalse assertThrow等关键字来判断返回的值是否符合预期。

行为验证

对于业务代码中的外部依赖是否被调用,以及传入的参数是否符合预期,在测试过程中需要被重点关注。对于需要打桩的函数,如果根据预期输入的值和返回的模拟值和实际得到的值相等则可以间接的认为该函数被调用,且传入参数正确。但对于不需要进行打桩的函数或返回值为空的函数,无法通过这种形式来确认,需要通过Mockito中的verify进行确认。

业务代码(被测试对象):示例中除了not-found异常外,save方法和publish方法是否被调用且调用时传入的参数是否符合预期也是需要测试的内容。

@Override
@Transactional
public EmployeeCommandEntity create(OperatorDTO operator, String tenantId, EmployeeCreateDTO createDTO) {
EmployeeCommandEntity employeeCommandEntity = new EmployeeCommandEntity();
createDTO.copyPropertiesTo(employeeCommandEntity);
employeeCommandEntity.setTenant(
organizationCommandRepository
.findByTenantIdAndIdAndDeletedIsFalse(tenantId, tenantId)
.orElseThrow(() -> new NotFoundError("error.tenant.not-found"))
);
employeeCommandEntity.create(operator);
employeeCommandRepository.save(employeeCommandEntity);
eventPublisher.publish(this, EntityCommandType.CREATE, employeeCommandEntity);
return employeeCommandEntity;
}

测试代码:通过verify验证函数是否被调用,通过自定义Argument Matcher验证传入的参数是否符合预期。

@Test
@DisplayName("创建职员 - 成功创建")
void givenCreateDTO_whenCreate_thenSaveAndReturnEmployee() {
String tenantId = "TENANT_ID";
EmployeeCreateDTO createDTO = getCreateDTO();

doReturn(Optional.of(new OrganizationCommandEntity()))
.when(organizationCommandRepository)
.findByTenantIdAndIdAndDeletedIsFalse(tenantId, tenantId);

EmployeeCommandEntity employeeCommandEntity = employeeCommandService.create(operator, tenantId, createDTO);

assertAll(
() -> assertNotNull(employeeCommandEntity),
() -> assertEquals(createDTO.getName(), employeeCommandEntity.getName())
);
verify(employeeCommandRepository)
.save(argThat(employee -> employee.getName().equals(createDTO.getName())));
verify(eventPublisher).publish(eq(employeeCommandService),
eq(EntityCommandType.CREATE),
argThat(employee -> employee.getName().equals(createDTO.getName())));
}

参数

参数匹配器

Mockito中提供了ArgumentMacher对函数的参数进行约束,通常与verify一同使用。

@Test
@DisplayName("组织创建 - 创建TENANT组织")
void givenTenantOrg_whenCreate_thenParentIsRoot() {
OrganizationCreateDTO createDTO = new OrganizationCreateDTO();
createDTO.setId("000000000000001");
createDTO.setType(OrganizationType.TENANT);
int depth = 1;
assertEquals(depth, organizationCommandService.create(operator, createDTO).getDepth());
ArgumentCaptor<OrganizationCommandEntity> saveCaptor = ArgumentCaptor.forClass(OrganizationCommandEntity.class);
verify(organizationCommandRepository).save(saveCaptor.capture());
assertAll(
() -> assertEquals(depth, saveCaptor.getValue().getDepth()),
() -> assertEquals(OrganizationType.TENANT, saveCaptor.getValue().getType())
);
verify(orgPathUpdatedEventPublisher).publish(
eq(organizationCommandService),
argThat(updateDTO -> updateDTO.getOrgId().equals(saveCaptor.getValue().getId())
&& updateDTO.getPath().equals(saveCaptor.getValue().getPath()))
);
}

参数捕获器

Mockito中提供了ArgumentCaptor来获取函数对应的参数,适合在函数没有返回的情况下根据传入的参数进行断言的场景,通常与打桩一同使用。

@ParameterizedTest
@DisplayName("组织认证 - 认证成功")
@CsvHeader(
fromColumn = 2,
parameter = 2,
propertyNames = {"credentialType", "credentialNo", "credentialList[;]", "remark"}
)
@CsvSource({
"00000000000000001, 1619417905692, BUSINESS_LICENSE, 11010605882235, 'photo1;photo2;photo3', 申请商户认证",
"00000000000000002, 1619417905692, IDENTITY_CARD, 110102202104135815, 'photo', 申请个人认证"
})
void givenNotSubmittedOrg_whenSubmit_thenSaveSubmittedOrg(String orgId, Long revision,
@CsvParameter OrganizationSubmitDTO submitDTO) {

OrganizationCommandEntity submitOrg = new OrganizationCommandEntity();
submitOrg.setStatus(OrganizationStatus.NOT_SUBMITTED);
submitOrg.setRevision(1619417905692L);

doReturn(Optional.of(submitOrg)).when(organizationCommandRepository).findById(orgId);
ArgumentCaptor<OrganizationCommandEntity> saveCaptor = ArgumentCaptor.forClass(OrganizationCommandEntity.class);
doReturn(new OrganizationCommandEntity()).when(organizationCommandRepository).save(saveCaptor.capture());

organizationCommandService.submit(operator, orgId, revision, submitDTO);
assertEquals(OrganizationStatus.SUBMITTED, saveCaptor.getValue().getStatus());
}

测试内容(对哪些内容进行断言)

特殊示例

1.外部依赖中含有lamda表达式。

private ParameterCommandEntity get(String tenantId, String appId, String parameterName, Long revision) {
ParameterCommandEntity parameterEntity = parameterCommandRepository.findOne((root, query, builder) -> {
List<Predicate> predicates = new ArrayList<>();
if (tenantId == null) {
predicates.add(builder.isNull(root.get("tenantId")));
} else {
predicates.add(builder.equal(root.get("tenantId"), tenantId));
}
if (appId == null) {
predicates.add(builder.isNull(root.get("appId")));
} else {
predicates.add(builder.equal(root.get("appId"), appId));
}
predicates.add(builder.equal(root.get("name"), parameterName));
predicates.add(builder.equal(root.get("deleted"), false));
return builder.and(predicates.toArray(new Predicate[] {}));
}).orElse(null);

if (parameterEntity != null && revision != null && !revision.equals(parameterEntity.getRevision())) {
throw new ConflictError();
}
return parameterEntity;
}

解决方案:将私有化方法封装到repository中,代码如下:

/**
* 数据仓库
*/
public interface ParameterCommandRepository
extends JpaRepository<ParameterCommandEntity, String>, JpaSpecificationExecutor<ParameterCommandEntity> {
/**
* 取得参数。
* @param tenantId 租户 ID
* @param appId 应用 ID
* @param parameterName 参数名
* @return 参数实体
*/
default Optional<ParameterCommandEntity> findOne(String tenantId, String appId, String parameterName) {
return findOne((root, query, builder) -> {
List<Predicate> predicates = new ArrayList<>();
if (tenantId == null) {
predicates.add(builder.isNull(root.get("tenantId")));
} else {
predicates.add(builder.equal(root.get("tenantId"), tenantId));
}
if (appId == null) {
predicates.add(builder.isNull(root.get("appId")));
} else {
predicates.add(builder.equal(root.get("appId"), appId));
}
predicates.add(builder.equal(root.get("name"), parameterName));
predicates.add(builder.equal(root.get("deleted"), false));
return builder.and(predicates.toArray(new Predicate[]{}));
});
}
}

/**
*
*/
public class ParameterCommandServiceImpl implements ParameterCommandService {
/**
* 从数据库取得业务参数数据。
* @param tenantId 商户 ID
* @param appId 应用 ID
* @param parameterName 参数名
* @param revision 修订版本号
* @return 业务参数数据实体
*/
private ParameterCommandEntity get(String tenantId, String appId, String parameterName, Long revision) {
ParameterCommandEntity parameterEntity = parameterCommandRepository
.findOne(tenantId, appId, parameterName)
.orElse(null);
if (parameterEntity != null && revision != null && !revision.equals(parameterEntity.getRevision())) {
throw new ConflictError();
}
return parameterEntity;
}
}
  // 配置数据
ParameterCommandEntity parameterEntity = new ParameterCommandEntity();
parameterEntity.setRevision(2L);

doReturn(Optional.of(parameterEntity)).when(parameterCommandRepository).findOne(tenantId, appId, paramName);
  1. 有分支条件的情况(组装参数,for循环,调用私有化方法,私有化方法中包含分支逻辑,有返回值)
/** 每次保存最大记录数 */
private static final Integer SAVE_MAX_ENTITIES = 100;

/** 待保存审计数据队列 */
private final Queue<AccessLogCommandEntity> entityQueue = new ConcurrentLinkedQueue<>();

/**
* 保存用户操作审计数据。
* @param accessLogDetailDTO 用户操作审计数据
*/
@Override
public List<AccessLogCommandEntity> save(AccessLogDetailDTO accessLogDetailDTO) {
// 将审计数据加入到待保存队列
AccessLogCommandEntity accessLogCommandEntity = new AccessLogCommandEntity();
BeanUtils.copyProperties(accessLogDetailDTO, accessLogCommandEntity);
entityQueue.add(accessLogCommandEntity);

// 若待保存队列长度已达上限则将队列中的数据保存到数据库
if (entityQueue.size() >= SAVE_MAX_ENTITIES) {
return saveAll();
}
return new ArrayList<>();
}

/**
* 将待保存队列中的审计数据保存到数据库。
*/
private List<AccessLogCommandEntity> saveAll() {
List<AccessLogCommandEntity> accessLogCommandEntities = new ArrayList<>();
while(!entityQueue.isEmpty()) {
accessLogCommandEntities.add(entityQueue.poll());
}
if (!accessLogCommandEntities.isEmpty()) {
return accessLogCommandRepository.saveAll(accessLogCommandEntities);
}
return new ArrayList<>();
}

3.无分支条件的情况,直接调用外部依赖,没有返回值

/**
* 根据保留时长删除审计用户信息
* @param retentionHours 保留时长(小时)
*/
@Override
public void removeExpired(Integer retentionHours) {
accessLogCommandRepository.deleteByTimeLessThan(new Date(System.currentTimeMillis() - retentionHours * 3600000L));
}

测试案例代码如下:


/**
* 测试删除用户审计数据时参数为空
*/
@Test
void giveRetentionHours_whenRemoveExpiredAndRetentionHoursIsNull_thenThrowNullPointerException(){
// 配置参数
Integer retentionHours = null;
assertThrows(NullPointerException.class, () -> accessLogCommandService.removeExpired(retentionHours));
}

/**
* 测试删除用户审计数据时参数不为空
*/
@Test
void giveRetentionHours_whenRemoveExpiredAndIsNotNull_thenReturnTrue() {
// 配置参数
int retentionHours = 1;
Date startTime = new Date();
doNothing().when(accessLogCommandRepository).deleteByTimeLessThan(any());
accessLogCommandService.removeExpired(retentionHours);
Date endTime = new Date(System.currentTimeMillis() - retentionHours * 3600000L);
assertTrue(startTime.getTime() - endTime.getTime() >= 3600000L - 50 && startTime.getTime() - endTime.getTime() <= 3600000L);
}

4.querydsl(无分支逻辑方法内仅有外部以来,有返回值,并且有传入参数)

@Override
public Page<AppQueryEntity> search(AppQueryDTO queryDTO) {
return QuerydslHelper
.create(appQueryRepository)
.eq(app.platform, queryDTO.getPlatform())
.eq(app.code, queryDTO.getCode())
.likeRight(app.name, queryDTO.getName())
.eq(app.disabled, queryDTO.getDisabled())
.eq(app.deleted, Boolean.FALSE)
.fetch(queryDTO.pageable());
}

话题

  1. 多线程标注的方法怎么测试?只测逻辑发现不了问题。
  2. 在断言时 让意图更明确 比如:判断对的时候使用assertTrue而不是assertEquals(xxx, true);
  3. 在使用断言注解时,如果场景复杂 可以在后面添加错误后的说明 assertEquals(4, calcular.multiply(2,2), "最后一个参数传传错了"),尤其在assertAll的场景下很清晰的识别出哪一个断言没有通过
  4. 避免使用标准输出(system.out 或 system.err),单元测试应该是自动化完成,不应该是人工检查
  5. 对Repo中的存在的逻辑如何测试
  6. 不要写没有必要的断言,只对关键属性(影响流程)进行断言,比如:对一个实体中所有属性进行断言是否与createDTO中属性相同
  7. static和final修饰的方法 无法通过when().thenReturn()进行打桩
  8. 打桩支持迭代风格,when(iterator.next()).thenReturn("A").thenReturn("B"),第一次调用next()会返回A,第二次返回B。需要找真实案例。doNothing().doThrow(new RuntimeException()).when(iterator).remove(),第一次调用remove()什么都不做,第二次抛出RuntimeException异常
  9. 使用when().thenReturn()方式进行打桩时,打桩的方法的返回值不能为void。使用doReturn().when().method()方式可以。