Promise测试

这章我们学习如果编写Promise 的测试代码

基本测试

关于ES6 Promises的语法我们已经学了一些, 我想大家应该也能够在实际项目中编写Promise 的Demo代码了吧。

这时,接下来你可能要苦恼该如何编写Promise 的测试代码了。

那么让我们先来学习下如何使用 Mocha来对Promise 进行基本的测试吧。

先声明一下,这章中涉及的测试代码都是运行在Node.js环境下的。

本书中出现的示例代码也都有相应的测试代码。 测试代码可以参考 azu/promises-book 。

Mocha

Mocha是Node.js下的测试框架工具,在这里,我们并不打算对 Mocha本身进行详细讲解。对 Mocha感兴趣的读者可以自行学习。

Mocha可以自由选择BDD、TDD、exports中的任意风格,测试中用到的Assert 方法也同样可以跟任何其他类库组合使用。 也就是说,Mocha本身只提供执行测试时的框架,而其他部分则由使用者自己选择。

这里我们选择使用Mocha,主要基于下面3点理由。

它是非常著名的测试框架

支持基于Node.js 和浏览器的测试

支持"Promise测试"

最后至于为什么说 支持"Promise测试" ,这个我们在后面再讲。

要想在本章中使用Mocha,我们需要先通过npm来安装Mocha。

$ npm install -g mocha 另外,Assert库我们使用的是Node.js自带的assert模块,所以不需要额外安装。

首先,让我们试着编写一个对传统回调风格的异步函数进行测试的代码。

回调函数风格的测试

如果想使用回调函数风格来对一个异步处理进行测试,使用Mocha的话代码如下所示。

basic-test.js

var assert = require('power-assert');
describe('Basic Test', function () {
    context('When Callback(high-order function)', function () {
        it('should use `done` for test', function (done) {
            setTimeout(function () {
                assert(true);
                done();
            }, 0);
        });
    });
    context('When promise object', function () {
        it('should use `done` for test?', function (done) {
            var promise = Promise.resolve(1);
            // このテストコードはある欠陥があります
            promise.then(function (value) {
                assert(value === 1);
                done();
            });
        });
    });
});

将这段代码保存为 basic-test.js,之后就可以使用刚才安装的Mocha的命令行工具进行测试了。

$ mocha basic-test.js

Mocha的 it 方法指定了 done 参数,在 done() 函数被执行之前, 该测试一直处于等待状态,这样就可以对异步处理进行测试。

Mocha中的异步测试,将会按照下面的步骤执行。

it("should use `done` for test", function (done) {

    setTimeout(function () {
        assert(true);
        done();
    }, 0);
});

回调式的异步处理 调用done 后测试结束 这也是一种非常常见的实现方式。

使用done 的Promise测试

接下来,让我们看看如何使用 done 来进行Promise测试。

it("should use `done` for test?", function (done) {
    var promise = Promise.resolve(42);
    promise.then(function (value) {
        assert(value === 42);
        done();
    });
});

创建名为Fulfilled 的promise对象 调用done 后测试结束 Promise.resolve 用来返回promise对象, 返回的promise对象状态为FulFilled。 最后,通过 .then 设置的回调函数也会被调用。

像专栏: Promise只能进行异步操作? 中已经提到的那样, promise对象的调用总是异步进行的,所以测试也同样需要以异步调用的方式来编写。

但是,在前面的测试代码中,在assert 失败的情况下就会出现问题。

对异常promise测试

it("should use `done` for test?", function (done) {
    var promise = Promise.resolve();
    promise.then(function (value) {
        assert(false);// => throw AssertionError
        done();
    });
});

在此次测试中 assert 失败了,所以你可能认为应该抛出“测试失败”的错误, 而实际情况却是测试并不会结束,直到超时。

promise test timeout Figure 7. 由于测试不会结束,所以直到发生超时时间未知,一直会处于挂起状态。 通常情况下,assert 失败的时候,会throw一个error, 测试框架会捕获该error,来判断测试失败。

