背景

几个月前我开始接手了公司的 java 项目,虽不懂 java,但还是被实际的 java 工程惊讶到了:《后端工程圣殿形象的崩塌以及重建》。其中最重要的问题是没有一行测试,这简直就是在裸奔。今天正好需要写一个小的新功能,因此我有机会开始基于现有代码构建测试安全屏障,然后再有信心地添加新的功能。

尽管最近火起来的一篇阮一峰翻译的所谓国外程序员的酒后真言中说到,TDD 变成了一个邪教,但我还是不以为然,仍然是 TDD 的忠实拥趸。在改造自己本不熟悉的 java 项目中,再次运用了 TDD 的方法论,感觉对自己了解项目和 java 世界大有裨益,并且非常有信心自己把事情做对了,还可以证明给你看真的做对了。

在《后端工程圣殿形象的崩塌以及重建》一文里,出于当时的需求,只列举了如何 Mock 第三方响应,实现接口的测试驱动开发。今天因为需求的关系,涉及到数据库的增删改查,正好可以记录下如何在 java 项目里通过引入 h2 来加快测试驱动开发的过程。

当然,由于水平有限,代码上仍然写得很幼稚以及留有很多坏味道,但是我觉得目前够了,代码优化和重构,不应该专门建技术故事卡来做,而是应该持续不断的(小步)改进。虽然受水平所限,不能写出优雅的代码,但是最关键的是能够验证代码能够如期工作,为持续改进修建好了安全屏障。

其实,TDD 的过程很简单,但是却很少有人坚持,这个现象很奇怪。我觉得这个过程让人受益匪浅,尽管不能入大佬的法眼,但还是要记录一下这个过程。

h2

h2 是一个内存数据库,如果没有它,可能需要自己写很多数据库层面的 mock,或者不写 mock,就需要在测试运行前启动一个 docker 数据库。虽然 docker 只是一个普通的进程,但是毕竟和 java 无关,因此要和 java 的测试运行过程结合起来,还是需要一些额外脚本。无论这个脚本有多简单,比起使用内存数据库,仍然更耗资源。

需求

一个活动查询方法,需要支持额外的渠道参数。

现状代码

活动分页查询已经实现,主要代码如下。 java @Service public class CampaignQueryUseCaseImpl implements CampaignQueryUseCase {

@Autowired
private ICampaignService campaignService;
@Autowired
private CampaignDtoConvertor campaignDtoConvertor;
@Autowired
private CampaignEntityConvertor campaignEntityConvertor;
@Autowired
private PortalApiAdaptor portalApiAdaptor;

@Override
public PageInfo<CampaignDto> getCampaignList(CampaignPageQueryCondition condition) throws Exception {
    PageQuery pageQuery = condition.getPageQuery();
    PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize());
    LambdaQueryWrapper<Campaign> queryWrapper = getCampaignLambdaQueryWrapper(condition);
    List<Campaign> campaignList = campaignService.list(queryWrapper);
    PageInfo pageInfo = PageInfo.of(campaignList);

    List<CampaignDto> campaignDtoList = campaignDtoConvertor.convertCampaignEntityToDto(campaignList);
    enrichCampaignWithEvents(campaignDtoList);
    pageInfo.setList(campaignDtoList);

    return pageInfo;
}

private LambdaQueryWrapper<Campaign> getCampaignLambdaQueryWrapper(CampaignPageQueryCondition condition) {
    Campaign campaign = campaignEntityConvertor.convertCampaignConditionToEntity(condition);
    campaign.setCampaignName(null);  
    LambdaQueryWrapper<Campaign> queryWrapper = Wrappers.lambdaQuery(campaign);

    queryWrapper.like(condition.getCampaignName() != null, Campaign::getCampaignName, condition.getCampaignName());

    String[] campaignTypes = condition.getCampaignTypes();
    if (ArrayUtils.isNotEmpty(campaignTypes)) {
        queryWrapper.in(Campaign::getCampaignType, campaignTypes);
    }

    List<String> campaignIds = condition.getIds();
    queryWrapper.in(CollectionUtils.isNotEmpty(campaignIds), Campaign::getId, campaignIds);
    List<Byte> status = condition.getStatusList();
    if (CollectionUtils.isNotEmpty(status)) {
        queryWrapper.in(Campaign::getStatus, status);
    } else {
        Byte[] notInStatus = CollectionUtils.isEmpty(campaignIds) ?
                new Byte[]{CampaignStatus.DELETED.getStatus(), CampaignStatus.OFFLINE.getStatus()} : new Byte[]{CampaignStatus.DELETED.getStatus()};
        queryWrapper.notIn(Campaign::getStatus, notInStatus);
    }

    String orderBy = condition.getOrderBy();
    SFunction<Campaign, ?> field = OrderByFields.getField(orderBy);
    if (field != null) {
        if (condition.isAsc()) {
            queryWrapper.orderByAsc(field);
        } else {
            queryWrapper.orderByDesc(field);
        }
    }
    return queryWrapper;
}

}

