TDD 如何写好单元测试

admin · December 31, 2020 · 34 hits

本文主要阐述单元测试(UT)的重要性,以及解释一些常见的困惑,以帮助我们写出质量更高的 UT。至于类似 Mocha 怎么用,断言库怎么用之类的问题,建议看官方文档。 原文在此

一、为什么需要写 UT

我发现很多朋友意识不到单元测试的重要性。在谈如何写好 UT 之前,我想先说说测试的必要性,这有利于提高我们写 UT 的内驱力。

假如,张三负责开发接口,李四负责开发具体的业务。李四会调用张三开发的接口,但由于种种原因,张三开发的接口可能存在一些 bug 。

在没有单元测试的情况下,这些 bug 往往都是由接口的使用者也就是李四发现。这无形中给李四增加了额外的工作量,因为保证接口质量的工作本该是写接口的人也就是张三应该做的事儿。假设张三还是一个比较粗心的人,其中额外增加的时间成本会更大(实际开发中经常遇到)。

张三可能会很委屈的说:谁能保证代码永远不会产生 bug 。

是的,没人能够绝对的保证代码的正确性。但张三需要对他所提供的这些接口有一些起码的保证。他需要保证这些接口的确具备接口文档中所描述的那些功能,且能正常执行。

于是乎,张三含情脉脉的望着李四的眼睛说道:I promise.

哈哈哈,显然,口头上的保证永远都是不靠谱的,我们需要白纸黑字的保证,也就是我们本文的主题:单元测试。

再回到上述的场景。假如张三为他所写的那些接口写了一些质量还不错的测试用例。

李四可能会兴奋的把张三公主抱了起来。

哈哈哈哈,原因如下:

  • 原因一:李四不用担心接口存在一些简单 bug 。要知道因为这些简单的 bug ,李四需要向张三反馈,然后等待张三修改,张三很有可能还不会马上去修改它,李四只能等着做其他的事儿,等到张三说修好了之后,李四才能继续原先的开发。天呐,这是多么大的时间成本啊。

  • 原因二:李四可以通过测试用例了解接口的用法。在实际的开发中,接口文档可能并没有很高的实时性,很多小的公司甚至没有接口文档这一说。在这种情况下,接口的用法以及涉及到的数据结构就只能靠张三和李四的沟通了。然而,在写了 UT 之后,这部分的沟通成本很大程度上是可以避免的。因为李四通过阅读各个测试用例可以清晰的知道接口该如何使用以及能得到怎么的结果。当然,前提是李四的 UT 写的足够好。

OK,我们再换一种场景。假如张三现在是某个开源库的作者,李四是这个开源库的使用者,两人相互并不认识。可以想象,一旦李四遇到什么 bug,他和张三的沟通成本无疑会更大。这就是为什么社区总是强调开源项目的测试覆盖率的原因。

小结一下,写单元测试有如下几点好处:

  • 对代码有白纸黑字的保证,避免了一些或是粗心或是难以察觉的 bug 。
  • 优秀的 UT 可以充当接口文档的作用,减少许多不必要的沟通成本。

此外还有一些好处在上述的例子中并未体现出来,在此不再赘述,各位自行感受:

  • 方便代码的重构。
  • 设计代码的思路更加清晰。

二、一些关于 UT 的困惑

通过跟身边同事的交流以及些许切身的感受,我总结了如下几个常见的关于 UT 的困惑。

2.1 UT 的本质是什么?

UT 的本质:保证接口在具体的场景下能有符合预期的输出 (或是行为)。

解释:由于代码的本质是对实际行为的抽象。所以理论上当我们的测试用例覆盖了项目中的所有行为以及对应的所有场景时,我们是能够绝对确保软件的质量的。虽然这只是理想状态,但是这却是我们写 UT 的初衷。梦想总是要有的,对吧?

2.2 什么是测试覆盖率?

很多大公司都会要求项目有足够的测试覆盖率,在 CI(Continuous Integration) 的时候会有一个测试覆盖率的阀门,如果测试覆盖率低于这个阀门,就不让提交代码。比如我们公司阀门是 90% 。

关于测试覆盖率,我们大致了解下以下几个常见的计算维度即可:

  • 行覆盖率:可执行语句执行的比例。
  • 函数覆盖率:函数被调用的比例。
  • 分支覆盖率:判断语句分支被执行的比例。

需要注意,这里说的都是可测量的覆盖率。另外还有一种是无法测量的覆盖率,也就是上面所说的:测试覆盖所有行为以及对应所有场景的比例。值得一提的是,后者才是我们真正追求的覆盖率,当这个覆盖率足够高的时候,那些可观测的覆盖率必然也就低不了。作为一位优秀的开发者,我们需要弄清楚其中的主次关系。

2.3 需要为私有方法写 UT 吗?

关于这个问题,社区有着许多不同的看法,在 stackoverflow 上大家争论的很火热。在此,我也谈谈我自己的看法。

如下,写或不写,都有其各自的说法:

  • 写:因为我们是测试驱动,所以我通过写测试来更好的设计自己代码。何况很多核心的逻辑都是写在私有方法中,那些暴露出来的接口只是调用了这些私有方法,我们不希望为某一个接口写一大堆的测试用例,这让测试代码显得难以阅读。

  • 不写:这会占据许多的工作量,我只需保证对外的接口有足够覆盖度即可。

而在我看来,这显然不是一个非黑即白的问题。如果我是团队的 Leader,我绝对不会强制要求团队必须为所有的私有方法写 UT ,这是一件很愚蠢的事情。如果我是一个普通的开发者,即使 Leader 说我们团队可以不用为私有方法写 UT,我可能还是会为某些私有方法写 UT 。

我的原则是:如果该私有方法复杂度较高且比较重要(重要这个词的理解就仁者见仁了),那么我会为它写 UT 。

2.4 什么时候需要 mock 数据或是方法?

前面说了,UT 的本质就是预期接口在指定场景下的输出或是行为。这里的指定场景是我们通过 mock 的手段营造出来的。

在给某个接口写 UT 的过程中,比较常见的情况是:输入 a 会触发 A 行为,输入 b 会触发 B 行为。这种情况比较简单,我们只需写两个 case ,然后分别 mock 数据 a 和 数据 b ,然后再写断言语句来预期对应的行为即可。

除了 mock 输入数据,我们常常还需要 mock 一些方法。我总结了下述两种情况。

2.4.1 为了测试方便,所以我们需要 mock 某个方法。

这里通过具体的例子会比较好理解。假设我们需要为下述接口写 UT 。

// index.js
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const _ = require('underscore');

exports.refreshConfiguration = function(param) {
    return fs.readFileAsync(param.path)
        .then(JSON.parse)
        .then((val) => {
            return _.extend(val, param.config);
        })
        .catch((err) => {
            throw err;
        })
}

这是一个更新配置信息的接口。当参数都正常时,代码执行成功,我们能够获取到最新的配置信息,这个配置信息包含了 param 中已有的配置信息和指定文件中的配置信息。于是便有了如下这个测试用例。

// mock/config.json
{
    "foo": "foo"
}

// test.js
const _ = require('underscore');
const assert = require('chai').assert;
const testModule = require('./index');

describe('refreshConfiguration', () => {
    const fakeParam = {
        path: './mock/config.json',
        config: { bar: 'bar' }
    };

    it('should be seccessed when everything is right', () => {
        testModule.refreshConfiguration(fakeParam)
            .then((ret) => {
                assert.deepEqual(ret, _.extend({ 
                    foo: 'foo' 
                }, fakeParam.config));
            });
    });
});

如上,我们需要为这个测试用例新建一个测试文件。但是,你可能觉得新建文件很麻烦。于是,我们通过 mock fs.readFileAsync() 方法来实现同样的目的。

// rewire 是为 NodeJS 提供 mock 功能的第三方库 
const rewire = require('rewire');
const _ = require('underscore');
const assert = require('chai').assert;
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const testModule = rewire('./index');

describe('refreshConfiguration', () => {
    let fakeParam;
    let fakeFileConfig;

    beforeEach(() => {
        fakeParam = {
            config: { bar: 'bar' }
        };
        fakeFileConfig = {
            foo: 'foo'
        };
    });

    it('should be seccessed when everything is right', () => {
        testModule.__set__('fs', {
            readFileAsync: () {
                return  Promise.resolve(JSON.stringify(fakeFileConfig));
            }
        });

        testModule.refreshConfiguration(fakeParam)
            .then((ret) => {
                assert.deepEqual(ret, _.extend(fakeFileConfig, fakeParam.config));
            });
    });
});

我们只为这个接口写了一个 case 。另外还有一中可能就是如果获取配置文件失败后,这个接口应该 catch 到对应的 error 。你不妨动手试着写写这种情况下的 case 。

2.4.2 如果方法中有其他模块的接口,(理论上)必须 mock

还是上面的那个例子,refreshConfiguration 接口中有使用第三方模块 underscore 和 node 内置的 fs 模块。对于两种模块对应的接口,如果没有 mock 的必要,我们可以不用 mock ,因为我们默认他们是安全的有保障的。

但是,如果待测试的方法中有调用其他业务模块的接口,理论上来说,我们必须 mock 这些接口。这涉及到 UT 中很重要的一个概念:隔离 (Isolation)。我们需要隔离与当前测试用例无关的方法,这样的好处有两点:

  1. 减少了写 UT 的复杂度,只需专注于具体某个场景下的执行逻辑,其余全部 mock 。

  2. 避免了各个测试模块间的相互依赖。这样就不会出现某个接口出现了一个 bug ,导致一大堆的测试用例都跑不过的情况。

实际的开发中,由于种种原因我们可能没法如此严格的遵守隔离的原则,但必须理解它,尽可能的避免一些「坏味道」。

2.5 如何理解和实践 TDD(测试驱动开发)?

在习惯于写 UT 之后,我才深切感受到 TDD 这种开发模式非常值得尝试。

先简单介绍下 TDD 是什么。TDD(Test Dirven Development),又叫测试驱动开发。其特点是先写测试用例,再进行开发。我最初听说 TDD 的时候觉得非常的不可思议。先写测试?再写开发?这是效率是有多低啊?

哈哈哈,我也是有点后知后觉。

同样,我们需要辩证的去看待 TDD 。它只是提供了一种思路:在某些情况下,我们可以先写测试再进行具体的开发。而不是说我写任意一行业务代码之前都需要先把对应的测试用例给写了。

那么,先测试后开发的开发模式有什么好处呢?

在我们具体开发某个接口时,如果接口不是特别的简单的话,我们是不会一股脑的就开始写代码,而是先在脑中或是纸上设计代码。我们会分析这个接口有那些场景,有那些可能性,每一种场景对应的行为是什么样。这句话是不是有点熟悉(上文有提过,不熟悉的话你肯定没认真看)。是的,我们可以将这些设计的过程在 UT 中体现出来,或者说,UT 能够更好的实践我们的代码设计

所以,对于某些复杂度较高的接口(甚至是一些私有方法),我们可以使用 TDD 进行开发。我建议所以的接口都通过 TDD 进行开发,反正这些接口的 UT 你是躲不掉的,就是早写晚写的问题。

三、推荐阅读

「软件匠艺社区」旨在传播匠艺精神,通过分享好的「工作方式」,让帮助程序员更加快乐高效地编程!

No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.