先問我們自己以下幾個(gè)問題:
如果答案都比較猶豫,那么就證明我們非常需要單元測試。
它能帶給我們很多保障:
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,功能非常豐富,支持運(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,它看起來會跑得很快。 經(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)是:
報(bào)錯(cuò)信息實(shí)在太美太詳細(xì),讓人有種想看錯(cuò)誤報(bào)告的欲望:
為了讓我們更多地關(guān)注測試用例本身如何編寫,而不是耗費(fèi)時(shí)間在如何運(yùn)行測試腳本等輔助工作上, 框架對單元測試做了一些基本約定。
我們約定 test 目錄為存放所有測試腳本的目錄,測試所使用到的 fixtures 和相關(guān)輔助腳本都應(yīng)該放在此目錄下。
測試腳本文件統(tǒng)一按 ${filename}.test.js 命名,必須以 .test.js 作為文件后綴。
一個(gè)應(yīng)用的測試目錄示例:
test |
統(tǒng)一使用 egg-bin 來運(yùn)行測試腳本, 自動(dòng)將內(nèi)置的 Mocha、co-mocha、power-assert,nyc 等模塊組合引入到測試腳本中, 讓我們聚焦精力在編寫測試代碼上,而不是糾結(jié)選擇那些測試周邊工具和模塊。
只需要在 package.json 上配置好 scripts.test 即可。
{ |
然后就可以按標(biāo)準(zhǔn)的 npm test 來運(yùn)行測試了。
npm test |
本文主要介紹如何編寫應(yīng)用的單元測試,關(guān)于框架和插件的單元測試請查看框架開發(fā)和插件開發(fā)相關(guān)章節(jié)。
正常來說,如果要完整手寫一個(gè) app 創(chuàng)建和啟動(dòng)代碼,還是需要寫一段初始化腳本的, 并且還需要在測試跑完之后做一些清理工作,如刪除臨時(shí)文件,銷毀 app。
常常還有模擬各種網(wǎng)絡(luò)異常,服務(wù)訪問異常等特殊情況。
所以我們單獨(dú)為框架抽取了一個(gè)測試 mock 輔助模塊:egg-mock, 有了它我們就可以非??焖俚鼐帉懸粋€(gè) app 的單元測試,并且還能快速創(chuàng)建一個(gè) ctx 來測試它的屬性、方法和 Service 等。
在測試運(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 |
這樣我們就拿到了一個(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 |
我們除了 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', () => { |
如果我們想模擬 ctx.user 這個(gè)數(shù)據(jù),也可以通過給 mockContext 傳遞 data 參數(shù)實(shí)現(xiàn):
it('should mock ctx.user', () => { |
現(xiàn)在我們拿到了 app,也知道如何創(chuàng)建一個(gè) ctx 了,那么就可以進(jìn)行更多代碼的單元測試了。
特別需要注意的是執(zhí)行順序,盡量保證在執(zhí)行某個(gè)用例的時(shí)候執(zhí)行相關(guān)代碼。
常見的錯(cuò)誤寫法
// Bad |
Mocha 剛開始運(yùn)行的時(shí)候會載入所有用例,這時(shí) describe 方法就會被調(diào)用,那 doSomethingBefore 就會啟動(dòng)。 如果希望使用 only 的方式只執(zhí)行某個(gè)用例那段代碼還是會被執(zhí)行,這是非預(yù)期的。
正確的做法是將其放到 before 中,只有運(yùn)行這個(gè)套件中某個(gè)用例才會執(zhí)行。
// Good |
Mocha 使用 before/after/beforeEach/afterEach 來處理前置后置任務(wù),基本能處理所有問題。 每個(gè)用例會按 before -> beforeEach -> it -> afterEach -> after 的順序執(zhí)行,而且可以定義多個(gè)。
describe('egg test', () => { |
egg-bin 支持測試異步調(diào)用,它支持多種寫法:
// 使用返回 Promise 的方式 |
使用哪種寫法取決于不同應(yīng)用場景,如果遇到多個(gè)異步可以使用 async function,也可以拆分成多個(gè)測試用例。
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 |
寫一個(gè)完整的單元測試,它的測試代碼 test/controller/home.test.js 如下:
const { app, mock, assert } = require('egg-mock/bootstrap'); |
通過基于 SuperTest 的 app.httpRequest() 可以輕松發(fā)起 GET、POST、PUT 等 HTTP 請求,并且它有非常豐富的請求數(shù)據(jù)構(gòu)造接口, 例如以 POST 方式發(fā)送一個(gè) JSON 請求:
// app/controller/home.js |
更詳細(xì)的 HTTP 請求構(gòu)造方式,請查看 SuperTest 文檔。
框架的默認(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(); |
Service 相對于 Controller 來說,測試起來會更加簡單, 我們只需要先創(chuàng)建一個(gè) ctx,然后通過 ctx.service.${serviceName} 拿到 Service 實(shí)例, 然后調(diào)用 Service 方法即可。
例如
// app/service/user.js |
編寫單元測試:
describe('get()', () => { |
當(dāng)然,實(shí)際的 Service 代碼不會像我們示例中那么簡單,這里只是展示如何測試 Service 而已。
應(yīng)用可以對 Application、Request、Response、Context 和 Helper 進(jìn)行擴(kuò)展。 我們可以對擴(kuò)展的方法或者屬性針對性的編寫單元測試。
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'); |
對應(yīng)的單元測試:
describe('get lru', () => { |
可以看到,測試 Application 的擴(kuò)展是最容易的。
Context 測試只比 Application 多了一個(gè) app.mockContext() 步驟來模擬創(chuàng)建一個(gè) Context 對象。
例如在 app/extend/context.js 中增加一個(gè) isXHR 屬性,判斷是否通過 XMLHttpRequest 發(fā)起的請求:
module.exports = { |
對應(yīng)的單元測試:
describe('isXHR()', () => { |
通過 ctx.request 來訪問 Request 擴(kuò)展的屬性和方法,直接即可進(jìn)行測試。
例如在 app/extend/request.js 中增加一個(gè) isChrome 屬性,判斷是否 Chrome 瀏覽器發(fā)起的請求:
const IS_CHROME = Symbol('Request#isChrome'); |
對應(yīng)的單元測試:
describe('isChrome()', () => { |
Response 測試與 Request 完全一致。 通過 ctx.response 來訪問 Response 擴(kuò)展的屬性和方法,直接即可進(jìn)行測試。
例如在 app/extend/response.js 中增加一個(gè) isSuccess 屬性,判斷當(dāng)前響應(yīng)狀態(tài)碼是否 200:
module.exports = { |
對應(yīng)的單元測試:
describe('isSuccess()', () => { |
Helper 測試方式與 Service 類似,也是通過 ctx 來訪問到 Helper,然后調(diào)用 Helper 方法測試。
例如 app/extend/helper.js
module.exports = { |
對應(yīng)的單元測試:
describe('money()', () => { |
egg-mock 除了上面介紹過的 app.mockContext() 和 app.mockCsrf() 方法外,還提供了非常多的 mock 方法幫助我們便捷地寫單元測試。
因?yàn)?mock 之后會一直生效,我們需要避免每個(gè)單元測試用例之間是不能相互 mock 污染的, 所以通常我們都會在 afterEach 鉤子里面還原掉所有 mock。
describe('some test', () => { |
引入 egg-mock/bootstrap 時(shí),會自動(dòng)在 afterEach 鉤子中還原所有的 mock,不需要在測試文件中再次編寫。
下面會詳細(xì)解釋一下 egg-mock 的常見使用場景。
因?yàn)?egg-mock 是擴(kuò)展自 mm 模塊, 它包含了 mm 的所有功能,這樣我們就可以非常方便地 mock 任意對象的屬性和方法了。
mock app.config.baseDir 指向 /tmp/mockapp
mock(app.config, 'baseDir', '/tmp/mockapp'); |
mock fs.readFileSync 返回 hello world
mock(fs, 'readFileSync', filename => { |
還有 mock.data(),mock.error() 等更多高級的 mock 方法, 詳細(xì)使用說明請查看 mm API。
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.mockServiceError(service, methodName, error) 可以模擬 Service 調(diào)用異常。
例如,模擬 app/service/user 中的 get(name) 方法調(diào)用異常:
it('should mock service error', () => { |
框架內(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 { |
需要 mock 它的返回值:
describe('GET /httpclient', () => { |
完整示例代碼可以在 eggjs/exmaples/unittest 找到。
更多建議: