TDD在实际项目中的使用经验总结 - ZhangTory's NoteBlog - 张耀誉的笔记博客

TDD在实际项目中的使用经验总结

为了减少bug,在需求交付测试之前我们程序员也应该自测,最常见的如单元测试,就是提升代码质量的利器。
之前我们后端团队强制要求写单元测试,但是后来由于你能想到的各种原因不再要求编写单元测试了...bug也明显变多了...不过这次我们先不说为什么不再要求编写单元测试的问题。
之前有一次开会,运营和产品团队将推广效果不理想的锅扔给了我们开发团队,理由是出现了线上bug,所以部门经理又重新提到了代码质量的问题。
最近我自己“试点”了TDD,也就是测试驱动开发,总结一下在实际项目中如何使用。

TDD是什么我就不多说了,此外由于某些原因,我的TDD实践也不一定完全标准的,仅仅是我从0使用TDD时遇到的困难和处理经验的总结。

测试如何驱动开发

在TDD流程中,首先要编写测试方法,然后为了通过测试用例而编写功能代码,从而达到完成需求的效果。
第一步,我们就要理解需求,想想什么样的输入应该得到什么样的输出。为了在if-else中达到尽可能高的测试覆盖率,又应该设计什么样的输入和输出。同时如果有异常的输入又该如何。一个功能往往需要多个测试用例,如果有测试提供测试用例当然更方便,但是很显然绝大多数公司做不到。
第二步,完成第一个测试用例的功能代码,一般来说第一个测试用例肯定是理想的流程。编码完成后执行测试用例,如果失败则修复bug,成功则重复第二步完成后续的开发。
第三步,一次性跑完所有的测试用例,确保没有在编写新功能的时候影响到了之前的功能。同时查看代码覆盖率是否理想。
第四步,此时你应该跑通了所有的测试用例,这时你需要尝试重构你的代码,否则重构就会变成“下次一定”。重构完成后再次执行所有的测试用例,确保功能正常。
接下来就可以交付测试了。

如何编写测试代码

可能大家看别人的教程,上来实现一个类似于a+b的简单逻辑需求,没有用到spring等框架,也不涉及数据库的增删改查,但是实际项目中又该如何做呢?
首先SpringBoot引入了JUnit,可以很方便的编写单元测试。
我们使用IDEA时,在待开发的类上按下ctrl+shift+T,选择"Create New Test",在SpringBoot中默认依赖了Junit,选择Junit5,IDEA会自动按照包路径创建对应的Test文件。在需要执行测试方法上添加@Test注解,IDEA就会在左边行数那里显示一个箭头,点击箭头可以运行该测试方法。Junit还有其他的一些注解,比如@BeforeEach注解会在执行测试方法前执行特定的方法,可以实现一些初始化的操作。同理还有@AfterEach。
此外,最终的结果是否正确也是由代码完成判断的,我们可以使用Assertions来判断期望值和实际值。对于出错的代码,在测试用例左边会出现红色的标志,提醒我们有异常。
而对于数据库操作,这里有2种方法,一是直接在开发环境的数据库中创建做对应的操作,二是使用Mockito之类的工具进行模拟。
在我们团队这2个方法都有在使用,我个人而言倾向于使用第一种,因为更加方便测试及直观的看到数据。
如果使用Mockito模拟,那么必须要手动创建对应的数据库记录,产生的数据不会真正的落库,后续不能从数据库看到具体数据。
个人觉得,对开发环境而言,直接写入数据库并不会有什么问题,但是需要考虑到后续重复执行测试代码时的影响;用Mockito可以完全模拟,但是编写代码量稍微大一点。
除了CRUD还会有网络调用接口的情况,很多时候上游都会提供沙盒模式,或者给测试环境账号,不过这种测试很难遇到上游返回异常的情况,这时可以考虑在代码中模拟返回的json数据。

实战运用

