w3ctech

[Jest]单元测试初学者指南 - 第二部分 - Spying and fake timers

原文: Unit Testing Beginners Guide - Part 2 - Spying and fake timers

作者:jstweetster

示例代码

在我们开始之前,如果你对Jest测试的基础不是很熟悉的话,请先确保你已经学习了这个系列文章的第一部分,关于介绍《使用Jest测试函数》

这一部分是前一篇文章的扩展,请确保你已经创建了 unit-testing-functions 目录,而且你已经安装了所有依赖(NodeJS & Jest)。

到目前为止我们看到的例子都非常基础:一个函数接收一些参数,计算结果并且返回结果。这使得单元测试变得轻而易举,因为您只需要调用它并返回它。

不幸的是,在现实生活中,事情没有那么的简单。有很多函数可以执行所谓的副作用(side-effects),这使得单元测试过程变得更加复杂:函数设置定时器,调用HTTP,DOM访问,写入磁盘等。

幸运的是,几乎所有这些案例都有适用的技术。

涉及使用定时器的测试函数

我们将会创建一个以秒为单位开始倒计时的定时函数。每一步将会调用一个 progressCallback 回调函数。当倒计时结束时,doneCallback 回调函数将会在最后被调用。

让我们创建 time.js 文件,添加如下代码:

function countdown(time, progressCallback, doneCallback) {
  progressCallback(time);
  setTimeout(function() {
    if( time > 1) {
      countdown(time-1, progressCallback, doneCallback);
    } else {
      doneCallback();
    }
  }, 1000);
}

module.exports = countdown;

怎样测试这段代码呢?让我们来尝试一下吧。

首先创建 timer.spec.js 文件,内容如下:

const countdown = require('../src/timer.js');

describe('timer suite', function() {
  test('Should call the done callback when the timer has finished counting', function() {
    countdown(1, function(currentTime) {
      console.log('Progress callback invoked with time '+currentTime);
    }, function() {
      console.log('Done callback invoked');
    });
  });
});

我们仅仅只是调用 countdown 函数,而且在调用 progressCallback 和调用 doneCallback 的时候在控制台打印日志。

运行测试 npm run test ,你将会看到如下输出:

 PASS  __tests__/timer.spec.js
  ● Console

    console.log __tests__/timer.spec.js:6
      Progress callback invoked with time 1

测试显然是通过了,有什么需要大惊小怪呢?

好吧,如果你仔细看看,请注意下面两件事:

  • 我们没有执行任何的断言
  • 即使我们只是给了一秒钟去倒计时,但是 doneCallback 函数没有被调用(没有日志输出)

它实际发生的情况是,因为测试中没有断言,所以没有可以验证函数行为并在错误的情况下抛出错误的情况出现。由于没有抛出任何错误,Jest认为测试成功。

不一定是这种情况,更改代码如下:

function countdown(time, progressCallback, doneCallback) {
  progressCallback(time);
  setTimeout(function() {
    if( time > 1) {
      // countdown(time-1, progressCallback, doneCallback);
    } else {
      // doneCallback();
    }
  }, 1000);
}

module.exports = countdown;

重新运行测试...测试依然通过。

所以看来我们的测试,就目前的形式而言,完全缺乏测试并不是更有用。并且它也不是测试运行器故障:即使您使用Mocha,Jasmine,Ava或其他任何测试运行器,它也不可能在没有断言的情况下验证行为。

在我的开发生涯中,我发现很多次我们是开发人员,包括我,被这些行为所欺骗:他们认为他们对某个区域进行了大量的测试,事实上,他们中的许多人都没有进行任何测试。

小建议

每当编写测试时,通过更改测试中的代码,验证它确实实际上做了应该做的事情。

稍微修改它以便测试失败并将其更改回来并确保它通过。

现在,恢复注释代码,让我们从一个基本问题开始:

我们应该验证(断言)有关此代码的什么内容?

倒计时函数的描述如下:

  • progressCallback 回调函数每秒钟会被调用一次
  • doneCallback 回调函数在最后会被调用

有了这个了解,我们该如果断言呢?

使用spies

我们使用spies来“spy”(窥探)一个函数的行为。

Jest文档对于spies的解释:

Mock函数也称为“spies”,因为它们让你窥探一些由其他代码间接调用的函数的行为,而不仅仅是测试输出。你可以通过使用 jest.fn() 创建一个mock函数。

简单来说,一个spy是另一个内置的能够记录对其调用细节的函数:调用它的次数,使用什么参数。

这对我们来说非常方便,因为我们需要做出的两个断言都必须验证是否调用了2个回调函数。

让我们使用“spy”来更改 timer.spec.js 中的测试方法:

const countdown = require('../src/timer.js');

