# 持续集成之测试篇

# 单元测试(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/plugins/jquery/jquery-1.8.1.min.js',
  '../../src/plugins/common/bluebird.min.js',
  'specs/validators.js'
],
/**
 * 排除的文件列表
 */
exclude: [
],
/**
 * 在浏览器使用之前处理匹配的文件
 * 可用的预处理: https://npmjs.org/browse/keyword/karma-preprocessor
 */
preprocessors: { //报告覆盖
  "../../src/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 (opens new window)

下面简单的介绍一下这是那种风格

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 (opens new window)

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=aa]");
                  })
                  .pause(500)
                  .moveToElement("#mntList li[value=aa]", 0, 0, function () { //将鼠标光标移动
                    browser.click("#mntList li[value=aa]", function () {
                      browser.assert.containsText("#mntCode", "abc");
                    });
                  });
              }
            })
            .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 utils.data("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;
        browser.expect.element('.data_num').text.to.equal('(' + nums + ')');
        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 + '/demo.zip')) //上传文件
              .waitForElementVisible(".layui-layer-btn0", 20000, function () {
                browser
                  .click(".layui-layer-btn0");
              })
              .setValue("#img-3d-max-layout input[type='file']", path.resolve(batchFile + '/demo.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();
})();