【JavaScript】 JavaScript 单元测试框架:Jasmine

Posted by 西维蜀黍 on 2017-04-09, Last Modified on 2021-09-21

简介

Jasmine 是一款完全开源的基于行为驱动(BDD,behavior-driven development)的 JavaScript 测试框架,它不依赖于其他任何 JavaScript 组件,也不需要 DOM。它有灵活而清晰的语法,可以帮助我们最高效的完成测试代码。

不仅如此,对于 Jasmine 的测试结果,可以将直接现在在HTML上。也可以将 Jasmine 与 持续集成系统(CI,Continuous Integration)对接,使得测试结果可以通过持续集成系统反馈给开发者。

所谓BDD(Behaviour Driven Development),是一种新的敏捷软件开发方法。

BDD与TDD(Test Driven Development)经常联系在一起。我的理解:

TDD更多从技术层面阐述,是指当软件产生需求的时候,程序员应该先完成这一需求对应的测试用例(或者说用一系列测试用例来描述这一需求),再开始进行代码编码工作。最终以代码是否通过这一测试用例来判断代码与需求是否匹配。总的来说,TDD更多是在描述码农的工作方式。

BDD更多是从完成业务需求的角度阐述,是指一种可降低开发者与非开发者(如项目经理)之间需求沟通和理解成本的工作方式,可使得非程序开发人员也能参与到编写测试用例的编写工作中来。其根本目的是希望码农开发出来的功能与项目模块的需求相切合

Demo 体验

https://github.com/jasmine/jasmine/releases 中,官方有一个Demo,因此可以体验一把,并感觉一下其风格。

我下载的是jasmine-standalone-2.5.2,对应地址:https://github.com/jasmine/jasmine/releases/download/v2.5.2/jasmine-standalone-2.5.2.zip。

解压后的文件结构:

// 运行测试用例的环境
SpecRunner.html
// 要测试的源代码
-src
// 测试用例
-spec
// jasmine的源代码
-lib

执行SpecRunner.html以查看测试结果:

这里的测试用例,更像是业务人员描述的一个使用场景,且当这个使用场景发生时,期待的是什么状态。其实这就是对BDD的诠释。

项目中添加 Jasmine

你可通过Node,在项目中添加Jasmine

// Add Jasmine to your package.json
npm install --save-dev jasmine

// Initialize Jasmine in your project
jasmine init

// Run your tests
jasmine

当然,更常用的做法是,通过Gulp or Grunt 构建工具以在项目以集成 Jasmine。

Jasmine 基本使用

  • 官方API文档:https://jasmine.github.io/api/edge/global
  • 官方介绍:https://jasmine.github.io/edge/introduction.html

以下例子均从官网搬运!

下来我们来详细讲解一下 Jasmine 中的一些概念,和测试用例的使用方法。

一个 Jasmine 例子:

// 对应一个Suite
describe('JavaScript addition operator',function(){
    // 对应一个Spec
    it('adds two numbers together',function(){
        // 对应一个Expectation
        expect(1 + 2).toEqual(3);
    });
});

测试用例基本结构

Suites - 测试组

对应describe函数块,通常包含一个到多个测试用例。

describe函数包含两个参数:

  • 第一个参数(String):通常描述被测试组件的名称,或一个测试的场景(where…,when…)
  • 第二个参数(function):测试代码

Specs - 测试用例

对应it函数块,通常仅仅包含一个测试用例。

it函数包含两个参数:

  • 第一个参数(String):通常描述期待的测试结果
  • 第二个参数(function):测试代码

Expectations - 断言

对应expect函数块,通常是一个具体的判断。

注意

注意,一个Suite(describe)可以包含多个Suite(describe),也可以包含多个Specs(it)。一个Specs(it)包含多个断言(expect)。

字符串的描述

又一个 Jasmine 例子:

describe("Player", function() {
  var player;
  var song;

  it("should be able to play a Song", function() {
    player = player = new Player();
    song = song = new Song();

    player.play(song);
    expect(player.currentlyPlayingSong).toEqual(song);

    //demonstrates use of custom matcher
    expect(player).toBePlaying(song);
  });

  describe("when song has been paused", function() {
    it("should indicate that the song is currently paused", function() {
      player = player = new Player();
      song = song = new Song();

      player.play(song);
      player.pause();

      expect(player.isPlaying).toBeFalsy();

      // demonstrates use of 'not' with a custom matcher
      expect(player).not.toBePlaying(song);
    });
  });

});

