天天看點

基于jest和puppeteer的前端自動化測試實戰

前端測試現狀

經常聽到後端同學說“單元測試”,前端寫過測試用例的有多少?答案是:并不多,為什麼呢?兩個主要原因

1、前端屬于GUI軟體,浏覽器衆多,相容問題讓人頭大,使用者量有一定規模的浏覽器包括:

  IE8、IE9、IE10、IE11、chrome、FireFox、360浏覽器、搜狗浏覽器、QQ浏覽器……

要在這麼多浏覽器上做幾輪測試并不容易

2、前端界面變化快,很多時候界面比測試腳本疊代的更快,測試跟不上腳步,投入産出不成正比

以上兩點導緻前端測試不受重視,很多前端開發者可能工作數年仍未寫過單元測試

英國的一個前端開發者做了一項前端測試工具調查發現,目前仍有43%的前端開發者沒有做過任何前端測試,這是現狀

基于jest和puppeteer的前端自動化測試實戰

該不該寫前端測試,還是得視項目情況而定,一般标準的開源項目都會做單元測試,是以有必要了解一下前端測試大概是個什麼東西

分類

前面一直說的是前端測試而不是單元測試,是因為前端不同于後端,前端是有界面的,測試應該分為單元測試和內建測試

所謂單元測試,就是測試一個函數或某個代碼片段,通過模拟輸入確定輸出符合預期

執行個體1:以下是一個完整的測試用例,用來測試函數sum是否按預期的計算兩個數字之和

const sum = (a,b) => { 
    return a+b;
} 
describe('分組測試描述',() => {
    test('test 1+1', () => {
        expect(1 + 1).toBe(2);
    });    
})      

解釋一下兩個關鍵字:

describe,作用是将test分組,影響 beforeEach/afterEach/beforeAll/afterAll四個方法的作用域,它有兩個參數

第一個參數就是分組描述,描述這個分組是幹嘛的

第二個參數是個回調函數,内部可以有多個test,test的作用是聲明一個測試

test,作用就是聲明一個測試,有三個參數

第一個同樣是描述,描述測試内容

第二個也是回調,内部為詳細測試内容

第三個是測試逾時時間,預設為5s鐘,做單元測試一般都是足夠的,內建測試一般都是不夠的,可以用jest.setTimeout(timeout)方法修改所有test預設逾時時間

內建測試,測的是一個功能子產品,比如使用者注冊功能,內建測試又包括UI測試,UI測試用于確定頁面正常渲染

內建測試完全是用測試腳本去模拟使用者操作,比如打開浏覽器、點選注冊連結、輸入使用者名密碼、點選注冊

UI測試怎麼確定頁面正常渲染?

兩種方式:像素級對比和快照

像素及對比,就是首先人肉确認頁面渲染正常,執行腳本對頁面截個圖,下次利用測試腳本截個圖跟上次的截圖的每個像素自動進行對比,如果每個像素都一樣,那麼測試通過

快照,這裡的快照不是截圖的意思,而是将頁面渲染後的DOM結構生成一個序列化的文本,下次再次生成一個序列化的DOM文本與之對比,如果内容完全一樣,測試通過,做快照測試,必須保證多次測試輸出快照總是一緻的,然而在react中,model經常變化,這時就要用mock模拟函數傳回固定資料確定model不變,mock功能在下文有介紹

主流庫 

流行的單元測試包括jest、mocha、jasmine、……

流行的內建測試庫包括puppeteer、casperJS、PhantomJS、……

jest的特點是零配置、即時回報,它所有測試用例預設是并行執行的,速度快,它也可以配置成串行,在調試時比較有用,jest每個測試用例檔案都是一個沙箱,在單個測試檔案内部定義或修改全局變量,不會影響其它測試檔案,jest由Facebook團隊維護,對React友好,适合大型項目

mocha是一個精簡而靈活的單元測試架構,它本身沒有包含斷言庫和mock(模拟)功能,需要自行引入其它庫,而jest和jasmine都自帶斷言庫和mock功能,什麼是mock,後面會介紹

puppeteer是個神器,它并不僅僅可以做自動化內建測試,它本身是個node庫,自帶chromuium浏覽器(是以npm安裝它比較慢),它提供了一些進階API通過DevTools協定控制headless chrome或chromuium,它也可以配置為使用有界面版的chrome,既然是浏覽器,chrome能做到的它基本都能做到,chrome做不到的,它也能做到,用puppeteer做內建測試,測試用例是真正在真實的浏覽器上執行的,下面幾點都是它所擅長的

  • 生成頁面螢幕截圖或pdf
  • 自動送出表單,做UI測試、模拟鍵盤輸入、滑鼠操作等
  • 建立一個最新的自動化測試環境,用最新的JavaScript和浏覽器功能,直接在最新的chrome中做測試
  • 捕獲你網站的時間線跟蹤,以幫助診斷性能問題

casperJS是一個基于PhantomJS的庫,它封裝了PhantomJS的API使它更容易使用,PhantomJS内置了webkit的核心,測試用例并不是跑在真正的浏覽器上面

本文的重點是jest和puppeteer,下面是執行個體和API都是基于這兩者

Setup 

如果在執行jest測試用例之前需要做一些配置,在用例執行完做一些清除操作,那你需要了解下面4個API

beforeEach(callback)

在每個test用例執行前執行回調callback,在單個測試檔案内,它對每個test都有效,如果它放在describe内部,那麼它隻對describe内部的test用例有效,上面講過,describe内部可以有多個test

afterEach(callback)

在每個test用例執行後執行回調callback,作用域同beforeEach

beforeAll(callback)

在所有test用例執行前執行回調callback

afterAll(callback)

在所有test用例執行後執行回調callback

