初識Jasmine
<small><small>Jin Sun, January 17, 2016</small></small>
我們要聊些什么:
- 一個不錯的引子
- 簡單粗暴的介紹
- 那么我們開始吧
- 我該如何使用呢
- 與KnockoutJS不得不說的故事
引子
我們不缺乏解決問題的能力,我們?nèi)鄙俚闹皇歉绨l(fā)現(xiàn)問題的方法,而自動化測試可以幫助我們,所以讓我們歡迎 <big><big>Jasmine</big></big>.
介紹
Jasmine 是一款 JavaScript 測試框架,它不依賴于其他任何 JavaScript 組件。它有干凈清晰的語法,讓你可以很簡單的寫出測試代碼。
開始
-
前往 Jasmine 官網(wǎng)下載standalone版本。
image -
將jasmine-standalone-xxx.zip解壓,運行SpecRunner.html,你會看到下面的界面:
image 打開SpecRunner.html,我們看看它的用法:
<html>
<head>
<meta charset="utf-8">
<title>Jasmine Spec Runner v2.4.1</title>
<link rel="shortcut icon" type="image/png" href="lib/jasmine-2.4.1/jasmine_favicon.png">
<link rel="stylesheet" href="lib/jasmine-2.4.1/jasmine.css">
<!-- 測試界面css樣式 -->
<script src="lib/jasmine-2.4.1/jasmine.js"></script>
<!-- 核心文件用于執(zhí)行單元測試的類庫 -->
<script src="lib/jasmine-2.4.1/jasmine-html.js"></script>
<!-- 用于顯示單元測試結(jié)果的類庫 -->
<script src="lib/jasmine-2.4.1/boot.js"></script>
<!-- 用于初始化單元測試所需的執(zhí)行環(huán)境類庫 -->
<!-- include source files here... -->
<script src="src/Player.js"></script>
<script src="src/Song.js"></script>
<!-- include spec files here... -->
<script src="spec/SpecHelper.js"></script>
<script src="spec/PlayerSpec.js"></script>
</head>
<body>
</body>
</html>
使用
打開測試文件PlayerSpec.js,我們會看到describe,it,beforeEach,afterEach,expect,toBe....都是些什么意思呢?
Jasmine有四個核心概念:分組(Suites)、用例(Specs)、期望(Expectations)、匹配(Matchers).
Suites
Suites可以理解為一組測試用例,使用全局的Jasmin函數(shù)describe 創(chuàng)建。describe函數(shù)接受兩個參數(shù),一個字符串和一個函數(shù)。字符串是這個Suites的名字或標題(通常描述下測試內(nèi)容),函數(shù)是實現(xiàn)Suites的代碼塊。
Specs
Specs可以理解為一個測試用例,使用全局的Jasmin函數(shù)it創(chuàng)建。和describe一樣接受兩個參數(shù),一個字符串和一個函數(shù),函數(shù)就是要執(zhí)行的測試代碼,字符串就是測試用例的名字。一個Spec可以包含多個expectations來測試代碼。
Expectations
Expectations由expect 函數(shù)創(chuàng)建。接受一個參數(shù)。和Matcher一起聯(lián)用,設置測試的預期值。
在分組(describe)中可以寫多個測試用例(it),也可以再進行分組(describe),在測試用例(it)中定義期望表達式(expect)和匹配判斷(toBe**)??匆粋€簡單的Demo:
describe("A suite", function() {//suites
var a;
it("A spec", function() {//spec
a = true;
expect(a).toBe(true);//expectations
});
describe("a suite", function() {//inner suites
it("a spec", function() {//spec
expect(a).toBe(true);//expectations
});
});
});
Matchers
Matcher實現(xiàn)一個“期望值”與“實際值”的對比,如果結(jié)果為true,則通過測試,反之,則失敗。每一個matcher都能通過not執(zhí)行否定判斷。
簡單的matchers:
expect(a).toBe(true);//期望變量a為true
expect(a).toEqual(true);//期望變量a等于true
expect(a).toMatch(/reg/);//期望變量a匹配reg正則表達式,也可以是字符串
expect(a.foo).toBeDefined();//期望a.foo已定義
expect(a.foo).toBeUndefined();//期望a.foo未定義
expect(a).toBeNull();//期望變量a為null
expect(a.isMale).toBeTruthy();//期望a.isMale為真
expect(a.isMale).toBeFalsy();//期望a.isMale為假
expect(true).toEqual(true);//期望true等于true
expect(a).toBeLessThan(b);//期望a小于b
expect(a).toBeGreaterThan(b);//期望a大于b
expect(a).toThrowError(/reg/);//期望a方法拋出異常,異常信息可以是字符串、正則表達式、錯誤類型以及錯誤類型和錯誤信息
expect(a).toThrow();//期望a方法拋出異常
expect(a).toContain(b);//期望a(數(shù)組或者對象)包含b
其他matchers:
jasmine.any(Class)--傳入構(gòu)造函數(shù)或者類返回數(shù)據(jù)類型作為期望值,返回true表示實際值和期望值數(shù)據(jù)類型相同:
it("matches any value", function() {
expect({}).toEqual(jasmine.any(Object));
expect(12).toEqual(jasmine.any(Number));
});
jasmine.anything()--如果實際值不是null或者undefined則返回true:
it("matches anything", function() {
expect(1).toEqual(jasmine.anything());
});
jasmine.objectContaining({key:value})--實際數(shù)組只要匹配到有包含的數(shù)值就算匹配通過:
foo = {
a: 1,
b: 2,
bar: "baz"
};
expect(foo).toEqual(jasmine.objectContaining({bar: "baz"}));
jasmine.arrayContaining([val1,val2,...])--stringContaining可以匹配字符串的一部分也可以匹配對象內(nèi)的字符串:
expect({foo: 'bar'}).toEqual({foo: jasmine.stringMatching(/^bar$/)});
expect('foobarbaz').toEqual({foo: jasmine.stringMatching('bar')});
<small>Jasmine還支持自定義Matchers,今天我們先不展開了.</small>
Setup and Teardown
為了在復雜的測試用例中更加便于組裝和拆卸,Jasmine提供了四個函數(shù):
beforeEach(function) //在每一個測試用例(it)執(zhí)行之前都執(zhí)行一遍beforeEach函數(shù);
afterEach(function) //在每一個測試用例(it)執(zhí)行完成之后都執(zhí)行一遍afterEach函數(shù);
beforeAll(function) //在所有測試用例執(zhí)行之前執(zhí)行一遍beforeAll函數(shù);
afterAll(function) //在所有測試用例執(zhí)行完成之后執(zhí)行一遍afterAll函數(shù);
對照一下結(jié)果看一下下面的例子就一目了然啦:
describe("A spec using beforeEach and afterEach", function() {
var foo = 0;
beforeEach(function() {
foo += 1;
le.log("I am beforEach");
});
afterEach(function() {
foo = 0;
console.log("I am afterEach");
});
it("A spec1", function() {
expect(foo).toEqual(1);
console.log("I am spec1");
});
it("A spec2", function() {
expect(foo).toEqual(1);
expect(true).toEqual(true);
console.log("I am spec2");
});
});
describe("A spec using beforeAll and afterAll", function() {
var foo;
beforeAll(function() {
foo = 1;
console.log("I am beforAll");
});
afterAll(function() {
foo = 0;
console.log("I am afterAll");
});
it("A spec1", function() {
expect(foo).toEqual(1);
foo += 1;
console.log("I am A spec1");
});
it("A spec2", function() {
expect(foo).toEqual(2);
console.log("I am A spec2");
});
});
最終輸出:

Suites禁用和Specs掛起
Jasmine提供xdescrib和xit方法用于屏蔽測試用例,xdescribe里面的定義的it、beforeEach、afterEach等之類的方法不會執(zhí)行,describe里面定義的xit不會執(zhí)行。
測試文檔遇到用例掛起的地方就不會執(zhí)行期望的匹配表達式,有三種使用方法:
describe("Pending specs", function() {
xit("can be declared 'xit'", function() {//第一種,使用xit將測試用例直接屏蔽
expect(true).toBe(false);
});
it("can be declared with 'it' but without a function");//第二種,只聲明it,不定義回調(diào)函數(shù)
it("can be declared by calling 'pending' in the spec body", function() {
expect(true).toBe(false);
pending('this is why it is pending');//第三種,使用pending函數(shù)
});
});
Spy追蹤
Jasmine具有函數(shù)的追蹤和反追蹤的雙重功能,這東西就是Spy!
Spy能夠存儲任何函數(shù)調(diào)用記錄和傳入的參數(shù),Spy只存在于describe和it中,在spec執(zhí)行完之后銷毀。說的這么晦澀,還是直接上例子吧:
describe("A spy", function() {
var foo, bar = null;
beforeEach(function() {
foo = {
setBar: function(value) {
bar = value;
}
};
spyOn(foo, 'setBar');//給foo對象的setBar函數(shù)綁定追蹤
foo.setBar(123);
foo.setBar(456, 'another param');
});
it("tracks that the spy was called", function() {
expect(foo.setBar).toHaveBeenCalled();//toHaveBeenCalled用來匹配測試函數(shù)是否被調(diào)用過
});
it("tracks all the arguments of its calls", function() {
expect(foo.setBar).toHaveBeenCalledWith(123);//toHaveBeenCalledWith用來匹配測試函數(shù)被調(diào)用時的參數(shù)列表
expect(foo.setBar).toHaveBeenCalledWith(456, 'another param');//期望foo.setBar已經(jīng)被調(diào)用過,且傳入?yún)?shù)為[456, 'another param']
});
it("stops all execution on a function", function() {
expect(bar).toBeNull();//用例沒有執(zhí)行foo.setBar,bar為null
});
});
and.callThrough--spy鏈式調(diào)用and.callThrough后,在獲取spy的同時,調(diào)用實際的函數(shù),看示例:
describe("A spy, when configured to call through", function() {
var foo, bar, fetchedBar;
beforeEach(function() {
foo = {
setBar: function(value) {
bar = value;
},
getBar: function() {
return bar;
}
};
spyOn(foo, 'getBar').and.callThrough();//調(diào)用and.callThrough方法
foo.setBar(123);
fetchedBar = foo.getBar();//因為and.callThrough,這里執(zhí)行的是foo.getBar方法,而不是spy的方法
});
it("tracks that the spy was called", function() {
expect(foo.getBar).toHaveBeenCalled();
});
it("should not effect other functions", function() {
expect(bar).toEqual(123);
});
it("when called returns the requested value", function() {
expect(fetchedBar).toEqual(123);
});
});
and.returnValue--spy鏈式調(diào)用and.returnValue 后,任何時候調(diào)用該方法都只會返回指定的值,比如:
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);//指定返回值為745
foo.setBar(123);
fetchedBar = foo.getBar();
});
it("tracks that the spy was called", function() {
expect(foo.getBar).toHaveBeenCalled();
});
it("should not effect other functions", function() {
expect(bar).toEqual(123);
});
it("when called returns the requested value", function() {
expect(fetchedBar).toEqual(745);//默認返回指定的returnValue值
});
});
and.callFake--spy鏈式添加and.callFake相當于用新的方法替換spy的方法,比如:
describe("A spy, when configured with an alternate implementation", function() {
var foo, bar, fetchedBar;
beforeEach(function() {
foo = {
setBar: function(value) {
bar = value;
},
getBar: function() {
return bar;
}
};
spyOn(foo, "getBar").and.callFake(function() {//指定callFake方法
return 1001;
});
foo.setBar(123);
fetchedBar = foo.getBar();
});
it("tracks that the spy was called", function() {
expect(foo.getBar).toHaveBeenCalled();
});
it("should not effect other functions", function() {
expect(bar).toEqual(123);
});
it("when called returns the requested value", function() {
expect(fetchedBar).toEqual(1001);//執(zhí)行callFake方法,返回1001
});
});
and.throwError--spy鏈式調(diào)用and.callError后,任何時候調(diào)用該方法都會拋出異常錯誤信息:
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("error");//指定throwError
});
it("throws the value", function() {
expect(function() {
foo.setBar(123)
}).toThrowError("error");//拋出錯誤異常
});
});
and.stub--spy恢復到原始狀態(tài),不執(zhí)行任何操作。直接看下代碼:
describe("A spy", function() {
var foo, bar = null;
beforeEach(function() {
foo = {
setBar: function(value) {
bar = value;
}
};
spyOn(foo, 'setBar').and.callThrough();
});
it("can call through and then stub in the same spec", function() {
foo.setBar(123);
expect(bar).toEqual(123);
foo.setBar.and.stub();//把foo.setBar設置為原始狀態(tài),and.callThrough無效
bar = null;
foo.setBar(123);//執(zhí)行賦值無效
expect(bar).toBe(null);
});
});
Spy的其他方法
.calls.any():記錄spy是否被訪問過,如果沒有,則返回false,否則,返回true;
.calls.count():記錄spy被訪問過的次數(shù);
.calls.argsFor(index):返回指定索引的參數(shù);
.calls.allArgs():返回所有函數(shù)調(diào)用的參數(shù)記錄數(shù)組;
.calls.all ():返回所有函數(shù)調(diào)用的上下文、參數(shù)和返回值;
.calls.mostRecent():返回最近一次函數(shù)調(diào)用的上下文、參數(shù)和返回值;
.calls.first():返回第一次函數(shù)調(diào)用的上下文、參數(shù)和返回值;
.calls.reset():清除spy的所有調(diào)用記錄;
虛擬定時器
Jasmine Clock 使用setTimeout 和setInterval 來聲明定時的回調(diào)操作。它使回調(diào)函數(shù)同步執(zhí)行,當Clock的tick時間超過timer的時間,回調(diào)函數(shù)會被觸發(fā)一次。
Jasmine Clock使用jasmine.clock().install 在需要調(diào)用timer函數(shù)的spec和suite中初始化。在執(zhí)行完測試的時候,一定要卸載Clock來還原timer函數(shù)。使用jasmine.clock().tick來推進時間以使注冊的回調(diào)觸發(fā)。
Install--在Spec或者Suite中安裝Jasmine Clock:
beforeEach(function() {
timerCallback = jasmine.createSpy("timerCallback");
jasmine.clock().install();
});
Uninstall--保證使用完成后,切記要關閉Jasmine Clock:
afterEach(function() {
jasmine.clock().uninstall();
});
Tick--使用jasmine.clock().tick來計時,一旦累計的時間達到setTimeout或者setInterval中指定的延時時間,則觸發(fā)回調(diào)函數(shù):
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);//聲明回調(diào)函數(shù)tick到100ms就觸發(fā)
expect(timerCallback).not.toHaveBeenCalled();
jasmine.clock().tick(101);//tick 101 會觸發(fā)上面注冊的setTimeout
expect(timerCallback).toHaveBeenCalled();
});
});
Mock Date
describe("Mocking the Date object", function(){
it("mocks the Date object and sets it to a given time", function() {
var baseTime = new Date(2016, 1, 27);//new一個指定的時間,沒有參數(shù)則返回當前時間
jasmine.clock().mockDate(baseTime);//構(gòu)造一個虛擬的當前時間
jasmine.clock().tick(50);//讓虛擬的當前時間快進50ms
expect(new Date().getTime()).toEqual(baseTime.getTime() + 50);
});
});
});
異步支持
Jasmine支持測試需要執(zhí)行異步操作的specs,調(diào)用beforeEach , it , 和afterEach 的時候,可以帶一個可選的參數(shù)done ,當spec執(zhí)行完成之后需要調(diào)用done 來告訴Jasmine異步操作已經(jīng)完成。
默認Jasmine的超時時間是5s,可以通過全局的jasmine.DEFAULTTIMEOUTINTERVAL 設置。
describe("Asynchronous specs", function() {
var value;
beforeEach(function(done) {//傳入done參數(shù)表示要執(zhí)行異步操作
setTimeout(function() {
value = 0;
done();//執(zhí)行done()函數(shù)通知it異步操作已經(jīng)執(zhí)行完畢,必須執(zhí)行
}, 10000);
});
it("should support async execution of test preparation and expectations", function(done) {//傳入done參數(shù)表示要執(zhí)行異步操作
value++;
expect(value).toBeGreaterThan(0);
done();//執(zhí)行done()函數(shù)通知it異步操作已經(jīng)執(zhí)行完畢,必須執(zhí)行
});
});
Ajax
Jasmine擁有一個用于測試Ajax請求的plug-in:
describe("mocking ajax", function() {
describe("suite wide usage", function() {
beforeEach(function() {
jasmine.Ajax.install();
});
afterEach(function() {
jasmine.Ajax.uninstall();
});
it("specifying response when you need it", function() {
var doneFn = jasmine.createSpy("success");
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(args) {
if (this.readyState == this.DONE) {
doneFn(this.responseText);
}
};
xhr.open("GET", "/some/cool/url");
xhr.send();
expect(jasmine.Ajax.requests.mostRecent().url).toBe('/some/cool/url');
expect(doneFn).not.toHaveBeenCalled();
jasmine.Ajax.requests.mostRecent().response({
"status": 200,
"contentType": 'text/plain',
"responseText": 'awesome response'
});
expect(doneFn).toHaveBeenCalledWith('awesome response');
});
it("allows responses to be setup ahead of time", function () {
var doneFn = jasmine.createSpy("success");
jasmine.Ajax.stubRequest('/another/url').andReturn({
"responseText": 'immediate response'
});
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(args) {
if (this.readyState == this.DONE) {
doneFn(this.responseText);
}
};
xhr.open("GET", "/another/url");
xhr.send();
expect(doneFn).toHaveBeenCalledWith('immediate response');
});
});
it("allows use in a single spec", function() {
var doneFn = jasmine.createSpy('success');
jasmine.Ajax.withMock(function() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(args) {
if (this.readyState == this.DONE) {
doneFn(this.responseText);
}
};
xhr.open("GET", "/some/cool/url");
xhr.send();
expect(doneFn).not.toHaveBeenCalled();
jasmine.Ajax.requests.mostRecent().response({
"status": 200,
"responseText": 'in spec response'
});
expect(doneFn).toHaveBeenCalledWith('in spec response');
});
});
});
KnockoutJS
What should and shouldn’t be tested?
用人不疑,疑人不用。所以你不需要測試Knockout正確的將一個ko.observable()對象顯示在一個前臺data-bind="text: ..."的span對象上,同樣我們也不需要測試ko.computed會在任何依賴發(fā)生變化時運行。那么我們測試什么呢?答案就是邏輯,我們應該更多的關注自己所寫的邏輯代碼,這些才是測試的重點。
一個簡單栗子
var addressBookViewModel = {
entries : ko.observableArray([]),
newEntryFirstName : ko.observable(),
newEntrySurname : ko.observable()
addNewEntry : function() {
var newEntry = {
firstName : this.newEntryFirstName(),
surname : this.newEntrySurname()
};
this.entries.push(newEntry);
// clear form
this.newEntryFirstName('');
this.newEntrySurname('');
}
};
ko.applyBindings(addressBookViewModel);
分析
可以想象一下這個ViewModel的應用場景,用戶輸入firstname,一個surname并且點擊綁定了'addNewEntry'的按鈕。分析邏輯我們發(fā)現(xiàn),此時一個entry被加入了entries(可能會用在一個table里通過foreach顯示),然后清空。
在開始測試之前我們首先要對代碼進行一些重構(gòu),因為首先這個ViewModel是單例的,并且當前方法驗證點太多我們需要拆分一下。重構(gòu)后的代碼如下:
function AddressBookViewModel() {
this.entries = ko.observableArray([]);
this.newEntryFirstName = ko.observable();
this.newEntrySurname = ko.observable();
this.addNewEntry = function() {
addAddressBookEntry(this.newEntryFirstName, this.newEntrySurname, this.entries);
clearObservables([this.newEntryFirstName, this.newEntrySurname]);
};
}
function addAddressBookEntry(firstName, surname, list) {
var newEntry = {
firstName : ko.toJS(firstName),
surname : ko.toJS(surname)
};
list.push(newEntry);
}
function clearObservables(observables) {
observables.forEach(function(observable){
observable(null);
});
}
var addressBookViewModel = new AddressBookViewModel();
ko.applyBindings(addressBookViewModel);
開始測試
- addAddressBookEntry
// 'Describe' creates a Jasmine test. A describe block contains assertions, using the 'it' function.
describe('addAddressBookEntry', function(){
var newEntryFirstName, newEntryLastName, list;
// 'beforeEach' performs setup before each 'it' test
beforeEach(function(){
newEntryFirstName = ko.observable('Peggy');
newEntryLastName = ko.observable('Hill');
list = ko.observableArray([]);
});
it('Adds an entry to the provided list', function(){
var initialListLength = list().length;
addAddressBookEntry(newEntryFirstName, newEntryLastName, list);
var newListLength = list().length;
// Jasmine uses the 'expect' function for assertions. Its format is very human-readable.
// If an expection proves false, it will throw an exception and the assertion will be reported as failed.
expect(newListLength).toBe(initialListLength + 1);
});
it('Adds an entry containing the supplied firstname and surname', function(){
addAddressBookEntry(newEntryFirstName, newEntryLastName, list);
var unwrappedList = list();
var expectedNewEntry = {firstName: 'Peggy', surname: 'Hill'};
// Jasmine's toContain will, amongst other things, test whether an array contains an object with fields matching a supplied object
expect(unwrappedList).toContain(expectedNewEntry);
});
it('Adds the entry to the end of the list', function(){
addAddressBookEntry(newEntryFirstName, newEntryLastName, list);
var unwrappedList = list();
var lastEntry = unwrappedList[unwrappedList.length - 1];
// You can have multiple expectations in a Jasmine test
expect(lastEntry.firstName).toBe('Peggy');
expect(lastEntry.surname).toBe('Hill');
});
});
一個普通栗子
function FormatterBinding(formatter) {
this.update = function update(element, valueAccessor) {
var newModelValue = ko.unwrap(valueAccessor());
var formattedText = formatter(newModelValue);
// let's assume we don't need to support IE8
element.textContent = formattedText;
};
}
function formatNumberAsDollars(number) {
return number.toLocaleString('en-US',{style: 'currency', currency: 'USD', maximumFractionDigits: 2});
}
分析
這個FormatterBinding是用來將數(shù)字格式化為貨幣。對于下面的formatNumberAsDollars我們不需要測試,我們只需關注FormatterBinding。
var mockFormatter, customBinding, mockValueAccessor;
// 'beforeEach' performs setup before each 'it' test
beforeEach(function(){
mockFormatter = jasmine.createSpy('mockFormatter');
customBinding = new FormatterBinding(mockFormatter);
mockElement = document.createElement('p');
mockValueAccessor = function() {
return ko.observable('someMockValue');
}
});
it('Calls the formatter with the value in the valueAccessor', function(){
// let's assume we've created all our mocks as part of a Jasmine
// beforeEach block that runs before each set of assertions
customBinding.update(mockElement, mockValueAccessor);
expect(mockFormatter).toHaveBeenCalledWith('someMockValue');
});
it('Prints the output of the formatter to the element', function(){
// Setting up the spy is a little bit awkward
var mockFunctions = {
mockFormatter : function() { return 'I am the mockFormatter return value';}
};
spyOn(mockFunctons, 'mockFormatter');
// But everything else is dead easy
var customBinding = new FormatterBinding(mockFunctions.mockFormatter);
customBinding.update(mockElement, mockValueAccessor);
var mockElementContent = mockElement.textContent;
expect(mockElementContent).toBe('I am the mockFormatter return value');
});
然而并沒有時間再去準備高級栗子了...