但是,Promise的情况下 .then 绑定的函数执行时发生的error 会被Promise捕获,而测试框架则对此error将会一无所知。

我们来改善一下assert 失败的promise测试, 让它能正确处理 assert 失败时的测试结果。

测试正常失败的示例

it("should use `done` for test?", function (done) {
    var promise = Promise.resolve();
    promise.then(function (value) {
        assert(false);
    }).then(done, done);
});

在上面测试正常失败的示例中,为了确保 done 一定会被调用, 我们在最后添加了 .then(done, done); 语句。

assert 测试通过(成功)时会调用 done() ,而 assert 失败时则调用 done(error) 。

这样,我们就编写出了和 回调函数风格的测试 相同的Promise测试。

但是,为了处理 assert 失败的情况,我们需要额外添加 .then(done, done); 的代码。 这就要求我们在编写Promise测试时要格外小心,忘了加上上面语句的话,很可能就会写出一个永远不会返回直到超时的测试代码。

在下一节,让我们接着学习一下最初提到的使用Mocha理由中的支持"Promises测试"究竟是一种什么机制。

Mocha对Promise的支持

在这里,我们将会学习什么是Mocha支持的“对Promise测试”。

官方网站 Asynchronous code 也记载了关于Promise测试的概要。

Alternately, instead of using the done() callback, you can return a promise. This is useful if the APIs you are testing return promises instead of taking callbacks: 这段话的意思是,在对Promise进行测试的时候,不使用 done() 这样的回调风格的代码编写方式,而是返回一个promise对象。

那么实际上代码将会是什么样的呢?这里我们来看个具体的例子应该容易理解了。

mocha-promise-test.js
var assert = require('power-assert');
describe('Promise Test', function () {
    it('should return a promise object', function () {
        var promise = Promise.resolve(1);
        return promise.then(function (value) {
            assert(value === 1);
        });
    });
});

这段代码将前面 前面使用 done 的例子 按照Mocha的Promise测试方式进行了重写。

修改的地方主要在以下两点:

删除了 done

返回结果为promise对象

采用这种写法的话,当 assert 失败的时候,测试本身自然也会失败。

it("should be fail", function () {
    return Promise.resolve().then(function () {
        assert(false);// => 测试失败
    });
});

采用这种方法,就能从根本上省略诸如 .then(done, done); 这样本质上跟测试逻辑并无直接关系的代码。

Mocha已经支持对Promises的测试 | Web scratch 这篇(日语)文章里也提到了关于Mocha对Promise测试的支持。 3.2.1. 意料之外(失败的)的测试结果 因为Mocha提供了对Promise的测试,所以我们会认为按照Mocha的规则来写会比较好。 但是这种代码可能会带来意想不到的异常情况的发生。

比如对下面的mayBeRejected() 函数的测试代码,该函数返回一个当满足某一条件就变为Rejected的promise对象。

想对Error Object进行测试

function mayBeRejected(){ 
    return Promise.reject(new Error("woo"));
}
it("is bad pattern", function () {
    return mayBeRejected().catch(function (error) {
        assert(error.message === "woo");
    });
});

这个函数用来对返回的promise对象进行测试 这个测试的目的包括以下两点:

mayBeRejected() 返回的promise对象如果变为FulFilled状态的话 测试将会失败

mayBeRejected() 返回的promise对象如果变为Rejected状态的话 在 assert 中对Error对象进行检查 上面的测试代码,当promise对象变为Rejected的时候,会调用在 onRejected 中注册的函数,从而没有走正promise的处理常流程,测试会成功。

这段测试代码的问题在于当mayBeRejected() 返回的是一个 为FulFilled状态的promise对象时,测试会一直成功。

function mayBeRejected(){ 
    return Promise.resolve();
}
it("is bad pattern", function () {
    return mayBeRejected().catch(function (error) {
        assert(error.message === "woo");
    });
});

返回的promise对象会变为FulFilled 在这种情况下,由于在 catch 中注册的 onRejected 函数并不会被调用,因此 assert 也不会被执行,测试会一直通过(passed,成功)。

为了解决这个问题,我们可以在 .catch 的前面加入一个 .then 调用,可以理解为如果调用了 .then 的话,那么测试就需要失败。

function failTest() { 
    throw new Error("Expected promise to be rejected but it was fulfilled");
}
function mayBeRejected(){
    return Promise.resolve();
}
it("should bad pattern", function () {
    return mayBeRejected().then(failTest).catch(function (error) {
        assert.deepEqual(error.message === "woo");
    });
});

通过throw来使测试失败 但是,这种写法会像在前面 then or catch? 中已经介绍的一样, failTest 抛出的异常会被 catch 捕获。

Then Catch flow Figure 8. Then Catch flow 程序的执行流程为 then → catch,传递给 catch 的Error对象为AssertionError类型 , 这并不是我们想要的东西。

也就是说,我们希望测试只能通过状态会变为onRejected的promise对象, 如果promise对象状态为onFulfilled状态的话,那么该测试就会一直通过。

明确两种状态,改善测试中的意外(异常)状况

在编写 上面对Error对象进行测试的例子 时, 怎么才能剔除那些会意外通过测试的情况呢?

最简单的方式就是像下面这样,在测试代码中判断在各种promise对象的状态下,应进行如何的操作。

变为FulFilled状态的时候 测试会预期失败

变为Rejected状态的时候 使用 assert 进行测试 也就是说,我们需要在测试代码中明确指定在Fulfilled和Rejected这两种状态下,都需进行什么样的处理。

function mayBeRejected() {
    return Promise.resolve();
}
it("catch -> then", function () {
    // 变为FulFilled的时候测试失败
    return mayBeRejected().then(failTest, function (error) {
        assert(error.message === "woo");
    });
});

像这样的话,就能在promise变为FulFilled的时候编写出失败用的测试代码了。

Promise onRejected test Figure 9. Promise onRejected test 在 then or catch? 中我们已经讲过,为了避免遗漏对错误的处理, 与使用 .then(onFulfilled, onRejected) 这样带有二个参数的调用形式相比, 我们更推荐使用 then → catch 这样的处理方式。

但是在编写测试代码的时候,Promise强大的错误处理机制反而成了限制我们的障碍。 因此我们不得已采取了 .then(failTest, onRejected) 这种写法,明确指定promise在各种状态下进行何种的处理。

总结

在本小节中我们对在使用Mocha进行Promise测试时可能出现的一些意外情况进行了介绍。

普通的代码采用 then → catch 的流程的话比较容易理解

这是为了错误处理的方便。请参考 then or catch?

将测试代码集中到 then 中处理

为了能将AssertionError对象传递到测试框架中。

通过使用 .then(onFulfilled, onRejected) 这种形式的写法, 我们可以明确指定promise对象在变为 Fulfilled或Rejected时如何进行处理。

但是,由于需要显示的指定 Rejected时的测试处理, 像下面这样的代码看起来总是有一些让人感到不太直观的感觉。