可见使用了 LambdaQueryWrapper 去封装了查询条件 CampaignPageQueryCondition。新需求,很简单,需要在 CampaignPageQueryCondition 里额外增加渠道这个条件。

我对这些 LambdaQueryWrapper 以及究竟最后这个玩意儿怎么转换成 SQL 到数据库如何执行等都非常陌生,如果直接在 CampaignPageQueryCondition 加了个字段,谁知道能不能工作?

先看看现状是怎么工作的

目前同事以及很多我面试的候选者,都很依赖“开发”环境,就是只能在全部依赖都部署好了,才能做端到端的体验。我准备不这么做,我希望观察单个方法的现在行为,不需要部署,只需要给一些输入,执行这个方法,观察这个输出结果即可。

引入 h2

pom 文件添加 h2 依赖: xml com.h2database h2 1.4.200 test

第一个测试用例

写第一个新需求的测试用例,当数据库里没有任何记录时,应返还 0 条结果: java @SpringBootTest @RunWith(SpringRunner.class) @ActiveProfiles(test) class CampaignQueryUseCaseImplTest { @Autowired private CampaignServiceImpl campaignService;

private CampaignQueryUseCaseImpl sut;

@BeforeEach
public void initEach() {
    val mockPortalApiAdapter = new PortalApiAdaptorFallback();
    sut = new CampaignQueryUseCaseImpl(new CampaignEntityConvertorImpl(), campaignService, new CampaignDtoConvertorImpl(), mockPortalApiAdapter);
}

@Test
@Sql(statements = DELETE FROM campaign)
void getCampaignListEmptyRecords() {
    val condition = new CampaignPageQueryCondition();
    val pageQuery = new PageQuery();
    pageQuery.setPageNum(1);
    pageQuery.setPageSize(6);

    condition.setPageQuery(pageQuery);

    val res = sut.getCampaignList(condition);
    assertThat(res).isNotNull();
    assertThat(res.getTotal()).isEqualTo(0);
}

}

这里使用了 test 配置,响应需要在相关的配置文件里配置好数据源,指定 h2: yaml spring: datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:mmall;MODE=MySQL schema: classpath:db/h2Test/init-campaign-table.sql continue-on-error: true username: test password: test initialization-mode: always