最近接手了同事的返利订单模块,我需要重构淘宝订单同步的定时任务并修复发现的bug。
除了淘宝我们还对接了京东、拼多多、美团、唯品会等上游,其实订单同步的方法大同小异,所以不得不说模板方法模式真香。
这次我们要完成淘宝订单的同步,淘宝订单同步时又分为普通订单、会员订单、渠道订单。它们3种订单的区别在于会员订单能拉取到specialId不为空的订单;渠道订单能拉取到relationId不为空的订单;普通订单能拉取到所有订单,但是不能获取specialId和relationId。
同步时需要指定同步的时间段,将这段时间的订单存入数据库。
首先注入创建好的业务代码实现类。
针对每种情况,我从数据库的历史数据中找了几个对应的订单,分别构建了起始和结束时间,以及对应的订单号,用于删除和查询订单。

    @Resource
    private DdOrderDetailTbDao ddOrderDetailTbDao;
    @Resource
    private TaoBaoRelationOrderSyncXxlJob relationOrderSyncXxlJob;
    @Resource
    private TaoBaoSpecialOrderSyncXxlJob specialOrderSyncXxlJob;
    @Resource
    private TaoBaoOrdinaryOrderSyncXxlJob ordinaryOrderSyncXxlJob;

    String relationParam = "{\"startTime\":\"2020-11-26 21:40:00\",\"endTime\":\"2020-11-26 21:41:00\"}";
    String specialParam = "{\"startTime\":\"2020-12-07 08:38:00\",\"endTime\":\"2020-12-07 08:39:00\"}";
    String ordinaryParam = "{\"startTime\":\"2020-11-26 20:21:20\",\"endTime\":\"2020-11-26 20:21:22\"}";
    String specialAndRelationParam = "{\"startTime\":\"2020-11-26 20:29:00\",\"endTime\":\"2020-11-26 20:30:00\"}";

    String relationOrderSn = "1226235073570887787_1226235073570887787";
    String specialOrderSn = "1418102065674000449_1418102065674000449";
    String ordinaryOrderSn = "1226206921833670099_1226206921833670099";
    String specialAndRelationOrderSn = "1394712074469416554_1394712074469416554";

在测试开始前我们需要删除数据库的记录。

    @BeforeEach
    public void cleanData() {
        ddOrderDetailTbDao.deleteByOrderSn(relationOrderSn);
        ddOrderDetailTbDao.deleteByOrderSn(specialOrderSn);
        ddOrderDetailTbDao.deleteByOrderSn(ordinaryOrderSn);
        ddOrderDetailTbDao.deleteByOrderSn(specialAndRelationOrderSn);
    }

首先是拉取普通订单,普通订单没有specialId和relationId的,会有insert和update两种情况。

    /**
     * 普通订单,specialId和relationId都没有
     */
    @Test
    public void ordinaryOrderSync() {
        simpleOrdinaryOrderSync();
        simpleOrdinaryOrderSync();
    }

    private void simpleOrdinaryOrderSync() {
        ReturnT<String> result = ordinaryOrderSyncXxlJob.taobaoOrdinaryOrderSync(ordinaryParam);
        log.info(JSONObject.toJSONString(result));
        DdOrderDetailTb orderDetail = ddOrderDetailTbDao.getTbOrderDetailByOrderSn(ordinaryOrderSn);
        Assertions.assertNull(orderDetail.getSpecialId());
        Assertions.assertNull(orderDetail.getRelationId());
    }

继承订单同步模板方法类,完成对应的订单查询API的调用、数据库insert和update方法。
运行测试用例,完成普通订单拉取的功能。
同理再完成会员订单和渠道订单的同步。

