Egg 單元測試

2020-02-06 14:11 更新

為什么要單元測試

先問我們自己以下幾個(gè)問題:

  • 你的代碼質(zhì)量如何度量?
  • 你是如何保證代碼質(zhì)量?
  • 你敢隨時(shí)重構(gòu)代碼嗎?
  • 你是如何確保重構(gòu)的代碼依然保持正確性?
  • 你是否有足夠信心在沒有測試的情況下隨時(shí)發(fā)布你的代碼?

如果答案都比較猶豫,那么就證明我們非常需要單元測試。

它能帶給我們很多保障:

  • 代碼質(zhì)量持續(xù)有保障
  • 重構(gòu)正確性保障
  • 增強(qiáng)自信心
  • 自動(dòng)化運(yùn)行

Web 應(yīng)用中的單元測試更加重要,在 Web 產(chǎn)品快速迭代的時(shí)期,每個(gè)測試用例都給應(yīng)用的穩(wěn)定性提供了一層保障。 API 升級,測試用例可以很好地檢查代碼是否向下兼容。 對于各種可能的輸入,一旦測試覆蓋,都能明確它的輸出。 代碼改動(dòng)后,可以通過測試結(jié)果判斷代碼的改動(dòng)是否影響已確定的結(jié)果。

所以,應(yīng)用的 Controller、Service、Helper、Extend 等代碼,都必須有對應(yīng)的單元測試保證代碼質(zhì)量。 當(dāng)然,框架和插件的每個(gè)功能改動(dòng)和重構(gòu)都需要有相應(yīng)的單元測試,并且要求盡量做到修改的代碼能被 100% 覆蓋到。

測試框架

從 npm 搜索『test framework』, 我們會發(fā)現(xiàn)有大量測試框架存在,每個(gè)測試框架都有它的獨(dú)特之處。

Mocha

我們選擇和推薦大家使用 Mocha,功能非常豐富,支持運(yùn)行在 Node.js 和瀏覽器中, 對異步測試支持非常友好。

Mocha is a feature-rich JavaScript test framework running on Node.js and in the browser, making asynchronous testing simple and fun. Mocha tests run serially, allowing for flexible and accurate reporting, while mapping uncaught exceptions to the correct test cases.

AVA

為什么沒有選擇最近比較火的 AVA,它看起來會跑得很快。 經(jīng)過我們幾個(gè)真實(shí)項(xiàng)目實(shí)踐下來,AVA 真的只是看起來很美,但是實(shí)際會讓測試代碼越來越難寫,成本越來越高。

@dead-horse 的評價(jià):

AVA 自身不夠穩(wěn)定,并發(fā)運(yùn)行文件多的時(shí)候會撐爆 CPU;如果設(shè)置控制并發(fā)參數(shù)的方式運(yùn)行,會導(dǎo)致 only 模式無效。并發(fā)執(zhí)行對測試用例的要求很高,所有的測試不能有依賴,特別是遇到一些需要做 mock 的場景時(shí),寫好很難。app 在初始化的時(shí)候是有耗時(shí)的,如果串行運(yùn)行,只需要初始化一個(gè) app 對它測試。 但是 AVA 每一個(gè)文件都運(yùn)行在獨(dú)立進(jìn)程,有多少個(gè)文件就需要初始化多少個(gè) app。

@fool2fish 的評價(jià):

如果是簡單的程序的話用 AVA 會快一些(但是本來就簡單可能也沒啥感覺), 如果是復(fù)雜的就不推薦了,比較大的問題是可能沒法給出準(zhǔn)確的錯(cuò)誤堆棧, 另外并發(fā)可能會導(dǎo)致依賴的其他測試環(huán)境的服務(wù)掛掉,降低測試的成功率, 還有就是帶流程的測試(比如測試數(shù)據(jù)庫的增刪改查功能)真心不適合用 AVA。

斷言庫

同樣,測試斷言庫也是百花齊放的時(shí)代, 我們經(jīng)歷過 assert,到 should 和 expect,還是不斷地在嘗試更好的斷言庫。

直到我們發(fā)現(xiàn) power-assert, 因?yàn)?a href="http://m.hgci.cn/targetlink?url=https://github.com/atian25/blog/issues/16" target="_blank">『No API is the best API』, 最終我們重新回歸原始的 assert 作為默認(rèn)的斷言庫。