promise.then(failTest, function(error){ // 使用assert对error进行测试 }); 在下一小节,我们会介绍如何编写helper函数以方便编写Promise的测试代码, 以及怎样去编写更容易理解的测试代码。

编写可控测试(controllable tests)

在继续进行说明之前,我们先来定义一下什么是可控测试。在这里我们对可控测试的定义如下。

待测试的promise对象

如果编写预期为Fulfilled状态的测试的话

Rejected的时候要 Fail

assertion 的结果不一致的时候要 Fail

如果预期为Rejected状态的话

结果为Fulfilled 测试为 Fail

assertion 的结果不一致的时候要 Fail

如果一个测试能网罗上面的用例(Fail)项,那么我们就称其为可控测试。

也就是说,一个测试用例应该包括下面的测试内容。

结果满足 Fulfilled or Rejected 之一

对传递给assertion的值进行检查

在前面使用了 .then 的代码就是一个期望结果为 Rejected 的测试。

promise.then(failTest, function(error){
    // 通过assert验证error对象
    assert(error instanceof Error);
});

必须明确指定转换后的状态

为了编写有效的测试代码, 我们需要明确指定 promise的状态 为 Fulfilled or Rejected 的两者之一。

但是由于 .then 的话在调用的时候可以省略参数,有时候可能会忘记加入使测试失败的条件。

因此,我们可以定义一个helper函数,用来明确定义promise期望的状态。

笔者(原著者)创建了一个类库 azu/promise-test-helper 以方便对Promise进行测试,本文中使用的是这个类库的简略版。 首先我们创建一个名为 shouldRejected 的helper函数,用来在刚才的 .then 的例子中,期待测试返回状态为 onRejected 的结果的例子。

shouldRejected-test.js
var assert = require('power-assert');
function shouldRejected(promise) {
    return {
        'catch': function (fn) {
            return promise.then(function () {
                throw new Error('Expected promise to be rejected but it was fulfilled');
            }, function (reason) {
                fn.call(promise, reason);
            });
        }
    };
}
it('should be rejected', function () {
    var promise = Promise.reject(new Error('human error'));
    return shouldRejected(promise).catch(function (error) {
        assert(error.message === 'human error');
    });
});

shouldRejected 函数接收一个promise对象作为参数,并且返回一个带有 catch 方法的对象。

在这个 catch 中可以使用和 onRejected 里一样的代码,因此我们可以在 catch 使用基于 assertion 方法的测试代码。

在 shouldRejected 外部,都是类似如下、和普通的promise处理大同小异的代码。

将需要测试的promise对象传递给 shouldRejected 方法

在返回的对象的 catch 方法中编写进行onRejected处理的代码

在onRejected里使用assertion进行判断

在使用 shouldRejected 函数的时候,如果是 Fulfilled 被调用了的话,则会throw一个异常,测试也会失败。

promise.then(failTest, function(error){
    assert(error.message === 'human error');
});
// == 几乎这两段代码是同样的意思
shouldRejected(promise).catch(function (error) {
    assert(error.message === 'human error');
});

使用 shouldRejected 这样的helper函数,测试代码也会变得很直观。

Promise onRejected test Figure 10. Promise onRejected test 像上面一样,我们也可以编写一个测试promise对象期待结果为Fulfilled的 shouldFulfilled helper函数。

shouldFulfilled-test.js
var assert = require('power-assert');
function shouldFulfilled(promise) {
    return {
        'then': function (fn) {
            return promise.then(function (value) {
                fn.call(promise, value);
            }, function (reason) {
                throw reason;
            });
        }
    };
}
it('should be fulfilled', function () {
    var promise = Promise.resolve('value');
    return shouldFulfilled(promise).then(function (value) {
        assert(value === 'value');
    });
});

这和上面的 shouldRejected-test.js 结构基本相同,只不过返回对象的 catch 方法变为了 then ,promise.then的两个参数也调换了。

小结

在本小节我们学习了如何编写针对Promise特定状态的测试代码,以及如何使用便于测试的helper函数。

这里我们使用到的 shouldFulfilled 和 shouldRejected 也可以在下面的类库中找到。

azu/promise-test-helper。 此外,本小节中的helper方法都是以 Mocha对Promise的支持 为前提的, 在 基于done 的测试 中使用的话可能会比较麻烦。

是使用基于测试框架对Promis的支持,还是使用基于类似done 这样回调风格的测试方式,每个人都可以自由的选择,只是风格问题,我觉得倒没必要去争一个孰优孰劣。

比如在 CoffeeScript下进行测试的话,由于CoffeeScript 会隐式的使用return返回,所以使用 done 的话可能更容易理解一些。

对Promise进行测试比对通常的异步函数进行测试坑更多,虽说采取什么样的测试方法是个人的自由,但是在同一项目中采取前后风格一致的测试则是非常重要。