另外配置中的初始化活动表的脚本,如下。这个是从现有数据库的相关表直接导出的脚本: sql CREATE TABLE campaign ( id varchar(64) NOT NULL COMMENT 活动ID, campaign_name varchar(128) NOT NULL DEFAULT COMMENT 活动名称, campaign_code varchar(64) NOT NULL DEFAULT COMMENT campaign code, campaign_type varchar(32) NOT NULL COMMENT 活动类型。 CAMPAIGN, PRODUCT, channel_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 渠道:1:微信小程序;2:天猫;3:微信小程序&天猫, start_time bigint(20) NOT NULL COMMENT 活动开始时间, end_time bigint(20) NOT NULL COMMENT 活动结束时间, start_reserve_time bigint(20) NOT NULL DEFAULT -1 COMMENT 开始预约时间 -1为不设置, minimum_duration bigint(20) NOT NULL DEFAULT -1 COMMENT 活动最短可持续时间 -1代表不设置, stock int(11) NOT NULL DEFAULT 0 COMMENT 活动剩余库存, reservable tinyint(4) NOT NULL DEFAULT 0 COMMENT 是否可预约 1:是 0:否, need_coupon tinyint(4) NOT NULL DEFAULT 0 COMMENT 是否需要预约券 1:需要 0:不需要, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态,0: 有效 1:删除 2:下线 3:审核通过, store_ids text COMMENT 门店列表, store_opening_time bigint(20) DEFAULT NULL COMMENT 门店开业时间, has_kid tinyint(4) NOT NULL DEFAULT 0 COMMENT 是否有小孩 0:否 1:是, kid_age_low_limit int(11) DEFAULT NULL COMMENT 孩子年龄限制下限, kid_age_high_limit int(11) DEFAULT NULL COMMENT 孩子年龄限制上限, product_id varchar(64) DEFAULT NULL COMMENT 产品ID, create_time bigint(20) NOT NULL COMMENT 创建时间, update_time bigint(20) NOT NULL COMMENT 更新时间, version int(11) NOT NULL DEFAULT 1 COMMENT 数据版本号, PRIMARY KEY (id), KEY campaign__code_index (campaign_code), KEY campaign__product_id_index (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=活动表;

跑测试通过后,做了个代码提交。

第二个用例

第一个测试通过后,写第二个测试,在有两条记录的情况下,不添加筛选条件,应返还两条结果:

java @Test @Sql(statements = INSERT INTO campaign (id, campaign_name, campaign_code, campaign_type, channel_type, start_time, end_time, start_reserve_time, minimum_duration, stock, reservable, need_coupon, status, store_ids, store_opening_time, has_kid, kid_age_low_limit, kid_age_high_limit, product_id, create_time, update_time, version) VALUES (123, xxx, C218888, Book_Product, 1, 1627551000, 1627637040, 1627550940, -1, 0, 0, 0, 0, NULL, NULL, 0, NULL, NULL, 723618c9fb0df0c90c5067bdbf2d165a409f1999e8214582694e8ae7bd76b891, 1627550699, 1627551000, 1), (196, yyy, C210003, EnrollmentGift, 1, 1619798400, 1623049320, -1, -1, 0, 0, 0, 3, NULL, NULL, 0, NULL, NULL, NULL, 1621907624, 1623049320, 1)) void getCampaignListWith2Records() { val condition = new CampaignPageQueryCondition(); val pageQuery = new PageQuery(); pageQuery.setPageNum(1); pageQuery.setPageSize(6);

condition.setPageQuery(pageQuery);

val res = sut.getCampaignList(condition);
assertThat(res.getTotal()).isEqualTo(2);

}

目前有个问题,在测试里的 SQL 语句导致测试意图不清晰,需要一个一个地数字段,才能定位到各个数字的含义,需要后续优化。

先做了个代码提交。

第三个

前两个测试用例通过后,开始写第三个用例,这次指定了渠道这个筛选条件,期待测试失败,因为还没有实现这个筛选逻辑。

java @Test @Sql(statements = DELETE FROM campaign; INSERT INTO campaign (id, campaign_name, campaign_code, campaign_type, channel_type, start_time, end_time, start_reserve_time, minimum_duration, stock, reservable, need_coupon, status, store_ids, store_opening_time, has_kid, kid_age_low_limit, kid_age_high_limit, product_id, create_time, update_time, version) VALUES (123, xxx, C218888, Book_Product, 1, 1627551000, 1627637040, 1627550940, -1, 0, 0, 0, 0, NULL, NULL, 0, NULL, NULL, 723618c9fb0df0c90c5067bdbf2d165a409f1999e8214582694e8ae7bd76b891, 1627550699, 1627551000, 1), (196, yyy, C210003, EnrollmentGift, 2, 1619798400, 1623049320, -1, -1, 0, 0, 0, 3, NULL, NULL, 0, NULL, NULL, NULL, 1621907624, 1623049320, 1)) void getCampaignListByChannelTypeWechatOnly() { val condition = new CampaignPageQueryCondition(); condition.setChannelType(CampaignChannelType.WECHAT_MP);

val pageQuery = new PageQuery();
pageQuery.setPageNum(1);
pageQuery.setPageSize(6);

condition.setPageQuery(pageQuery);
val res = sut.getCampaignList(condition);
assertThat(res.getTotal()).isEqualTo(1);

}

虽然和第二个测试用例一样,在数据库中插入了两条记录,但是这两条记录的渠道不一样,期待只返回一条。测试运行失败,显示返回了 2 条,这正是我要的失败,反应了现在系统缺失这个逻辑。

实现筛选逻辑

首先在筛选条件类里添加这个按照渠道筛选的字段:

diff

  • private CampaignChannelType channelType;

}

然后编译挂了,因为原来的代码里会使用 MyBatisPlus 自动生成的 Mapper 将搜索条件对象转换成数据库实体对象,但是这个 channelType 在数据库层是 byte 类型,因此需要我们告诉它如何将 java 里的自定义枚举转换成对应的 byte。

从编译错误信息可以看出能够通过自定义新的 mapper 来解决,但是我还不会,考虑到看了 MyBatisPlus 自动生成的代码也没有做特别的事情,就删掉了对这个自动生成的 Mapper 的依赖,将原来的搜索条件对象转换成数据库实体对象的逻辑封装成了一个方法。

diff ...

  •   Campaign campaign = campaignEntityConvertor.convertCampaignConditionToEntity(condition);
    
  •   Campaign campaign = convertCampaignConditionToEntity(condition);
    ...
    
  • private Campaign convertCampaignConditionToEntity(CampaignPageQueryCondition condition) {

  • if (condition == null) {

  •   return null;
    
  • }

  • Campaign campaign = new Campaign();

  • campaign.setCampaignName(condition.getCampaignName());

  •   return campaign;
    
  • }

这时通过跑所有的测试,验证没有破坏系统原有的行为。

然后,为了让第三个测试用例通过,在原有的 LambdaQueryWrapper 组装方法里添加了一个额外的方法:

diff ... } +

  • filterByChannelType(condition, queryWrapper);

  • return queryWrapper; }

  • private void filterbyChannelType(CampaignPageQueryCondition condition, LambdaQueryWrapper queryWrapper) {

  •   if (condition.getChannelType() != null) {
    
  •   queryWrapper.eq(Campaign::getChannelType, condition.getChannelType().getType());
    
  •   }
    
  • }