簡單地說,它的優(yōu)點(diǎn)是:

  • 沒有 API 就是最好的 API,不需要任何記憶,只需 assert 即可。
  • 強(qiáng)大的錯(cuò)誤信息反饋
  • 強(qiáng)大的錯(cuò)誤信息反饋
  • 強(qiáng)大的錯(cuò)誤信息反饋

報(bào)錯(cuò)信息實(shí)在太美太詳細(xì),讓人有種想看錯(cuò)誤報(bào)告的欲望:

測試約定

為了讓我們更多地關(guān)注測試用例本身如何編寫,而不是耗費(fèi)時(shí)間在如何運(yùn)行測試腳本等輔助工作上, 框架對單元測試做了一些基本約定。

測試目錄結(jié)構(gòu)

我們約定 test 目錄為存放所有測試腳本的目錄,測試所使用到的 fixtures 和相關(guān)輔助腳本都應(yīng)該放在此目錄下。

測試腳本文件統(tǒng)一按 ${filename}.test.js 命名,必須以 .test.js 作為文件后綴。

一個(gè)應(yīng)用的測試目錄示例:

test
├── controller
│   └── home.test.js
├── hello.test.js
└── service
└── user.test.js

測試運(yùn)行工具

統(tǒng)一使用 egg-bin 來運(yùn)行測試腳本, 自動(dòng)將內(nèi)置的 Mocha、co-mocha、power-assert,nyc 等模塊組合引入到測試腳本中, 讓我們聚焦精力在編寫測試代碼上,而不是糾結(jié)選擇那些測試周邊工具和模塊。

只需要在 package.json 上配置好 scripts.test 即可。

{
"scripts": {
"test": "egg-bin test"
}
}

然后就可以按標(biāo)準(zhǔn)的 npm test 來運(yùn)行測試了。

npm test

> unittest-example@ test /Users/mk2/git/github.com/eggjs/examples/unittest
> egg-bin test

test/hello.test.js
? should work

1 passing (10ms)

準(zhǔn)備測試

本文主要介紹如何編寫應(yīng)用的單元測試,關(guān)于框架和插件的單元測試請查看框架開發(fā)插件開發(fā)相關(guān)章節(jié)。

mock

正常來說,如果要完整手寫一個(gè) app 創(chuàng)建和啟動(dòng)代碼,還是需要寫一段初始化腳本的, 并且還需要在測試跑完之后做一些清理工作,如刪除臨時(shí)文件,銷毀 app。

常常還有模擬各種網(wǎng)絡(luò)異常,服務(wù)訪問異常等特殊情況。

所以我們單獨(dú)為框架抽取了一個(gè)測試 mock 輔助模塊:egg-mock, 有了它我們就可以非??焖俚鼐帉懸粋€(gè) app 的單元測試,并且還能快速創(chuàng)建一個(gè) ctx 來測試它的屬性、方法和 Service 等。

app

在測試運(yùn)行之前,我們首先要?jiǎng)?chuàng)建應(yīng)用的一個(gè) app 實(shí)例, 通過它來訪問需要被測試的 Controller、Middleware、Service 等應(yīng)用層代碼。

通過 egg-mock,結(jié)合 Mocha 的 before 鉤子就可以便捷地創(chuàng)建出一個(gè) app 實(shí)例。

// test/controller/home.test.js
const assert = require('assert');
const mock = require('egg-mock');

describe('test/controller/home.test.js', () => {
let app;
before(() => {
// 創(chuàng)建當(dāng)前應(yīng)用的 app 實(shí)例
app = mock.app();
// 等待 app 啟動(dòng)成功,才能執(zhí)行測試用例
return app.ready();
});
});

這樣我們就拿到了一個(gè) app 的引用,接下來所有測試用例都會基于這個(gè) app 進(jìn)行。 更多關(guān)于創(chuàng)建 app 的信息請查看 mock.app(options) 文檔。

每一個(gè)測試文件都需要這樣創(chuàng)建一個(gè) app 實(shí)例非常冗余,因此 egg-mock 提供了一個(gè) bootstrap 文件,可以直接從它上面拿到我們所常用的實(shí)例:

// test/controller/home.test.js
const { app, mock, assert } = require('egg-mock/bootstrap');

describe('test/controller/home.test.js', () => {
// test cases
});

ctx

我們除了 app,還需要一種方式便捷地拿到 ctx,方便我們進(jìn)行 Extend、Service、Helper 等測試。 而我們已經(jīng)通過上面的方式拿到了一個(gè) app,結(jié)合 egg-mock 提供的 app.mockContext(options) 方法來快速創(chuàng)建一個(gè) ctx 實(shí)例。

it('should get a ctx', () => {
const ctx = app.mockContext();
assert(ctx.method === 'GET');
assert(ctx.url === '/');
});

如果我們想模擬 ctx.user 這個(gè)數(shù)據(jù),也可以通過給 mockContext 傳遞 data 參數(shù)實(shí)現(xiàn):

it('should mock ctx.user', () => {
const ctx = app.mockContext({
user: {
name: 'fengmk2',
},
});
assert(ctx.user);
assert(ctx.user.name === 'fengmk2');
});

現(xiàn)在我們拿到了 app,也知道如何創(chuàng)建一個(gè) ctx 了,那么就可以進(jìn)行更多代碼的單元測試了。

測試執(zhí)行順序

特別需要注意的是執(zhí)行順序,盡量保證在執(zhí)行某個(gè)用例的時(shí)候執(zhí)行相關(guān)代碼。

常見的錯(cuò)誤寫法

// Bad
const { app } = require('egg-mock/bootstrap');

describe('bad test', () => {
doSomethingBefore();

it('should redirect', () => {
return app.httpRequest()
.get('/')
.expect(302);
});
});

Mocha 剛開始運(yùn)行的時(shí)候會載入所有用例,這時(shí) describe 方法就會被調(diào)用,那 doSomethingBefore 就會啟動(dòng)。 如果希望使用 only 的方式只執(zhí)行某個(gè)用例那段代碼還是會被執(zhí)行,這是非預(yù)期的。

正確的做法是將其放到 before 中,只有運(yùn)行這個(gè)套件中某個(gè)用例才會執(zhí)行。

// Good
const { app } = require('egg-mock/bootstrap');

describe('good test', () => {
before(() => doSomethingBefore());

it('should redirect', () => {
return app.httpRequest()
.get('/')
.expect(302);
});
});

Mocha 使用 before/after/beforeEach/afterEach 來處理前置后置任務(wù),基本能處理所有問題。 每個(gè)用例會按 before -> beforeEach -> it -> afterEach -> after 的順序執(zhí)行,而且可以定義多個(gè)。

describe('egg test', () => {
before(() => console.log('order 1'));
before(() => console.log('order 2'));
after(() => console.log('order 6'));
beforeEach(() => console.log('order 3'));
afterEach(() => console.log('order 5'));
it('should worker', () => console.log('order 4'));
});

異步測試

egg-bin 支持測試異步調(diào)用,它支持多種寫法:

// 使用返回 Promise 的方式
it('should redirect', () => {
return app.httpRequest()
.get('/')
.expect(302);
});

// 使用 callback 的方式
it('should redirect', done => {
app.httpRequest()
.get('/')
.expect(302, done);
});

// 使用 async
it('should redirect', async () => {
await app.httpRequest()
.get('/')
.expect(302);
});

使用哪種寫法取決于不同應(yīng)用場景,如果遇到多個(gè)異步可以使用 async function,也可以拆分成多個(gè)測試用例。

Controller 測試

Controller 在整個(gè)應(yīng)用代碼里面屬于比較難測試的部分了,因?yàn)樗?router 配置緊密相關(guān), 我們需要利用 app.httpRequest() SuperTest 發(fā)起一個(gè)真實(shí)請求, 來將 Router 和 Controller 連接起來,并且可以幫助我們發(fā)送各種滿足邊界條件的請求數(shù)據(jù), 以測試 Controller 的參數(shù)校驗(yàn)完整性。 app.httpRequest() 是 egg-mock 封裝的 SuperTest 請求實(shí)例。

例如我們要給 app/controller/home.js:

// app/router.js
module.exports = app => {
const { router, controller } = app;
router.get('homepage', '/', controller.home.index);
};

// app/controller/home.js
class HomeController extends Controller {
async index() {
this.ctx.body = 'hello world';
}
}

寫一個(gè)完整的單元測試,它的測試代碼 test/controller/home.test.js 如下:

const { app, mock, assert } = require('egg-mock/bootstrap');

describe('test/controller/home.test.js', () => {
describe('GET /', () => {
it('should status 200 and get the body', () => {
// 對 app 發(fā)起 `GET /` 請求
return app.httpRequest()
.get('/')
.expect(200) // 期望返回 status 200
.expect('hello world'); // 期望 body 是 hello world
});

it('should send multi requests', async () => {
// 使用 generator function 方式寫測試用例,可以在一個(gè)用例中串行發(fā)起多次請求
await app.httpRequest()
.get('/')
.expect(200) // 期望返回 status 200
.expect('hello world'); // 期望 body 是 hello world

// 再請求一次
const result = await app.httpRequest()
.get('/')
.expect(200)
.expect('hello world');

// 也可以這樣驗(yàn)證
assert(result.status === 200);
});
});
});

通過基于 SuperTest 的 app.httpRequest() 可以輕松發(fā)起 GET、POST、PUT 等 HTTP 請求,并且它有非常豐富的請求數(shù)據(jù)構(gòu)造接口, 例如以 POST 方式發(fā)送一個(gè) JSON 請求:

// app/controller/home.js
class HomeController extends Controller {
async post() {
this.ctx.body = this.ctx.request.body;
}
}

// test/controller/home.test.js
it('should status 200 and get the request body', () => {
// 模擬 CSRF token,下文會詳細(xì)說明
app.mockCsrf();
return app.httpRequest()
.post('/post')
.type('form')
.send({
foo: 'bar',
})
.expect(200)
.expect({
foo: 'bar',
});
});

更詳細(xì)的 HTTP 請求構(gòu)造方式,請查看 SuperTest 文檔。

mock CSRF

框架的默認(rèn)安全插件會自動(dòng)開啟 CSRF 防護(hù), 如果完整走 CSRF 校驗(yàn)邏輯,那么測試代碼需要先請求一次頁面,通過解析 HTML 拿到 CSRF token, 然后再使用此 token 發(fā)起 POST 請求。

所以 egg-mock 對 app 增加了 app.mockCsrf() 方法來模擬取 CSRF token 的過程。 這樣在使用 SuperTest 請求 app 就會自動(dòng)通過 CSRF 校驗(yàn)。

app.mockCsrf();
return app.httpRequest()
.post('/post')
.type('form')
.send({
foo: 'bar',
})
.expect(200)
.expect({
foo: 'bar',
});

Service 測試

Service 相對于 Controller 來說,測試起來會更加簡單, 我們只需要先創(chuàng)建一個(gè) ctx,然后通過 ctx.service.${serviceName} 拿到 Service 實(shí)例, 然后調(diào)用 Service 方法即可。

例如

// app/service/user.js
class UserService extends Service {
async get(name) {
return await userDatabase.get(name);
}
}

編寫單元測試:

describe('get()', () => {
it('should get exists user', async () => {
// 創(chuàng)建 ctx
const ctx = app.mockContext();
// 通過 ctx 訪問到 service.user
const user = await ctx.service.user.get('fengmk2');
assert(user);
assert(user.name === 'fengmk2');
});

it('should get null when user not exists', async () => {
const ctx = app.mockContext();
const user = await ctx.service.user.get('fengmk1');
assert(!user);
});
});

當(dāng)然,實(shí)際的 Service 代碼不會像我們示例中那么簡單,這里只是展示如何測試 Service 而已。

Extend 測試

應(yīng)用可以對 Application、Request、Response、Context 和 Helper 進(jìn)行擴(kuò)展。 我們可以對擴(kuò)展的方法或者屬性針對性的編寫單元測試。

Application

egg-mock 創(chuàng)建 app 的時(shí)候,已經(jīng)將 Application 的擴(kuò)展自動(dòng)加載到 app 實(shí)例了, 直接使用這個(gè) app 實(shí)例訪問擴(kuò)展的屬性和方法即可進(jìn)行測試。

