TDD测试驱动开发

官网:https://haomo-tech.com

作者:胡小根

邮箱:hxg@haomo-studio.com

1 What 什么是TDD

TDD,全称Test Driven Development。测试驱动开发是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

测试驱动开发主要指 单元测试

2 Why 为什么要采用TDD

  • 自动测试代码是一张安全网:让我们写的代码沙子不会散掉,对自己的代码更自信;
  • 自动测试代码引导先写TODO(花轮廓),再写实现(实现细节)。确保没有理解好需求前,不要写代码;

写测试代码会拖慢开发速度吗?

不会

相反会极大加速

3 Who 谁要做测试驱动开发

  • 前端
    • 前端复杂业务逻辑开发人员(简单的交互逻辑无需编写)
    • 三方库开发人员
    • 组件开发人员
  • 后端
    • 复杂业务逻辑开发人员
    • 第三方接口对接开发人员
    • 后端通用模块开发人员
    • 数据库视图开发人员

谁不要做测试驱动开发:

  • 初级工程师
    • 前端:应该只安排做静态页面+简单交互逻辑。凡是有算法和复杂/全局业务逻辑;
    • 后端:应该只安排做代码生成、简单CRUD接口;

4 When 什么时候要写和跑测试

4.1 什么时候写

4.1.1 以下情况应该写

  • 前端
    • 前端复杂业务逻辑开发
    • 三方库开发(业内名词:学习性测试
    • 组件开发人员
  • 后端
    • 复杂业务逻辑开发
    • 第三方接口对接开发
    • 后端通用模块开发
    • 数据库视图开发

自研产品,如设计云:

  • 前端:
    • 组件/页面开发
    • 低代码编辑器开发
  • 后端:
    • 核心api开发

4.1.2 以下情况不建议写

  • 前端
    • 切图业务
    • 简单的CRUD操作
  • 后端
    • CRUD接口

4.2 什么时候运行

  • 通过CI,自动运行前端和后端代码每次git commit的测试;
  • 前端由于通过CI自动部署,所以相当于每次部署前都跑了测试用例;
  • 后端每次在手动部署前,需要跑一遍测试用例;

5 How 如何做测试驱动开发

遵循:红->绿->重构 循环;

三定律

  • 在编写不能通过的单元测试前,不可编写生产代码;
  • 之编写刚好无法通过的单元测试,不能编译也算不通过;
  • 只可编写刚好足以通过当前失败测试的生产代码;

问题:先测轮廓,还是先测细节?有轮廓没细节的话,如何让轮廓通过测试?

5.1 原则

  • 测试优先:测试代码先于实现代码,不允许有没有测试代码的实现代码。
  • 独立测试:不同代码的测试应该相互独立,一个类对应一个测试类(对于C代码或C++全局函数,则一个文件对应一个测试文件),一个函数对应一个测试函数。用例也应各自独立,每个用例不能使用其他用例的结果数据,结果也不能依赖于用例执行顺序。 一个角色:开发过程包含多种工作,如:编写测试代码、编写产品代码、代码重构等。做不同的工作时,应专注于当前的角色,不要过多考虑其他方面的细节。
  • 测试列表:代码的功能点可能很多,并且需求可能是陆续出现的,任何阶段想添加功能时,应把相关功能点加到测试列表中,然后才能继续手头工作,避免疏漏。
  • 测试驱动:即利用测试来驱动开发,是TDD的核心。要实现某个功能,要编写某个类或某个函数,应首先编写测试代码,明确这个类、这个函数如何使用,如何测试,然后在对其进行设计、编码。
  • 先写断言:编写测试代码时,应该首先编写判断代码功能的断言语句,然后编写必要的辅助语句。
  • 可测试性:产品代码设计、开发时的应尽可能提高可测试性。每个代码单元的功能应该比较单纯,“各家自扫门前雪”,每个类、每个函数应该只做它该做的事,不要弄成大杂烩。尤其是增加新功能时,不要为了图一时之便,随便在原有代码中添加功能,对于C++编程,应多考虑使用子类、继承、重载等OO方法。
  • 及时重构:对结构不合理,重复等“味道”不好的代码,在测试通过后,应及时进行重构。
  • 小步前进:软件开发是复杂性非常高的工作,小步前进是降低复杂性的好办法。

5.2 方法

F.I.R.S.T 规则:

  • 快速(Fast):测试可以快速运行,这样才喜欢经常运行;
  • 独立(Indenpendent):用例不互相依赖;
  • 可重复(Repeatable):在任何环境中重复通过;
  • 自动验证(Self-Validating):不能通过人去验证,例如去查看日志中是否生成了某条日志;
  • 及时(Timely):测试先于生产代码;

5.3 工具

5.3.1 前端工具

  • 单元测试:Jest
beforeEach(async () => {
  page = new Page({
    id: 'page1',
    projectId: 'project1',
    title: '测试Page',
    name: 'TestPage',
    type: 1
  })
  blockComponents = await createComponents()
})

describe('测试Page.js', () => {
  it(`测试 getChildBlockComponent`, () => {
    for(let i = 0; i < 10; i++) {
      page.addComponent(null, blockComponents[i], page.key)
    }
    let componentKey = blockComponents[3].key
    let component = page.getChildBlockComponent(page, componentKey)
    expect(componentKey).toBe(component.key)
  })
});
  • 组件测试:Vue Testing Library (此框架还支持html原生、React、Angular等框架的组件测试)
test('测试', async () => {
  const {container, baseElement, debug} = render(HmBgCard, {
    props: {
      width: '500',
      height: '500'
    },
    slots: {
      'default': 'hm bg card'
    } 
  });

  debug();

  expect(container.getElementsByClassName('hm-bg-card').length).toBe(1);
  const ele = container.getElementsByClassName('hm-bg-card')[0];
  expect(ele.outerHTML).toBe('<div class="hm-bg-card">hm bg card</div>');

5.3.2 后端工具

public class WaterLogicVerificationTest {
    /***
     * 少量数据测试
     */
    @Test
    public void TestCalcWaterUsage(){
        Double[] doubles = {868.4000000000001, 868.4000000000001, ...};
        List<Double> doubleList = Arrays.asList(doubles);
        VehicleAttendanceStatisticsTask vehicleAttendanceStatisticsTask = new VehicleAttendanceStatisticsTask();
        double v = vehicleAttendanceStatisticsTask.calcGpsUsage(doubleList);
        Assert.assertEquals(v > 1400, true);
        Assert.assertEquals(v < 1700, true);
    }
}
  • Component测试:SpringBootTest
@RunWith(SpringRunner.class)
@SpringBootTest
public class SampleTest {

    @Resource
    private JeecgDemoMapper jeecgDemoMapper;
    @Resource
    private IJeecgDemoService jeecgDemoService;
    @Resource
    private ISysDataLogService sysDataLogService;
    @Resource
    private MockController mock;

    @Test
    public void testSelect() {
        System.out.println(("----- selectAll method test ------"));
        List<JeecgDemo> userList = jeecgDemoMapper.selectList(null);
        Assert.assertEquals(5, userList.size());
        userList.forEach(System.out::println);
    }
}
  • 数据库测试:MyTap(只支持mysql,但是postgresql有对应的库)
-- test view_user_stat
# SELECT tap.has_view(DATABASE(), 'view_user_stat', 'view_user_stat--视图存在');
# SELECT tap.columns_are(DATABASE(), 'view_user_stat', 'normal_male_num,normal_female_num,lock_male_num,lock_female_num', 'view_user_stat--字段正确');
-- view_user_stat第一条数据
set @vusNmn = (select normal_male_num from view_user_stat limit 1),
    @vusNFn = (select normal_female_num from view_user_stat limit 1),
    @vusLmn = (select lock_male_num from view_user_stat limit 1),
    @vusLfn = (select lock_female_num from view_user_stat limit 1);
-- view_user_stat总数
set @vusCount = (select count(*) from view_user_stat);
-- test ok
SELECT tap.ok(@vusNmn = 2 and @vusNFn = 1 and @vusLmn = 0 and @vusLfn = 0 and @vusCount = 1, 'view_user_stat 数据校验');

5.4 技巧

  • 数据准备
    • 从接口里拿
    • 打断点拷贝数据
  • 边界/接口定义
    • 学习性测试
  • 断言
    • 每个测试一个断言(或者尽量少断言);

参考