在上面的例子中,把用例的描述连接起来,变成 it should be able to play a Songwhen song has been paused, it should indicate that the song is currently paused。你会发现他们通常是一个完整的句子,而且是一个场景的描述。这就是BDD的精髓所在。

Matches 判断

以下用例均可通过!

not

对于以下的每一个Match,都可以在前面加上not,以表示

比如,

describe("The 'toBe' matcher compares with ===", function() {
  it("and has a positive case", function() {
    expect(true).toBe(true);
  });

  it("and can have a negative case", function() {
    expect(false).not.toBe(true);
  });
});

toBe

相当于==

  • 对于对象,比较引用是否相等
  • 对于值,比较值是否相等
describe("The 'toBe' matcher", function() {
  it("and so is a spec", function() {
    var a = true;

    expect(a).toBe(true);
  });

  it("The 'toBe' matcher compares with ===", function() {
    var a = 12;
    var b = a;

    expect(a).toBe(b);
    expect(a).not.toBe(null);
  });
});

toEqual

相当于===

  • 对于对象,比较对象的每个属性是否相同
  • 对于数组,比较数组中每个元素是否相同
  • 对于值,比较值是否相等
describe("The 'toEqual' matcher", function() {
    it("works for simple literals and variables", function() {
      var a = 12;
      expect(a).toEqual(12);
    });

    it("should work for objects", function() {
      var foo = {
        a: 12,
        b: 34
      };
      var bar = {
        a: 12,
        b: 34
      };
      expect(foo).toEqual(bar);
    });
});

toMatch

通常用toMatch配合正则表达式来匹配字符串。

describe("The 'toMatch' matcher", function() {
  it("The 'toMatch' matcher is for regular expressions", function() {
    var message = "foo bar baz";

    expect(message).toMatch(/bar/);
    expect(message).toMatch("bar");
    expect(message).not.toMatch(/quux/);
  });
});

toBeDefined与toBeUndefined

toBeDefined是否已经被定义了。

