持續集成之測試篇

持續集成

單元測試(unit)

karma

KarmaGoogle開源的一個基於Node.jsJavaScript 測試執行過程管理工具(Test Runner)。該工具可用於測試所有主流Web瀏覽器,也可集成到 CIContinuous integration)工具,也可和其他代碼編輯器一起使用。

我們測試用的無界面瀏覽器phantomjs。測試框架使用mochachai

以下是我們項目中使用的主要配置信息:

/**
 * 測試啓動的瀏覽器
 * 可用的瀏覽器: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

mochaJavaScript的一種單元測試框架,既可以在瀏覽器環境下運行,也可以在Node.js環境下運行。

使用mocha,我們就只需要專注於編寫單元測試本身,然後,讓mocha去自動運行所有的測試,並給出測試結果。

mocha的特點主要有:

  • 既可以測試簡單的JavaScript函數,又可以測試異步代碼,因爲異步是JavaScript的特性之一;
  • 可以自動運行所有測試,也可以只運行特定的測試;
  • 可以支持beforeafterbeforeEachafterEach來編寫初始化代碼。

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.jsexpect.jschai,具體的語法規則需要大家去查閱相關文檔。
因爲chai既包含shouldexpectassert三種風格,可擴展性比較強。本質是一樣的,按個人習慣選擇。詳見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或者phantomjsnodejs編寫的e2e自動測試框架,可以很方便的寫出測試用例來模仿用戶的操作來自動驗證功能的實現。

nightwatch的使用很簡單,一個nightwatch.json或者nightwatch.config.js(後者優先級高)配置文件,使用runner會自動找同級的這兩個文件來獲取配置信息。也可以手動使用--config來制定配置文件的相對路徑。

selenium

selenium是一個強大瀏覽器測試平臺,支持firefoxchromeedge等瀏覽器的模擬測試,其原理是打開瀏覽器時,把自己的JavaScript文件嵌入網頁中。然後selenium的網頁通過frame嵌入目標網頁。這樣,就可以使用seleniumJavaScript對象來控制目標網頁。

項目中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;後者併發執行,同時用phantomjschrome瀏覽器進行測試。

測試代碼

凡是在上述src_folders文件夾下的js文件,都會被認爲是測試代碼,會執行測試。要跳過測試,有幾種方式:

  1. @disabled,這樣整個文件會跳過測試
  2. @tags標籤,多個文件可以標記一樣的標籤。可以命令行中添加--tag manager,這樣,只會測試標籤爲managerjs文件,其它都會略過
  3. 如果只是想跳過當前文件的某個測試方法,可以將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同學的使用總結

  1. 有些情況下延時(pause)是必須的,比如在表單操作中需要上傳圖片,需要等文件上傳成功後再點擊保存按鈕
  2. 接着第一條說,用pause就必須傳入一個固定時毫秒值,數值太大浪費時間,數值太小可能未執行完畢,需要反覆測試。如果可以的話,可以使用 waitForElementVisible 類的方法,時間設置的長些也無妨。
  3. command方法的回調函數中的返回值會是一個對象,先把這個對象打印出來看一下格式,再使用這個對象
  4. 所有的assertcommand最後都有一個可選參數,自定義測試通過時命令行提示信息

附錄

phantomjs

PhantomJS是一個基於webkitJavaScript API。它使用QtWebKit作爲它核心瀏覽器的功能,使用webkit來編譯解釋執行JavaScript代碼。任何你可以在基於webkit瀏覽器做的事情,它都能做到。它不僅是個隱形的瀏覽器,提供了諸如CSS選擇器、支持Web標準、DOM操作、JSONHTML5CanvasSVG等,同時也提供了處理文件I/O的操作,從而使你可以向操作系統讀寫文件等。PhantomJS的用處可謂非常廣泛,諸如網絡監測、網頁截屏、無需瀏覽器的 Web 測試、頁面訪問自動化等。

因爲phantomjs本身並不是一個nodejs庫,所以我們使用的其實是phantomjs-prebuilt這個包,它會根據當前操作系統判斷從phantomjs官網下載驅動包。

遺憾的是,PhantomJS 的核心開發者之一 Vitaly Slobodin 近日宣佈,已辭任 maintainer ,不再維護項目。

Vitaly 發文表示,Chrome 59 將支持 headless 模式,用戶最終會轉向去使用它。ChromePhantomJS 更快,更穩定,也不會像 PhantomJS 這樣瘋狂吃內存:

“我看不到 PhantomJS 的未來,作爲一個單獨的開發者去開發 PhantomJS 22.5 ,簡直就像是一個血腥的地獄。即便是最近發佈的 2.5 Beta 版本擁有全新、亮眼的 QtWebKit ,但我依然無法做到真正的支持 3 個平臺。我們沒有得到其他力量的支持!”

隨着 Vitaly 的退出,項目僅剩下兩位核心開發者進行維護。

上面也有說到,項目並未得到資源支持,如此大型的項目,就算兩人正職維護,也很艱難。

缺陷

  • 雖然Phantom.jsfully functional headless browser,但是它和真正的瀏覽器還是有很大的差別,並不能完全模擬真實的用戶操作。很多時候,我們在Phantom.js發現一些問題,但是調試了半天發現是Phantom.js自己的問題。
  • 將近2kissue,仍然需要人去修復。
  • Javascript天生單線程的弱點,需要用異步方式來模擬多線程,隨之而來的callback地獄,對於新手而言非常痛苦,不過隨着es6的廣泛應用,我們可以用promise來解決多重嵌套回調函數的問題。
  • 雖然webdriver支持htmlunitphantomjs,但由於沒有任何界面,當我們需要進行調試或復現問題時,就非常麻煩。

Puppeteer

Puppeteer是谷歌官方出品的一個通過DevTools協議控制headless ChromeNode庫。可以通過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();
})();

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章