例如 app/extend/application.js,我們給 app 增加了一個(gè)基于 ylru 的緩存功能:

const LRU = Symbol('Application#lru');
const LRUCache = require('ylru');
module.exports = {
get lru() {
if (!this[LRU]) {
this[LRU] = new LRUCache(1000);
}
return this[LRU];
},
};

對應(yīng)的單元測試:

describe('get lru', () => {
it('should get a lru and it work', () => {
// 設(shè)置緩存
app.lru.set('foo', 'bar');
// 讀取緩存
assert(app.lru.get('foo') === 'bar');
});
});

可以看到,測試 Application 的擴(kuò)展是最容易的。

Context

Context 測試只比 Application 多了一個(gè) app.mockContext() 步驟來模擬創(chuàng)建一個(gè) Context 對象。

例如在 app/extend/context.js 中增加一個(gè) isXHR 屬性,判斷是否通過 XMLHttpRequest 發(fā)起的請求:

module.exports = {
get isXHR() {
return this.get('X-Requested-With') === 'XMLHttpRequest';
},
};

對應(yīng)的單元測試:

describe('isXHR()', () => {
it('should true', () => {
const ctx = app.mockContext({
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
assert(ctx.isXHR === true);
});

it('should false', () => {
const ctx = app.mockContext({
headers: {
'X-Requested-With': 'SuperAgent',
},
});
assert(ctx.isXHR === false);
});
});

Request

通過 ctx.request 來訪問 Request 擴(kuò)展的屬性和方法,直接即可進(jìn)行測試。

例如在 app/extend/request.js 中增加一個(gè) isChrome 屬性,判斷是否 Chrome 瀏覽器發(fā)起的請求:

const IS_CHROME = Symbol('Request#isChrome');
module.exports = {
get isChrome() {
if (!this[IS_CHROME]) {
const ua = this.get('User-Agent').toLowerCase();
this[IS_CHROME] = ua.includes('chrome/');
}
return this[IS_CHROME];
},
};

對應(yīng)的單元測試:

describe('isChrome()', () => {
it('should true', () => {
const ctx = app.mockContext({
headers: {
'User-Agent': 'Chrome/56.0.2924.51',
},
});
assert(ctx.request.isChrome === true);
});

it('should false', () => {
const ctx = app.mockContext({
headers: {
'User-Agent': 'FireFox/1',
},
});
assert(ctx.request.isChrome === false);
});
});

Response

Response 測試與 Request 完全一致。 通過 ctx.response 來訪問 Response 擴(kuò)展的屬性和方法,直接即可進(jìn)行測試。

例如在 app/extend/response.js 中增加一個(gè) isSuccess 屬性,判斷當(dāng)前響應(yīng)狀態(tài)碼是否 200:

module.exports = {
get isSuccess() {
return this.status === 200;
},
};

對應(yīng)的單元測試:

describe('isSuccess()', () => {
it('should true', () => {
const ctx = app.mockContext();
ctx.status = 200;
assert(ctx.response.isSuccess === true);
});

it('should false', () => {
const ctx = app.mockContext();
ctx.status = 404;
assert(ctx.response.isSuccess === false);
});
});

Helper

Helper 測試方式與 Service 類似,也是通過 ctx 來訪問到 Helper,然后調(diào)用 Helper 方法測試。

例如 app/extend/helper.js

module.exports = {
money(val) {
const lang = this.ctx.get('Accept-Language');
if (lang.includes('zh-CN')) {
return `¥ ${val}`;
}
return `$ ${val}`;
},
};

對應(yīng)的單元測試:

describe('money()', () => {
it('should RMB', () => {
const ctx = app.mockContext({
// 模擬 ctx 的 headers
headers: {
'Accept-Language': 'zh-CN,zh;q=0.5',
},
});
assert(ctx.helper.money(100) === '¥ 100');
});

it('should US Dolar', () => {
const ctx = app.mockContext();
assert(ctx.helper.money(100) === '$ 100');
});
});

Mock 方法

egg-mock 除了上面介紹過的 app.mockContext() 和 app.mockCsrf() 方法外,還提供了非常多的 mock 方法幫助我們便捷地寫單元測試。

  • 如我們不想在終端 console 輸出任何日志,可以通過 mock.consoleLevel('NONE') 來模擬。
  • 又如我想模擬一次請求的 Session 數(shù)據(jù),可以通過 app.mockSession(data) 來模擬。describe('GET /session', () => { it('should mock session work', () => { app.mockSession({ foo: 'bar', uid: 123, }); return app.httpRequest() .get('/session') .expect(200) .expect({ session: { foo: 'bar', uid: 123, }, }); });});

因?yàn)?mock 之后會一直生效,我們需要避免每個(gè)單元測試用例之間是不能相互 mock 污染的, 所以通常我們都會在 afterEach 鉤子里面還原掉所有 mock。

describe('some test', () => {
// before hook

afterEach(mock.restore);

// it tests
});

引入 egg-mock/bootstrap 時(shí),會自動(dòng)在 afterEach 鉤子中還原所有的 mock,不需要在測試文件中再次編寫。

下面會詳細(xì)解釋一下 egg-mock 的常見使用場景。

Mock 屬性和方法

因?yàn)?egg-mock 是擴(kuò)展自 mm 模塊, 它包含了 mm 的所有功能,這樣我們就可以非常方便地 mock 任意對象的屬性和方法了。