之后还需要考虑会员订单或渠道订单,在普通订单同步时先被同步的情况。
那么普通订单拉取时,是没有specialId和relationId的,在会员订单或渠道订单同步后,会将specialId或relationId更新到数据库中。

    /**
     * 普通订单拉取先拉取,之后会员订单和渠道订单,应填入specialId和relationId
     */
    @Test
    public void ordinaryBeforeSpecialAndRelation() {
        // 普通订单拉取渠道订单
        ReturnT<String> resultR = ordinaryOrderSyncXxlJob.taobaoOrdinaryOrderSync(relationParam);
        log.info(JSONObject.toJSONString(resultR));
        // 普通订单拉取会员订单
        ReturnT<String> resultS = ordinaryOrderSyncXxlJob.taobaoOrdinaryOrderSync(specialParam);
        log.info(JSONObject.toJSONString(resultS));
        // 普通订单拉取渠道和会员订单
        ReturnT<String> resultSR = ordinaryOrderSyncXxlJob.taobaoOrdinaryOrderSync(specialAndRelationParam);
        log.info(JSONObject.toJSONString(resultSR));

        // 分别执行各自的订单拉取
        simpleRelationOrderSync();
        simpleSpecialOrderSync();
        simpleSpecialAndRelationSync();

        // 检查各自拉取时是否填入specialId和relationId
        DdOrderDetailTb orderDetailR = ddOrderDetailTbDao.getTbOrderDetailByOrderSn(relationOrderSn);
        Assertions.assertNotNull(orderDetailR.getRelationId());
        Assertions.assertNull(orderDetailR.getSpecialId());
        DdOrderDetailTb orderDetailS = ddOrderDetailTbDao.getTbOrderDetailByOrderSn(specialOrderSn);
        Assertions.assertNotNull(orderDetailS.getSpecialId());
        Assertions.assertNull(orderDetailS.getRelationId());
        DdOrderDetailTb orderDetailSR = ddOrderDetailTbDao.getTbOrderDetailByOrderSn(specialAndRelationOrderSn);
        Assertions.assertNotNull(orderDetailSR.getSpecialId());
        Assertions.assertNotNull(orderDetailSR.getRelationId());
    }

同样还需要考虑会员订单或渠道订单同步后,普通订单同步时不能将specialId或relationId置空的情况。

    /**
     * 会员订单和渠道订单先拉取,之后普通订单拉取,不应该置空specialId和relationId
     */
    @Test
    public void ordinaryAfterSpecialAndRelation() {
        // 分别执行各自的订单拉取
        simpleRelationOrderSync();
        simpleSpecialOrderSync();
        simpleSpecialAndRelationSync();

        // 普通订单拉取渠道订单
        ReturnT<String> resultR = ordinaryOrderSyncXxlJob.taobaoOrdinaryOrderSync(relationParam);
        log.info(JSONObject.toJSONString(resultR));
        // 普通订单拉取会员订单
        ReturnT<String> resultS = ordinaryOrderSyncXxlJob.taobaoOrdinaryOrderSync(specialParam);
        log.info(JSONObject.toJSONString(resultS));
        // 普通订单拉取渠道和会员订单
        ReturnT<String> resultSR = ordinaryOrderSyncXxlJob.taobaoOrdinaryOrderSync(specialAndRelationParam);
        log.info(JSONObject.toJSONString(resultSR));

        // 检查普通订单拉取时是否置空了specialId和relationId
        DdOrderDetailTb orderDetailR = ddOrderDetailTbDao.getTbOrderDetailByOrderSn(relationOrderSn);
        Assertions.assertNotNull(orderDetailR.getRelationId());
        Assertions.assertNull(orderDetailR.getSpecialId());
        DdOrderDetailTb orderDetailS = ddOrderDetailTbDao.getTbOrderDetailByOrderSn(specialOrderSn);
        Assertions.assertNotNull(orderDetailS.getSpecialId());
        Assertions.assertNull(orderDetailS.getRelationId());
        DdOrderDetailTb orderDetailSR = ddOrderDetailTbDao.getTbOrderDetailByOrderSn(specialAndRelationOrderSn);
        Assertions.assertNotNull(orderDetailSR.getSpecialId());
        Assertions.assertNotNull(orderDetailSR.getRelationId());
    }

测试通过,尝试重构一下逻辑代码,再次运行测试,完成开发。

总结

这次重构情况还是非常好的,我已经很久都没有收到订单同步的线上报警了,再回想重构之前,晚上睡之前怕有报警,早上起来再看一眼有没有报警,把人都给看焦虑了。
如果以后有改动,可以编写对应的测试用例去实现对应的功能。如果新的需求对之前的功能有影响,通过跑之前的测试用例,也能发现一部分冲突的地方,及时向上反馈。

添加新评论

电子邮件地址不会被公开,评论内容可能需要管理员审核后显示。