斷言 

在編寫測試時,您經常需要檢查值是否符合某些條件。Expect就是幹這個的,它有很多 比對方法,執行個體1中的

expect(sum(1,1)).toBe(2);      

 意思就是斷言函數sum執行的結果等于2,其它比對方法包括但不限于:

  • 判斷某個變量是否定義:.toBeDefined();
  • 比較某個值是否大于指定數字:.

    toBeGreaterThan(number);

  • 檢查對象length屬性是否等于指定值:.

    toHaveLength(number);

  • ……

mock定時器 

業務代碼中經常會用到定時器,包括setTimeout、setInterval,在做單元測試的時候,如果傻傻地等定時器一秒一秒走那就很浪費時間,大家都是一秒鐘幾十萬上下的人,哪怕幾秒鐘也不會浪費,jest的mock功能,可以模拟定時器執行,有4個重要的API必須了解一下:

  1. jest.useFakeTimers() 聲明在目前測試檔案中使用模拟定時器,聲明後,可以直接用expect(setTimeout).toHaveBeenCalledTimes(1)判斷定時器調用的次數
  2. jest.runAllTimers() 立即執行所有定時器 
  3. jest.runOnlyPendingTimers() 立即執行挂起的定時器
  4. jest.advanceTimersByTime(msToRun) 提前msToTun毫秒執行定時器

第1個API需要注意,僅僅聲明jest.useFakeTimers(),定時器回調的代碼并不會執行,第2、3、4個API都會真正執行定時器回調代碼;

jest.runOnlyPendingTimers()執行挂起的定時器是什麼意思?其實就是即将要執行的那一個定時器,下面這段代碼,會調用兩次setTimeout,第一次是jest.useFakeTimers()觸發的,第二次是jest.runOnlyPendingTimers()觸發的

function timeout() {
    setTimeout(() => {
        console.count('count');
        timeout();
    }, 10000);
}

jest.useFakeTimers();

test('useFakeTimers', () => {
    timeout();
    jest.runOnlyPendingTimers();
    expect(setTimeout).toHaveBeenCalledTimes(2);
});      

如果把上一段測試用例的jest.runOnlyPendingTimers()換成jest.runAllTimers()會進入死循環

基于jest和puppeteer的前端自動化測試實戰

mock函數

手動實作了一個forEach函數,要測試它是否按預期執行回調,這裡模拟了一個回調函數mockCallback,模拟函數的好處是可以擷取每次調用它的參數和它的執行次數,在項目中可以模拟請求傳回指定資料而無需通路伺服器

function forEach(items, callback) {
    for (let index = 0; index < items.length; index++) {
        callback(items[index]);
    }
}

test('test forEach', () => {
    const mockCallback = jest.fn();
    forEach([0, 1], mockCallback);
    // The mock function is called twice
    expect(mockCallback.mock.calls.length).toBe(2);
    // The first argument of the first call to the function was 0
    expect(mockCallback.mock.calls[0][0]).toBe(0);
    // The first argument of the second call to the function was 1
    expect(mockCallback.mock.calls[1][0]).toBe(1);
});       

異步 

測試腳本中可能包含異步操作,如果不用異步方式寫test,test執行到最後一行就認為測試完成,很可能測試失敗

方式一:done回調,傳入參數done,異步操作執行完後執行done()

// done
test('async test', done => {
    function callback(data) {
        expect(data).toBe('xx');
        done();
    }
    fetchData(callback);
});      

方式二:傳回promise,test會等promise執行完才跳出

// return promise
test('async test', () => {
    //判斷目前測試有一個斷言被執行
    expect.assertions(1);
    return fetchData().then(data => {
        expect(data).toBe('xx');
    });
});      

方式三:.resolves/.rejects,同樣必須return promise

test('works with resolves', () => {
    expect.assertions(1);
    return expect(user.getUserName(5)).resolves.toEqual('xx');
});      

方式四:ES8的async/await,可以和.resolves/.rejects混合使用

// async/await can be used.
it('works with async/await', async () => {
    expect.assertions(1);
    const data = await user.getUserName(4);
    expect(data).toEqual('xx');
});

// async/await can also be used with `.resolves`.
it('works with async/await and resolves', async () => {
    expect.assertions(1);
    await expect(user.getUserName(5)).resolves.toEqual('xx');
});      

關于describe還有兩個重要重要的方法應該了解下

describe.only(name, fn)

隻執行該describe,其它describe會被忽略

describe.skip(name, fn)

 和.only相反,隻跳過該describe,在調試時很有用

puppeteer常用的幾個API也了解一下

  • puppeteer.launch() 執行個體化一個浏覽器
  • browser.newPage(url) 打開新頁面
  • page.goto(url) 跳轉到url
  • page.$(selector) 選擇頁面元素,傳回的是元素句柄(ElementHandle),不是真實DOM節點,selector底層實作用的就是document.querySelector
  • page.$$(selector) 同上,selector底層實作用的就是document.querySelectorAll,傳回多個句柄
  • page.$eval(selector, pageFunction[, ...args]) 同上,傳回的是pageFunction的傳回值,在pageFunction内可以擷取到真實DOM節點,如擷取元素ID,page.$eval('div', divs => divs.id);
  • page.$$eval(selector, pageFunction[, ...args]) 同上,selector底層實作用的就是document.querySelectorAll
  • page.click(selector[, options]) 點選指定元素
  • page.type(selector, text[, options]) 改變元素的值,如果是react,會同時改變model層資料,就像真實使用者輸入 

單元測試 VS 內建測試 

基于jest和puppeteer的前端自動化測試實戰

兩種測試方法各有優缺點,具體用哪種視項目具體情況而定

繼續閱讀