describe("The 'toBeDefined' matcher",function() {
    it("The 'toBeDefined' matcher compares against `undefined`",
    function() {
        var a = {
            foo: "foo"
        };

        expect(a.foo).toBeDefined();
        expect(a.bar).not.toBeDefined();
});

toBeUndefined是否未被定义了。

describe("The 'toBeUndefined' matcher",function() {
  it("The `toBeUndefined` matcher compares against `undefined`", function() {
    var a = {
      foo: "foo"
    };

    expect(a.foo).not.toBeUndefined();
    expect(a.bar).toBeUndefined();
  });
});

toBeTruthy 与 toBeFalsy

toBeTruthy判断是否为 true。

describe("The 'toBeTruthy' matcher",function() {
  it("The 'toBeTruthy' matcher is for boolean casting testing", function() {
    var a, foo = "foo";

    expect(foo).toBeTruthy();
    expect(a).not.toBeTruthy();
  });
});

toBeFalsy判断是否为 false。

describe("The 'toBeFalsy' matcher",function() {
  it("The 'toBeFalsy' matcher is for boolean casting testing", function() {
    var a, foo = "foo";

    expect(a).toBeFalsy();
    expect(foo).not.toBeFalsy();
  });
});

toContain

toContain判断数组中是否包含某个元素。

describe("The 'toContain' matcher", function() {
    it("works for finding an item in an Array", function() {
      var a = ["foo", "bar", "baz"];

      expect(a).toContain("bar");
      expect(a).not.toContain("quux");
    });

    it("also works for finding a substring", function() {
      var a = "foo bar baz";

      expect(a).toContain("bar");
      expect(a).not.toContain("quux");
    });
});

toBeLessThan 和 toBeGreaterThan

toBeLessThan判断是否小于,toBeGreaterThan判断是否大于。

describe("The 'toBeLessThan' and 'toBeGreaterThan' matcher", function() {

   it("The 'toBeLessThan' matcher is for mathematical comparisons", function() {
    var pi = 3.1415926,
      e = 2.78;

    expect(e).toBeLessThan(pi);
    expect(pi).not.toBeLessThan(e);
  });

  it("The 'toBeGreaterThan' matcher is for mathematical comparisons", function() {
    var pi = 3.1415926,
      e = 2.78;

    expect(pi).toBeGreaterThan(e);
    expect(e).not.toBeGreaterThan(pi);
  });

  it("The 'toBeCloseTo' matcher is for precision math comparison", function() {
    var pi = 3.1415926,
      e = 2.78;

    expect(pi).not.toBeCloseTo(e, 2);
    expect(pi).toBeCloseTo(e, 0);
  });
});

toThrow 和 toThrowError

toThrow判断是否抛出了异常,toThrowError判断是否抛出了特定的异常。

describe("The 'toThrow' and 'toThrowError' matcher",function() {
    it("The 'toThrow' matcher is for testing if a function throws an exception",function() {
        var foo = function() {
            return 1 + 2;
        };
        var bar = function() {
            return a + 1;
        };
        var baz = function() {
            throw 'what';
        };

        expect(foo).not.toThrow();
        expect(bar).toThrow();
        expect(baz).toThrow('what');
    });

    it("The 'toThrowError' matcher is for testing a specific thrown exception",function() {
        var foo = function() {
            throw new TypeError("foo bar baz");
        };

        expect(foo).toThrowError("foo bar baz");
        expect(foo).toThrowError(/bar/);
        expect(foo).toThrowError(TypeError);
        expect(foo).toThrowError(TypeError, "foo bar baz");
    });
});

一些操作

beforeEach 和 afterEach

为了增加测试用例模块的复用性,在一个测试用例集合中,对于一些执行测试用例前或执行测试用例后统一的操作可以放到beforeEach 函数和 afterEach 函数(或者beforeAll()afterAll())中。

如果你熟悉 SetupTeardown(来自于JUnit) ,其实他们和beforeEachafterEach就是一回事啦!

  • beforeEach():在 describe 函数中的每个 Spec 执行之前执行
  • afterEach(): 在 describe 函数中每个 Spec 数执行之后执行
  • beforeAll():在 describe 函数中所有的 Specs 执行之前执行,在 Sepc 之间不会被执行
  • afterAll(): 在 describe 函数中所有的 Specs 执行之后执行,在 Sepc 之间不会被执行

还是举例子来说明beforeEachafterEach吧:

describe("A spec using beforeEach and afterEach", function() {
  var foo = 0;

  beforeEach(function() {
    foo += 1;
  });

  afterEach(function() {
    foo = 0;
  });

  it("is just a function, so it can contain any code", function() {
    expect(foo).toEqual(1);
  });

  it("can have more than one expectation", function() {
    // 这个用例是通过的,是因为在上一个用例执行后,调用了afterEach
    expect(foo).toEqual(1);
  });
});

执行顺序:

  • beforeEach
  • it(“is just a function, so it can contain any code”)
  • afterEach
  • beforeEach
  • it(“can have more than one expectation”)
  • afterEach

再来举例子说明beforeAll()afterAll()吧:

describe("A spec using beforeAll and afterAll", function() {
  var foo;

  beforeAll(function() {
    foo = 1;
  });

  afterAll(function() {
    foo = 0;
  });

  it("sets the initial value of foo before specs run", function() {
    expect(foo).toEqual(1);
    foo += 1;
  });

  it("does not reset foo between specs", function() {
    expect(foo).toEqual(2);
  });
});

执行顺序:

  • beforeAll
  • it(“sets the initial value of foo before specs run”)
  • it(“does not reset foo between specs”)
  • afterAll

总结

beforeAll()afterAll()通常用于执行最初初始化和最终的清洁工作。再次强调,beforeAll()afterAll()不会在每个Specs 之间执行,因此,每个Specs 之间的清洁工作需要放在beforeEachafterEach中。

手工使测试失败

在某些场合下,如果我们希望在满足某个条件时,测试用例失败,则可以使用fail函数。

describe("A spec using the fail function",function() {
    it("The 'toThrow' matcher is for testing if a function throws an exception",function() {
        var a = true;
        if(!a){
            fail("Fail manually.");
        }

        expect(a).toBeTruthy();
    });
});

在多个测试用例内部传值

this关键字可用于在多个测试用例内部传值。

describe("A spec", function() {
  beforeEach(function() {
    this.foo = 0;
  });

  it("can use the `this` to share state", function() {
    expect(this.foo).toEqual(0);
    this.bar = "test pollution?";
  });

  it("prevents test pollution by having an empty `this` created for the next spec", function() {
    expect(this.foo).toEqual(0);
    expect(this.bar).toBe(undefined);
  });
});

禁用测试用例

如果希望禁用某些测试用例,可以使用xdescribexit

当被禁用后,这些 Suites 和 Specs 会被跳过,也不会在结果中出现。

xdescribe("A spec", function() {
  var foo;

  beforeEach(function() {
    foo = 0;
    foo += 1;
  });

  it("is just a function, so it can contain any code", function() {
    expect(foo).toEqual(1);
  });
});

Jasmine 高阶使用

自定义的断言

除了上面介绍的系统定义的断言之外,也可以使用自己定义断言。

以 Jasmine 的 Demo 中的 toBePlaying 作为自定义断言的例子。

//demonstrates use of custom matcher
expect(player).toBePlaying(song);

定义:

beforeEach(function() {
    jasmine.addMatchers({
        toBePlaying: function() {
            return {
                compare: function(actual, expected) {
                    var player = actual;
                    return {
                        pass: player.currentlyPlayingSong === expected && player.isPlaying
                    };
                }
            };
        }
    });
});

高阶使用

测试函数被调用

spy对象可用于跟踪和检查一个函数被调用的次数和传入的参数。

  • toHaveBeenCalled:可以检查函数是否被调用过,
  • toHaveBeenCalledWith: 可以检查传入参数是否被作为参数调用过。
describe("A spy", function() {
  var foo, bar = null;

  beforeEach(function() {
    foo = {
      setBar: function(value) {
        bar = value;
      }
    };

    spyOn(foo, 'setBar');

    foo.setBar(123);
    foo.setBar(456, 'another param');
  });

  it("tracks that the spy was called", function() {
    expect(foo.setBar).toHaveBeenCalled();
  });

  it("tracks that the spy was called x times", function() {
    expect(foo.setBar).toHaveBeenCalledTimes(2);
  });

  it("tracks all the arguments of its calls", function() {
    expect(foo.setBar).toHaveBeenCalledWith(123);
    expect(foo.setBar).toHaveBeenCalledWith(456, 'another param');
  });

  it("stops all execution on a function", function() {
    expect(bar).toBeNull();
  });
});

指定函数返回值

通过and.returnValue,可以指定某个函数的返回值。

describe("A spy, when configured to fake a return value", function() {
  var foo, bar, fetchedBar;

  beforeEach(function() {
    foo = {
      setBar: function(value) {
        bar = value;
      },
      getBar: function() {
        return bar;
      }
    };

    spyOn(foo, "getBar").and.returnValue(745);

    foo.setBar(123);
    fetchedBar = foo.getBar();
  });

  it("tracks that the spy was called", function() {
    expect(foo.getBar).toHaveBeenCalled();
  });

  it("should not affect other functions", function() {
    expect(bar).toEqual(123);
  });

  it("when called returns the requested value", function() {
    expect(fetchedBar).toEqual(745);
  });
});

调用函数时抛出指定异常

and.throwError用于设置,当调用某个执行函数时,抛出指定异常。

describe("A spy, when configured to throw an error", function() {
  var foo, bar;

  beforeEach(function() {
    foo = {
      setBar: function(value) {
        bar = value;
      }
    };

    spyOn(foo, "setBar").and.throwError("quux");
  });

  it("throws the value", function() {
    expect(function() {
      foo.setBar(123)
    }).toThrowError("quux");
  });
});

构造一个mock对象

当没有一个具体的方法来spyon时,可以使用jasmine.createSpy来创建一个mock对象。

describe("A spy, when created manually", function() {
  var whatAmI;

  beforeEach(function() {
    whatAmI = jasmine.createSpy('whatAmI');

    whatAmI("I", "am", "a", "spy");
  });

  it("is named, which helps in error reporting", function() {
    expect(whatAmI.and.identity()).toEqual('whatAmI');
  });

  it("tracks that the spy was called", function() {
    expect(whatAmI).toHaveBeenCalled();
  });

  it("tracks its number of calls", function() {
    expect(whatAmI.calls.count()).toEqual(1);
  });

  it("tracks all the arguments of its calls", function() {
    expect(whatAmI).toHaveBeenCalledWith("I", "am", "a", "spy");
  });

  it("allows access to the most recent call", function() {
    expect(whatAmI.calls.mostRecent().args[0]).toEqual("I");
  });
});

当然,还可以使用jasmine.createSpyObj来创建一个mock对象,并且指定其包含特定的方法。

describe("Multiple spies, when created manually", function() {
  var tape;

  beforeEach(function() {
    tape = jasmine.createSpyObj('tape', ['play', 'pause', 'stop', 'rewind']);

    tape.play();
    tape.pause();
    tape.rewind(0);
  });

  it("creates spies for each requested function", function() {
    expect(tape.play).toBeDefined();
    expect(tape.pause).toBeDefined();
    expect(tape.stop).toBeDefined();
    expect(tape.rewind).toBeDefined();
  });

  it("tracks that the spies were called", function() {
    expect(tape.play).toHaveBeenCalled();
    expect(tape.pause).toHaveBeenCalled();
    expect(tape.rewind).toHaveBeenCalled();
    expect(tape.stop).not.toHaveBeenCalled();
  });

  it("tracks all the arguments of its calls", function() {
    expect(tape.rewind).toHaveBeenCalledWith(0);
  });
});

mock 当前时间

比如一些操作,是经过多少时间后、或者间隔多少时间才会触发的,可以mock当前时间。

注意,需要在开始前调用 jasmine.clock().install,在结束时调用jasmine.clock().uninstall()

jasmine.clock().tick 用于指定经过了多长时间。

describe("Manually ticking the Jasmine Clock", function() {
  var timerCallback;

  beforeEach(function() {
    timerCallback = jasmine.createSpy("timerCallback");
    jasmine.clock().install();
  });

  afterEach(function() {
    jasmine.clock().uninstall();
  });

  it("causes a timeout to be called synchronously", function() {
    setTimeout(function() {
      timerCallback();
    }, 100);

    expect(timerCallback).not.toHaveBeenCalled();

    jasmine.clock().tick(101);

    expect(timerCallback).toHaveBeenCalled();
  });

  it("causes an interval to be called synchronously", function() {
    setInterval(function() {
      timerCallback();
    }, 100);

    expect(timerCallback).not.toHaveBeenCalled();

    jasmine.clock().tick(101);
    expect(timerCallback.calls.count()).toEqual(1);

    jasmine.clock().tick(50);
    expect(timerCallback.calls.count()).toEqual(1);

    jasmine.clock().tick(50);
    expect(timerCallback.calls.count()).toEqual(2);
  });

  describe("Mocking the Date object", function(){
    it("mocks the Date object and sets it to a given time", function() {
      var baseTime = new Date(2013, 9, 23);
      jasmine.clock().mockDate(baseTime);

      jasmine.clock().tick(50);
      expect(new Date().getTime()).toEqual(baseTime.getTime() + 50);
    });
  });
});

Jasmine 与其他工具集成

Jasmine 默认不包括任何测试执行管理工具(当然这不意味着你不可以通过打开SpecRunner.html来执行所有测试)。

因此,使用 Jasmine 的最佳实践是以 Karma 作为 JavaScript 测试执行过程管理工具

换句话说,如果你不使用任何自动化构建框架,你可以直接手动的打开SpecRunner.html来执行所有测试用例。但是,在当今的敏捷开发时代,尽可能将大部分流程自动化,往往是更科学更高效的开发流程,这时候我们就要使用 Karma 了。

这个关系好比,在 Java 中, JUnit 做单元测试, Maven 以使 JUnit 单元测试自动化进行。

关于 Karma 的安装:https://github.com/karma-runner/karma-jasmine

参考