describe('timer suite', function() {
  test('Should call the done callback when the timer has finished counting', function() {
    const progressCallbackSpy = jest.fn();
    const doneCallbackSpy = jest.fn();

    countdown(1, progressCallbackSpy, doneCallbackSpy);
  });
});

我们刚刚做的就是我们创建两个“自我纪录”(self-recording)的spy函数。它们是不做任何事情的函数,但知道如何记录对自己的调用(如果有的话)。

重新运行测试,测试结果依旧是通过的...

这是因为Jest不知道我们正在处理异步测试,而且 countdown 函数执行一个随时间异步跨越调用的函数。

在这些情况下,我们可以暗示Jest我们正在处理异步行为,让它知道它必须等待一段时间才能完成测试,然后再继续并执行 下一个测试。

更改 timer.spec.js 代码:

const countdown = require('../src/timer.js');

describe('timer suite', function() {
  test('Should call the done callback when the timer has finished counting', function(done) {
    const progressCallbackSpy = jest.fn();
    const doneCallbackSpy = jest.fn();

    countdown(1, progressCallbackSpy, doneCallbackSpy);
  });
});

注意 function(done) 这部分,这里是我们告诉Jest它正在处理一个异步的测试。此时运行测试,等待几秒,我将会看到:

 FAIL  __tests__/timer.spec.js (6.844s)
  ● timer suite › Should call the done callback when the timer has finished counting

    Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.

      2 |
      3 | describe('timer suite', function() {
    > 4 |   test('Should call the done callback when the timer has finished counting', function(done) {
        |   ^
      5 |     const progressCallbackSpy = jest.fn();
      6 |     const doneCallbackSpy = jest.fn();
      7 |

      at new Spec (node_modules/jest-jasmine2/build/jasmine/Spec.js:85:20)
      at Suite.test (__tests__/timer.spec.js:4:3)
      at Object.describe (__tests__/timer.spec.js:3:1)

最后,得到了一个测试失败。但是这并不是我们期望的失败。这个测试错误说:

Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.

超时 - 在jasmine.DEFAULT_TIMEOUT_INTERVAL指定的超时内未调用异步回调函数。

实际上意味着Jest希望我们调用异步回调来表示异步测试的结束,但我们却没有这样做。而且通过异步回调函数,它意味着 done 回调函数被声明为 function(donw) 回调的一部分。

那么这个 done 参数是做什么的?

  • done 是一个函数
  • 每次测试都由Jest注入
  • 当它被声明为测试函数的一部分时(就像上面所示的那样),它向Jest发出信号,表明测试是异步的。在这种情况下,Jest希望程序员调用此函数来发出测试结束的信号。

那怎么样才能让我们的测试通过呢?

仅仅只需要添加一个 done 函数的调用。

const countdown = require('../src/timer.js');

describe('timer suite', function() {
  test('Should call the done callback when the timer has finished counting', function(done) {
    const progressCallbackSpy = jest.fn();
    const doneCallbackSpy = jest.fn();

    countdown(1, progressCallbackSpy, doneCallbackSpy);
    done() // <- When this is called, we tell Jest the test is over!
  });
});

再次运行测试,我们观察到测试通过了。

但是如果我们仔细观察,我们就会最开始的情况。即使在所有的 “spy” 和异步的回调函数的情况下,单元测试依然会通过,即使它们不应该通过。

问题是我们不应该在目前正在进行的地方调用 done 函数。测试仅在经过1秒后完成,而不是在调用 countdown 函数的时候立即执行。

那么,我们如何等待1秒钟通过后然后在调用 done 函数呢?

一种方法是在 doneCallbackSpy 函数被调用的时候执行 done 函数。如果不是的话,由于错误或者其他原因,那么测试将超时并最终失败,这正是我们所期望的。

更改 timer.spec.js 代码如下:

const countdown = require('../src/timer.js');

describe('timer suite', function() {
  test('Should call the done callback when the timer has finished counting', function(done) {
    const progressCallbackSpy = jest.fn();
    const doneCallbackSpy = jest.fn(function () {
      console.log('done spy invoked');
      done();
    });

    countdown(1, progressCallbackSpy, doneCallbackSpy);
  });
});

让我们注意:

const doneCallbackSpy = jest.fn(function () {
  console.log('done spy invoked');
  done();
});

我已经告诉你 jest.fn() 会创建一个函数,当调用它时,它不会做任何事情。

但是当它使用像 jest.fn(replacementFunction) 这种方式时,它创建了一个函数,当调用它时,它会调用 replacementFunction

当然,它依然保留了 spy 的基本特征,即纪录返回函数的用法。

jest.fn(replacementFunction) 允许我们为 spy 提供一个函数,并在调用时调用 done 回调函数。

再次运行测试,测试通过。

通过修改 timer.js 中的代码,注释掉调用回调的部分来检查我们是不是在欺骗自己:

if( time > 1) {
  countdown(time-1, progressCallback, doneCallback);
} else {
  // doneCallback();
}

运行测试,等待几秒后,观察到测试现在失败了。这是因为 done 回调函数永远没有被调用。

因此我们正在测试一些情况的(doneCallback回调函数)。还原注释掉的代码。

还有一件事需要测试 - progressCallback 回调函数。

因此,我们可以在 countdowndoneCallback 回调函数中放置另一个断言,并验证是否已调用 progressCallback ,并断言它应该被调用了多少次。

const countdown = require('../src/timer.js');

describe('timer suite', function() {
  test('Should call the done callback when the timer has finished counting', function(done) {
    const progressCallbackSpy = jest.fn();
    const doneCallbackSpy = jest.fn(function () {
      expect(progressCallbackSpy.mock.calls.length).toBe(1); // <= How many times it was called
      const firstCall = progressCallbackSpy.mock.calls[0];
      const firstCallArg = firstCall[0];
      expect(firstCallArg).toBe(1); // <= first param, of the first call,  is number 1
      done();
    });

    countdown(1, progressCallbackSpy, doneCallbackSpy);
  });
});

这里关键的部分是 mockFn.mock.calls 部分 (https://facebook.github.io/jest/docs/en/mock-function-api.html#mockfnmockcalls)。

我们断言了两件事情:

  1. expect(progressCallbackSpy.mock.calls.length).toBe(1); - progressCallback 回调函数只被调用一次。
  2. expect(firstCallArg).toBe(1); - progressCallback 回调函数的参数是剩余的时间

一切似乎都很好,让我们添加第二个单元测试。

timer.spec.js 文件中添加如下代码:

test('Should call the done callback when the timer has finished counting and the countdown is 4 secs', function(done) {
        const progressCallbackSpy = jest.fn();
        const doneCallbackSpy = jest.fn(function() {
            expect(progressCallbackSpy.mock.calls.length).toBe(4);
            done();
        });

        countdown(4, progressCallbackSpy, doneCallbackSpy);
});

运行测试。观察单元测试完成所花费的时间如何增加到大约4秒。

这不好......如果不是4秒,倒计时将是1000秒?

我们真正需要的是把时间放在“快进”(fast-forward)上。

在单元测试中操作时间

因此,我们可以在Jest中使用强大的计时器模拟(timer mocks):

jest.useFakeTimers()

这将实际的 setTimeoutsetInterval 等函数替换成其他允许我们快进时间的函数。

让我们在 timer.spec.js 中首先启用伪装定时器(fake timers):

const countdown = require('../src/timer.js');

jest.useFakeTimers(); // <= This mocks out any call to setTimeout, setInterval with dummy functions

接下来,让我们使用 jest.runTimersToTime(msToRun) 更改测试和快进时间。

test('Should call the done callback when the timer has finished counting', function() {
    const progressCallbackSpy = jest.fn();
    const doneCallbackSpy = jest.fn();
    countdown(1, progressCallbackSpy, doneCallbackSpy);

    jest.runTimersToTime(1000); // <= Move the time ahead with 1 second

    expect(progressCallbackSpy.mock.calls.length).toBe(1);
    const firstCall = progressCallbackSpy.mock.calls[0];
    const firstCallArg = firstCall[0];
    expect(firstCallArg).toBe(1);
});

test('Should call the done callback when the timer has finished counting and the countdown is 4 secs', function() {
    const progressCallbackSpy = jest.fn();
    const doneCallbackSpy = jest.fn();

    countdown(4, progressCallbackSpy, doneCallbackSpy);

    jest.runTimersToTime(4000); // <= Move the time ahead with 4 seconds

    expect(progressCallbackSpy.mock.calls.length).toBe(4);
});

一些说明:

  • 我们删除了 done 回调函数,因为测试不在时异步的(我们通过调用 jest.useFakeTimers()函数模拟 setTimeout
  • 我们实现了一个 done spy ,一个不做任何事情的函数 const doneCallbackSpy = jest.fn();
  • 我们正在调用 countdown 函数,并且以1秒/4秒快进时间: jest.runTimersToTime(1000);
  • 我们随后便可以进行断言,因为我们不需要再等待时间才能断言。

现在测试运行的更快并且更加可靠了!

这就结束了 "spying" 和测试时间相关的功能教程了。

请继续关注本系列的下一部分,介绍更多高级技术,以模拟和测试XHR请求和DOM访问。

我很乐意听取您关于测试此类代码的经验的评论!

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复