Mock 一個(gè)對象的屬性

mock app.config.baseDir 指向 /tmp/mockapp

mock(app.config, 'baseDir', '/tmp/mockapp');
assert(app.config.baseDir === '/tmp/mockapp');

Mock 一個(gè)對象的方法

mock fs.readFileSync 返回 hello world

mock(fs, 'readFileSync', filename => {
return 'hello world';
});
assert(fs.readFileSync('foo.txt') === 'hello world');

還有 mock.data(),mock.error() 等更多高級的 mock 方法, 詳細(xì)使用說明請查看 mm API

Mock Service

Service 作為框架標(biāo)準(zhǔn)的內(nèi)置對象,我們提供了便捷的 app.mockService(service, methodName, fn) 模擬 Service 方法返回值。

例如,模擬 app/service/user 中的 get(name) 方法,讓它返回一個(gè)本來不存在的用戶數(shù)據(jù)。

it('should mock fengmk1 exists', () => {
app.mockService('user', 'get', () => {
return {
name: 'fengmk1',
};
});

return app.httpRequest()
.get('/user?name=fengmk1')
.expect(200)
// 返回了原本不存在的用戶信息
.expect({
name: 'fengmk1',
});
});

通過 app.mockServiceError(service, methodName, error) 可以模擬 Service 調(diào)用異常。

例如,模擬 app/service/user 中的 get(name) 方法調(diào)用異常:

it('should mock service error', () => {
app.mockServiceError('user', 'get', 'mock user service error');
return app.httpRequest()
.get('/user?name=fengmk2')
// service 異常,觸發(fā) 500 響應(yīng)
.expect(500)
.expect(/mock user service error/);
});

Mock HttpClient

框架內(nèi)置了 HttpClient,應(yīng)用發(fā)起的對外 HTTP 請求基本都是通過它來處理。 我們可以通過 app.mockHttpclient(url, method, data) 來 mock 掉 app.curl 和 ctx.curl 方法, 從而實(shí)現(xiàn)各種網(wǎng)絡(luò)異常情況。

例如在 app/controller/home.js 中發(fā)起了一個(gè) curl 請求

class HomeController extends Controller {
async httpclient () {
const res = await this.ctx.curl('https://eggjs.org');
this.ctx.body = res.data.toString();
}
}

需要 mock 它的返回值:

describe('GET /httpclient', () => {
it('should mock httpclient response', () => {
app.mockHttpclient('https://eggjs.org', {
// 模擬的參數(shù),可以是 buffer / string / json,
// 都會轉(zhuǎn)換成 buffer
// 按照請求時(shí)的 options.dataType 來做對應(yīng)的轉(zhuǎn)換
data: 'mock eggjs.org response',
});
return app.httpRequest()
.get('/httpclient')
.expect('mock eggjs.org response');
});
});

示例代碼

完整示例代碼可以在 eggjs/exmaples/unittest 找到。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號