重新跑所有测试,全部通过!做了个代码提交。

但是,

第四个测试用例

由于采用了这样的枚举类型做这个渠道类型,导致以上的初步实现会存在问题。先看一下枚举的实现:

java public enum CampaignChannelType { WECHAT_MP((byte) 1,WMP, 微信), TMALL((byte) 2, EC,天猫), WECHAT_AND_TAMLL((byte) 3, null,微信,天猫); ...

于是,我写了第四个测试用例,在数据库里存在两条不同渠道的活动,分别是微信和微信与天猫,筛选条件指定微信渠道,但是查询结果应该是两条记录,因为 byte 3 包含了微信。

java @Test @Sql(statements = DELETE FROM campaign; INSERT INTO campaign (id, campaign_name, campaign_code, campaign_type, channel_type, start_time, end_time, start_reserve_time, minimum_duration, stock, reservable, need_coupon, status, store_ids, store_opening_time, has_kid, kid_age_low_limit, kid_age_high_limit, product_id, create_time, update_time, version) VALUES (123, xxx, C218888, Book_Product, 3, 1627551000, 1627637040, 1627550940, -1, 0, 0, 0, 0, NULL, NULL, 0, NULL, NULL, 723618c9fb0df0c90c5067bdbf2d165a409f1999e8214582694e8ae7bd76b891, 1627550699, 1627551000, 1), (196, yyy, C210003, EnrollmentGift, 1, 1619798400, 1623049320, -1, -1, 0, 0, 0, 3, NULL, NULL, 0, NULL, NULL, NULL, 1621907624, 1623049320, 1)) void getCampaignListByWechatChannelCanGetBothWechatAndTmallCampaigns() { val condition = new CampaignPageQueryCondition(); condition.setChannelType(CampaignChannelType.WECHAT_MP);

val pageQuery = new PageQuery();
pageQuery.setPageNum(1);
pageQuery.setPageSize(6);

condition.setPageQuery(pageQuery);
val res = sut.getCampaignList(condition);
assertThat(res.getTotal()).isEqualTo(2);

}

跑一下测试,果然失败了。因为我们在实现中指定了等于这个条件。于是修改最初的实现为:

java private void filterByChannelType(CampaignPageQueryCondition condition, LambdaQueryWrapper queryWrapper) { if (condition.getChannelType() != null) { if (condition.getChannelType().equals(CampaignChannelType.WECHAT_MP)) { queryWrapper.in(Campaign::getChannelType, new Byte[]{CampaignChannelType.WECHAT_MP.getType(), CampaignChannelType.WECHAT_AND_TAMLL.getType()}); } else if (condition.getChannelType().equals(CampaignChannelType.TMALL)) { queryWrapper.in(Campaign::getChannelType, new Byte[]{CampaignChannelType.TMALL.getType(), CampaignChannelType.WECHAT_AND_TAMLL.getType()}); } else { queryWrapper.eq(Campaign::getChannelType, condition.getChannelType().getType()); } } }

再次重新跑所有测试,全部通过!

目前够了,提交并推送

在这样的情况下,虽然代码写得比较丑,但是做到了在不破坏原有功能的情况下,正确地实现了新功能,所以我先做了个提交并推送了代码。

最后,端到端验证

以上只做了查询方法的增强,尽管对查询方法已经非常有信心了,但还是要做端到端自动化测试验证,即从 Springboot 的接口层面做验证,这只需要写个接口测试就好。不过,本文的重点是使用 h2 做数据库相关的测试,就不再赘述接口层面的测试了。

不过,如果也有小白看到本文想了解 java Springboot 接口测试的写法,可以参考这个文件:https://github.com/Jeff-Tian/securing-web-with-wechat-mp/blob/master/src/test/java/com/uniheart/securing/web/wechat/mp/WechatMessageControllerTest.java,流程很简单,主要是拼一个请求,然后借助 TestRestTemplate 发出请求,验证响应结果。

在写完接口测试后,还可以真正启动项目进行 cURL 验证: shell mvn clean install && mvn spring-boot:run -pl

curl http://localhost:8080/your-endpoint

总结

采用 h2 可以省去手动 mock 数据库,或者采用 docker 数据库的麻烦。不过实现上还有很多有待优化的地方,比如测试里直接内联了 SQL 语句,可读性不好,测试意图不够清晰等等。