持續集成
單元測試(unit)
karma
Karma
是Google
開源的一個基於Node.js
的 JavaScript
測試執行過程管理工具(Test Runner
)。該工具可用於測試所有主流Web
瀏覽器,也可集成到 CI
(Continuous integration
)工具,也可和其他代碼編輯器一起使用。
我們測試用的無界面瀏覽器phantomjs
。測試框架使用mocha
和chai
。
以下是我們項目中使用的主要配置信息:
/**
* 測試啓動的瀏覽器
* 可用的瀏覽器:https://npmjs.org/browse/keyword/karma-launcher
*/
browsers: ['PhantomJS'],
/**
* 測試框架
* 可用的框架:https://npmjs.org/browse/keyword/karma-adapter
*/
frameworks: ['mocha', 'chai'],
/**
* 需要加載到瀏覽器的文件列表
*/
files: [
'../../src/dcv/plugins/jquery/jquery-1.8.1.min.js',
'../../src/dcv/plugins/common/mock.min.js',
'../../src/dcv/plugins/common/bluebird.min.js',
'../../src/dcv/javascripts/uinv.js',
'../../src/dcv/javascripts/uinv_util.js',
'../../src/dcv/javascripts/browser/uinv_browser.js',
'specs/validators.js'
],
/**
* 排除的文件列表
*/
exclude: [
],
/**
* 在瀏覽器使用之前處理匹配的文件
* 可用的預處理: https://npmjs.org/browse/keyword/karma-preprocessor
*/
preprocessors: { //報告覆蓋
"../../src/dcv/javascripts/**/*.js": ["coverage"]
},
/**
* 使用測試結果報告者
* 可能的值: "dots", "progress"
* 可用的報告者:https://npmjs.org/browse/keyword/karma-reporter
*/
reporters: ['spec', 'coverage'],
/**
* 使用reporters爲"coverage"時報告輸出的類型和那目錄
*/
coverageReporter: {
type: 'html',
dir: 'coverage/'
},
/**
* 服務端口號
*/
port: 9876,
/**
* 啓用或禁用輸出報告或者日誌中的顏色
*/
colors: true,
/**
* 日誌等級
* 可能的值:
* config.LOG_DISABLE //不輸出信息
* config.LOG_ERROR //只輸出錯誤信息
* config.LOG_WARN //只輸出警告信息
* config.LOG_INFO //輸出全部信息
* config.LOG_DEBUG //輸出調試信息
*/
logLevel: config.LOG_INFO,
/**
* 啓用或禁用自動檢測文件變化進行測試
*/
autoWatch: true,
/**
* 開啓或禁用持續集成模式
* 設置爲true, Karma將打開瀏覽器,執行測試並最後退出
*/
// singleRun: true,
/**
* 併發級別(啓動的瀏覽器數)
*/
concurrency: Infinity
在package.json
中配置如下:
"scripts": {
"unit": "./node_modules/.bin/karma start test/unit/karma.conf.js --single-run"
}
--single-run
意思是單次執行測試,此處會覆蓋上面的singleRun
配置項。最終會在test/unit/coverage
目錄下生成測試覆蓋率的html格式報告。
mocha
mocha
是JavaScript
的一種單元測試框架,既可以在瀏覽器環境下運行,也可以在Node.js
環境下運行。
使用mocha
,我們就只需要專注於編寫單元測試本身,然後,讓mocha
去自動運行所有的測試,並給出測試結果。
mocha
的特點主要有:
- 既可以測試簡單的
JavaScript
函數,又可以測試異步代碼,因爲異步是JavaScript
的特性之一; - 可以自動運行所有測試,也可以只運行特定的測試;
- 可以支持
before
、after
、beforeEach
和afterEach
來編寫初始化代碼。
describe
表示測試套件,是一序列相關程序的測試;it
表示單元測試(unit test
),也就是測試的最小單位。例:
describe("樣例", function () {
it("deep用法", function () {
expect({a: 1}).to.deep.equal({a: 1});
expect({a: 1}).to.not.equal({a: 1});
expect([{a: 1}]).to.deep.include({a: 1});
// expect([{a: 1}]).to.not.include({a: 1});
expect([{a: 1}]).to.be.include({a: 1});
});
});
mocha
一共四個生命鉤子
before()
:在該區塊的所有測試用例之前執行
after()
:在該區塊的所有測試用例之後執行
beforeEach()
:在每個單元測試前執行
afterEach()
:在每個單元測試後執行
利用describe.skip
可以跳過測試,而不用註釋大塊代碼;異步只需要在函數中增加done
回調。例:
describe.skip('異步 beforeEach 示例', function () {
var foo = false;
beforeEach(function (done) {
setTimeout(function () {
foo = true;
done();
}, 50);
});
it('全局變量異步修改應該成功', function () {
expect(foo).to.be.equal(true);
});
it('read book async', function (done) {
book.read((err, result) => {
expect(err).equal(null);
expect(result).to.be.a('string');
done();
})
});
});
chai
chai
是斷言庫,可以理解爲比較函數,也就是斷言函數是否和預期一致,如果一致則表示測試通過,如果不一致表示測試失敗。
本身mocha
是不包含斷言庫的,所以必須引入第三方斷言庫,目前比較受歡迎的斷言庫有 should.js
、expect.js
、chai
,具體的語法規則需要大家去查閱相關文檔。
因爲chai
既包含should
、expect
和assert
三種風格,可擴展性比較強。本質是一樣的,按個人習慣選擇。詳見api
下面簡單的介紹一下這是那種風格
should
例:
let num = 4+5
num.should.equal(9);
num.should.not.equal(10);
//boolean
'ok'.should.to.be.ok;
false.should.to.not.be.ok;
//type
'test'.should.to.be.a('string');
({ foo: 'bar' }).should.to.be.an('object');
expect
例:
// equal or no equal
let num = 4+5
expect(num).equal(9);
expect(num).not.equal(10);
//boolean
expect('ok').to.be.ok;
expect(false).to.not.be.ok;
//type
expect('test').to.be.a('string');
expect({ foo: 'bar' }).to.be.an('object');
assert
例:
// equal or no equal
let num = 4+5
assert.equal(num,9);
//type
assert.typeOf('test', 'string', 'test is a string');
端到端測試(e2e)
e2e
(end to end
)測試是指端到端測試,又叫功能測試,站在用戶視角,使用各種功能、各種交互,是用戶的真實使用場景的仿真。
在產品高速迭代的現在,有個自動化測試,是重構、迭代的重要保障。對web
前端來說,主要的測試就是,表單、動畫、頁面跳轉、dom
渲染、Ajax
等是否按照期望。
e2e
測試正是保證功能的最高層測試,不關注代碼實現細節,專注於代碼能否實現對應的功能。對我們開發人員而言,測試的主要關注點是映射到頁面的邏輯(一般是存儲的變量)是否正確。
我們使用nigthwatch
來做e2e
測試
nightwatch
nightwatch
是一個使用selenium
或者webdriver
或者phantomjs
的nodejs
編寫的e2e
自動測試框架,可以很方便的寫出測試用例來模仿用戶的操作來自動驗證功能的實現。
nightwatch
的使用很簡單,一個nightwatch.json
或者nightwatch.config.js
(後者優先級高)配置文件,使用runner
會自動找同級的這兩個文件來獲取配置信息。也可以手動使用--config
來制定配置文件的相對路徑。
selenium
selenium
是一個強大瀏覽器測試平臺,支持firefox
、chrome
、edge
等瀏覽器的模擬測試,其原理是打開瀏覽器時,把自己的JavaScript
文件嵌入網頁中。然後selenium
的網頁通過frame
嵌入目標網頁。這樣,就可以使用selenium
的JavaScript
對象來控制目標網頁。
項目中nightwatch.config.js
的主要配置如下:
{
"src_folders": ["test/e2e/specs"],//測試代碼所在文件夾
"output_folder": "test/e2e/reports",//測試報告所在文件夾
"globals_path": "test/e2e/global.js",//全局變量所在文件夾,可以通過browser.globals.XX來獲取
"custom_commands_path": ["node_modules/nightwatch-helpers/commands"],//自定義擴展命令
"custom_assertions_path": ["node_modules/nightwatch-helpers/assertions"],//自定義擴展斷言
"selenium": {
"start_process": true,
"server_path": seleniumServer.path,//selenium的服務所在地址,一般是個jar包
"host": "127.0.0.1",
"port": 4444,
"cli_args": {
"webdriver.chrome.driver": chromedriver.path,//谷歌瀏覽器的drvier地址,在windows下是個exe文件
"webdriver.firefox.profile": "",
"webdriver.ie.driver": "",
"webdriver.phantomjs.driver": phantomjsDriver.path
}
},
"test_settings": {
"phantomjs": {
"desiredCapabilities": {
"browserName": "phantomjs",
"marionette": true,
"acceptSslCerts": true,
"phantomjs.binary.path": phantomjsDriver.path,
"phantomjs.cli.args": ["--ignore-ssl-errors=false"]
}
},
"chrome": {
"desiredCapabilities": {
"browserName": "chrome",
"javascriptEnabled": true,
"acceptSslCerts": true,
'chromeOptions': {
'args': [
// "start-fullscreen"
// '--headless', //開啓無界面
// '--disable-gpu'
]
}
}
},
"firefox": {
"desiredCapabilities": {
"browserName": "firefox",
"javascriptEnabled": true,
"acceptSslCerts": true
}
},
"ie": {
"desiredCapabilities": {
"browserName": "internet explorer",
"javascriptEnabled": true,
"acceptSslCerts": true
}
}
}
}
在package.json
中配置如下:
"scripts": {
"e2e_ci": "node test/e2e/runner.js --env phantomjs",
"e2e_parallel": "node test/e2e/runner.js --env phantomjs,chrome"
}
以上2個命令都是執行runner.js
文件,前者配置了個環境變量phantomjs
,這樣就會在上面查找test_settings
中的phantomjs
;後者併發執行,同時用phantomjs
和chrome
瀏覽器進行測試。
測試代碼
凡是在上述src_folders
文件夾下的js文件,都會被認爲是測試代碼,會執行測試。要跳過測試,有幾種方式:
-
@disabled
,這樣整個文件會跳過測試 -
@tags
標籤,多個文件可以標記一樣的標籤。可以命令行中添加--tag manager
,這樣,只會測試標籤爲manager
的js
文件,其它都會略過 - 如果只是想跳過當前文件的某個測試方法,可以將
function
轉換爲字符串,比如
module.exports = {
'step1': function (browser) {
},
'step2': "" + function (browser) {
}
}
以下是項目中一個樣例,幾乎涵蓋了各種操作。具體可參看http://nightwatchjs.org/api
var path = require("path");
module.exports = {
//'@disabled': true, //不執行這個測試模塊
'@tags': ["manager"],//標籤
'test manager': function (browser) {
const batchFile = browser.globals.batchFile;
const url = browser.globals.managerURL;
browser
.url(url)
.getCookie("token", function (result) {
if (result) {
// browser.deleteCookie("token");
} else {
this
.waitForElementVisible('#loginCode', 50)
.setValue('#loginCode', browser.globals.userName)
.setValue("#loginPwd", browser.globals.password)
.element("css selector", "#mntCode", function (res) { //判斷是否有多租戶
if (res.status != -1) {
browser
.click("#mntCode", function () {
browser
.assert.cssProperty("#mntList", "display", "block") //展示多租戶列表
.assert.elementPresent("#mntList li[value=uinnova]");
})
.pause(500)
.moveToElement("#mntList li[value=uinnova]", 0, 0, function () { //將鼠標光標移動到優鍩
browser.click("#mntList li[value=uinnova]", function () {
browser.assert.containsText("#mntCode", "優鍩科技");
});
});
}
})
.click("#fm-login-submit")
.pause(50)
.url(function (res) {
if (res.value !== url) {
//這個命令可以用來截圖
browser.saveScreenshot(browser.globals.imagePath + "login.png");
}
})
.assert.urlContains(url, "判斷有沒有跳轉成功,否則即是登陸失敗");
.execute(function (param) {
//此處可以執行頁面中的代碼,且得到後面傳遞的參數
try {
return uinv.data3("token");
} catch (e) {
}
}, ["param1"], function (res) {
//此處可以得到上面方法返回值
});
}
})
.maximizeWindow() //窗口最大化
.waitForElementVisible("#app", 1000)
.pause(1000)
.elements("css selector", ".data .clear li", function (res) {
var nums = res.value.length - 1; //獲取到manage.html頁面中場景的個數
browser.expect.element('.data_num').text.to.equal('(' + nums + ')'); // 用來統計場景個數的sapn標籤中的值是否等於實際的場景個數
browser.pause(500);
})
.click(".clear .last .add_data")
.waitForElementPresent("#dcControlFrame")
.frame("dcControlFrame", function () { //定位到頁面中的iframe,需要填寫iframe的id(不需要加#)
browser
.waitForElementPresent("#dataCenterId")
.saveScreenshot(browser.globals.imagePath + "dcControlFrame.png")
.setValue("#dataCenterId", browser.globals.sceneId)
.setValue("#dataCenterName", browser.globals.sceneName)
.setValue("#dataCenterText", "歡迎光臨")
.setValue("#up_picture[type='file']", path.resolve(batchFile + '/color.png')) //上傳圖片
.click(".group-btn .save", function () {
browser
.pause(1000)
.click(".layui-layer-btn0");
})
.waitForElementVisible("#dataCenterMenu3", 1000)
.pause(1500)
//上傳場景
.click("#dataCenterMenu3", function () {
browser
.setValue("#img-3d-max-model input[type='file']", path.resolve(batchFile + '/20121115uinnovaDEMO.zip')) //上傳場景文件
.waitForElementVisible(".layui-layer-btn0", 20000, function () {
browser
.click(".layui-layer-btn0");
})
.setValue("#img-3d-max-layout input[type='file']", path.resolve(batchFile + '/DEMO20140424-2016-01-14-17-48-17.js')) //上傳佈局文件
.waitForElementVisible(".layui-layer-btn0", 5000, function () {
browser
.click(".layui-layer-btn0");
});
})
.pause(500)
.saveScreenshot(browser.globals.imagePath + "frameParentBefore.png");
})
// .frameParent() //回到iframe的父級頁面;//TODO 無界面下,frame退出有問題,所以暫時改用refresh重新刷新頁面
.refresh()
.end();
}
};
以下是XX同學的使用總結
- 有些情況下延時(
pause
)是必須的,比如在表單操作中需要上傳圖片,需要等文件上傳成功後再點擊保存按鈕 - 接着第一條說,用
pause
就必須傳入一個固定時毫秒值,數值太大浪費時間,數值太小可能未執行完畢,需要反覆測試。如果可以的話,可以使用waitForElementVisible
類的方法,時間設置的長些也無妨。 -
command
方法的回調函數中的返回值會是一個對象,先把這個對象打印出來看一下格式,再使用這個對象 - 所有的
assert
和command
最後都有一個可選參數,自定義測試通過時命令行提示信息
附錄
phantomjs
PhantomJS
是一個基於webkit
的JavaScript API
。它使用QtWebKit
作爲它核心瀏覽器的功能,使用webkit
來編譯解釋執行JavaScript
代碼。任何你可以在基於webkit
瀏覽器做的事情,它都能做到。它不僅是個隱形的瀏覽器,提供了諸如CSS
選擇器、支持Web
標準、DOM
操作、JSON
、HTML5
、Canvas
、SVG
等,同時也提供了處理文件I/O
的操作,從而使你可以向操作系統讀寫文件等。PhantomJS
的用處可謂非常廣泛,諸如網絡監測、網頁截屏、無需瀏覽器的 Web
測試、頁面訪問自動化等。
因爲phantomjs
本身並不是一個nodejs
庫,所以我們使用的其實是phantomjs-prebuilt
這個包,它會根據當前操作系統判斷從phantomjs
官網下載驅動包。
遺憾的是,PhantomJS
的核心開發者之一 Vitaly Slobodin
近日宣佈,已辭任 maintainer
,不再維護項目。
Vitaly
發文表示,Chrome 59
將支持 headless
模式,用戶最終會轉向去使用它。Chrome
比PhantomJS
更快,更穩定,也不會像 PhantomJS
這樣瘋狂吃內存:
“我看不到 PhantomJS
的未來,作爲一個單獨的開發者去開發 PhantomJS 2
和 2.5
,簡直就像是一個血腥的地獄。即便是最近發佈的 2.5 Beta
版本擁有全新、亮眼的 QtWebKit
,但我依然無法做到真正的支持 3 個平臺。我們沒有得到其他力量的支持!”
隨着 Vitaly
的退出,項目僅剩下兩位核心開發者進行維護。
上面也有說到,項目並未得到資源支持,如此大型的項目,就算兩人正職維護,也很艱難。
缺陷
- 雖然
Phantom.js
是fully functional headless browser
,但是它和真正的瀏覽器還是有很大的差別,並不能完全模擬真實的用戶操作。很多時候,我們在Phantom.js
發現一些問題,但是調試了半天發現是Phantom.js
自己的問題。 - 將近
2k
的issue
,仍然需要人去修復。 -
Javascript
天生單線程的弱點,需要用異步方式來模擬多線程,隨之而來的callback
地獄,對於新手而言非常痛苦,不過隨着es6
的廣泛應用,我們可以用promise
來解決多重嵌套回調函數的問題。 - 雖然
webdriver
支持htmlunit
與phantomjs
,但由於沒有任何界面,當我們需要進行調試或復現問題時,就非常麻煩。
Puppeteer
Puppeteer
是谷歌官方出品的一個通過DevTools
協議控制headless Chrome
的Node
庫。可以通過Puppeteer
的提供的api
直接控制Chrome
模擬大部分用戶操作來進行UI Test
或者作爲爬蟲訪問頁面來收集數據。類似於webdriver
的高級別的api
,去幫助我們通過DevTools
協議控制無界面Chrome
。
在puppteteer
之前,我們要控制chrome headless
需要使用chrome-remote-interface
來實現,但是它比 Puppeteer API
更接近低層次實現,無論是閱讀還是編寫都要比puppteteer
更復雜。也沒有具體的dom
操作,尤其是我們要模擬一下click
事件,input
事件等,就顯得力不從心了。
我們用同樣2段代碼來對比一下2個庫的區別。
首先來看看 chrome-remote-interface
const chromeLauncher = require('chrome-launcher');
const CDP = require('chrome-remote-interface');
const fs = require('fs');
function launchChrome(headless=true) {
return chromeLauncher.launch({
// port: 9222, // Uncomment to force a specific port of your choice.
chromeFlags: [
'--window-size=412,732',
'--disable-gpu',
headless ? '--headless' : ''
]
});
}
(async function() {
const chrome = await launchChrome();
const protocol = await CDP({port: chrome.port});
const {Page, Runtime} = protocol;
await Promise.all([Page.enable(), Runtime.enable()]);
Page.navigate({url: 'https://www.github.com/'});
await Page.loadEventFired(
console.log("start")
);
const {data} = await Page.captureScreenshot();
fs.writeFileSync('example.png', Buffer.from(data, 'base64'));
// Wait for window.onload before doing stuff.
protocol.close();
chrome.kill(); // Kill Chrome.
再來看看 puppeteer
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.github.com');
await page.screenshot({path: 'example.png'});
await browser.close();
})();
就是這麼簡短明瞭,更接近自然語言。沒有callback
,幾行代碼就能搞定我們所需的一切。
再來段打印阮一峯大神的《ECMAScript 6 入門》
的pdf
文檔的例子:
const puppeteer = require('puppeteer');
const getRootDir = require('root-directory');
(async () => {
const rootDir = await getRootDir();
let pdfDir = rootDir + "/public/pdf/es6-pdf/";
const browser = await puppeteer.launch({
headless: false,
devtools: true //開發,在headless爲true時很有用
});
let page = await browser.newPage();
await page.goto('http://es6.ruanyifeng.com/#README');
await page.waitFor(2000);
const aTags = await page.evaluate(() => {
let as = [...document.querySelectorAll('ol li a')];
return as.map((a) => {
return {
href: a.href.trim(),
name: a.text
};
});
});
if (!aTags) {
browser.close();
return;
}
await page.pdf({path: pdfDir + `${aTags[0].name}.pdf`});
page.close();
// 這裏也可以使用promise all,但cpu可能吃緊,謹慎操作
for (var i = 1; i < aTags.length; i++) {
page = await browser.newPage();
var a = aTags[i];
await page.goto(a.href);
await page.waitFor(2000);
await page.pdf({path: pdfDir + `${a.name}.pdf`});
console.log(a.name);
page.close();
}
browser.close();
})();