felix-cao / Blog

A little progress a day makes you a big success!
31 stars 4 forks source link

基于 Vue 的 E2E test 环境搭建 #204

Open felix-cao opened 3 years ago

felix-cao commented 3 years ago

本文是基于一个2年前的 Vue 脚手架项目搭建 E2E 环境,实现思路是利用 Headless Chrome 来模拟用户的一切行为。

一、Headless Chrome

简单的在 window 系统中玩一下 Headless Chrome

cd 'C:\Program Files\Google\Chrome\Application'
# Dump DOM to the screen
chrome.exe --headless --disable-gpu --enable-logging --dump-dom https://www.baidu.com/
# Save the page as a PDF
chrome.exe --headless --disable-gpu --print-to-pdf=C:\Temp\output.pdf https://www.baidu.com/
# Screenshot the page
chrome.exe --headless --disable-gpu --screenshot=C:\Temp\screenshot.png  https://www.baidu.com/
# Set the window size
chrome.exe --headless --disable-gpu --screenshot=C:\Temp\screenshot.png --window-size=1280,1696 https://www.baidu.com/

二、安装依赖及 scripts 脚本

主要依赖 jest , puppeteer, babel, 在 package.json 中如下

"scripts": {
    "dev": "cross-env NODE_ENV=dev webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "build": "node build/build.js",
    "test": "jest --runInBand -c config/jest.config.js"
 },
// ......
"devDependencies": {
    "babel-core": "^6.22.1",
    "babel-helper-vue-jsx-merge-props": "^2.0.3",
    "babel-loader": "^7.1.1",
    "babel-plugin-syntax-jsx": "^6.18.0",
    "babel-plugin-transform-runtime": "^6.22.0",
    "babel-plugin-transform-vue-jsx": "^3.5.0",
    "babel-preset-env": "^1.3.2",
    "babel-preset-stage-2": "^6.22.0",
     // ....... add the follow dependencies
    "babel-jest": "^23.6.0",
    "jest": "^26.0.1",
    "jest-dev-server": "^5.0.3",
    "jest-html-reporters": "^2.1.6",
    "jest-puppeteer": "^5.0.4",
    "webpack": "^3.6.0",
    "puppeteer": "^10.0.0",
}

.babelrc, 配置 babel

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    ["babel-preset-env", {"targets": {"node": "current"}}],
    "stage-2"
  ],
  "plugins": [
    "transform-vue-jsx", 
    "transform-runtime"
  ]
}

注意,这里的依赖是踩了坑后可以完整运行的。

const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');

module.exports = async function () { const browser = await puppeteer.launch({ slowMo:500, // 输入延迟时间 headless: false, devtools: false, defaultViewport: null, args: ['--window-size=1920,1080'], }); global.__BROWSER_GLOBAL__ = browser; mkdirp.sync(DIR); fs.writeFileSync(path.join(DIR, 'wsEndpoint'), browser.wsEndpoint()); };

`/config/jest/teardown.js`
```js
const os = require('os');
const path = require('path');
const rimraf = require('rimraf');

const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');
module.exports = async function () {
  // close the browser instance
  setTimeout(() => {
     global.__BROWSER_GLOBAL__.close();
  }, 2000)
  rimraf.sync(DIR);
};

/config/jest/puppeteer_environment.js

const fs = require('fs');
const os = require('os');
const path = require('path');
const puppeteer = require('puppeteer');
const NodeEnvironment = require('jest-environment-node');
const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');

class PuppeteerEnvironment extends NodeEnvironment {
  constructor(config) {
    super(config);
  }

  async setup() {
    await super.setup();
    const wsEndpoint = fs.readFileSync(path.join(DIR, 'wsEndpoint'), 'utf8'); // get the wsEndpoint
    if (!wsEndpoint) {
      throw new Error('wsEndpoint not found');
    }

    this.global.__BROWSER__ = await puppeteer.connect({ // connect to puppeteer
      browserWSEndpoint: wsEndpoint,
    });

    this.global.page = await this.global.__BROWSER__.newPage();
    await this.global.page.setViewport({width: 1920, height: 1080});
  }

  async teardown() {
    await super.teardown();
  }

  getVmContext() {
    return super.getVmContext();
  }
}

module.exports = PuppeteerEnvironment;

`sequencer.js` 请参考 四

四、指定文件顺序

只有先登录了,才能进行完整的 E2E test, 按照 stackoverflow 这里的介绍是实现不了的, 本文另辟捷径,在配置 maxConcurrency: 1 的情况下, 指定 E2E test 文件的执行顺序,从而达到目的。 即在 jest.config.js 中配置 testSequencer: path.resolve(__dirname, '../test/e2e/config/sequencer.js'),

/test/e2e/config/sequencer.js

const _ = require('lodash');
const path = require('path');
const orders = require('./orders');
const Sequencer = require('@jest/test-sequencer').default;

class CustomSequencer extends Sequencer {
  sort(tests) {
    let arrs = [];
    const newTests = _.filter(tests, item => !_.includes(item.path, 'example'));
    _.forEach(orders, pathVal => {
      const index = _.findIndex(newTests, { path: path.resolve(__dirname, `../${pathVal}`) });
      if(index >= 0) {
        arrs.push(newTests[index]);
        newTests.splice(index, 1);
      }
    })
    return _.concat(arrs, newTests);
  }
}

module.exports = CustomSequencer;

/test/e2e/config/orders.js

module.exports =  [ // 严格按照 orders 指定的顺序玩
  'login/index.e2e.js', // 取 e2e 下的文件,含e2e下的路径,如 loanCentral/tearsheet.e2e.js
]

五、 Example

以百度为例, /test/e2e/example/baidu.search.e2e.js

import * as _ from 'lodash';
const { PuppeteerScreenRecorder } = require('puppeteer-screen-recorder');

const timeout = 10000;

beforeAll(async () => {
  let page = global.page;
  await page.goto(`http://www.baidu.com`);
});

afterAll(async () => {
  await page.close();
});

describe( 'Login to THC', () => {
    it('should load error when type invalid username', async () => {
      await page.waitForSelector('input');
      await page.focus("#kw");
      await page.waitForTimeout(300);
      await page.keyboard.type('合肥高新区牛X', {delay: 300});
      await page.click('#su', {delay: 300});
      expect(1).toBe(1);
    }, timeout);
  }
);

上面的代码中, page.keyboard.type 是模拟键盘输入.

六、生产测试 report

yarn add -D jest-html-reporters

配置 jest.config.js

reporters: [ 
    ["jest-html-reporters", {
    "publicPath": path.resolve(__dirname, '../test/e2e/html-report'),
    "filename": "report.html",
    "expand": true,
    "openReport": true
    }]
]

Reference

至此,一个完整的 E2E Test 环境就搭建起来了。

felix-cao commented 3 years ago

录屏尝试

jest-puppeteer issueshttps://github.com/smooth-code/jest-puppeteer/issues/361,不可行

yarn add -D puppeteer-screen-recorder

实现代码:

import * as _ from 'lodash';
const { PuppeteerScreenRecorder } = require('puppeteer-screen-recorder');

const timeout = 10000;
let page;
let recorder;
beforeAll(async () => {
  page = await global.__BROWSER__.newPage();
  recorder = new PuppeteerScreenRecorder(page);
  await page.setViewport({width: 1920, height: 1080});
  await recorder.start(`${global.videosPath}/ceshi.mp4`);
  // await page.goto(`${global.Host}/tlink/login`);
  await page.goto(`http://www.baidu.com`);
});

afterAll(async () => {
  await page.close();
});

describe( 'Login to THC', () => {
    it('should load error when type invalid username', async () => {
      await page.waitForSelector('input');
      await page.focus("#kw");
      await page.waitForTimeout(300);
      await page.keyboard.type('合肥九义软件公司', {delay: 300});
      await page.click('#su', {delay: 300});

      await recorder.stop();
      expect(1).toBe(1);
    }, timeout);
  }
);

结论:单个是成功的,但是对于整个项目是失败的,并且我们的项目中也没这个需求,因此,这个 feature 暂时不再深入研究下去

felix-cao commented 3 years ago

尝试让鼠标动起来

yarn add -D ghost-cursor

代码实现:

import { createCursor } from "ghost-cursor"
// .........
const cursor = createCursor(page)
await cursor.click(selector)

但效果不理想,鼠标没动起来,同时频繁引起 timeout 性能问题,不知道是什么原因,但 ghost-cursor 中展示的效果非常好。

felix-cao commented 3 years ago

邮件通知

实现方法:使用 jest reporters 中的自定义reporters 配置来触发发送邮件动作,发送邮件功能使用 emailjs 插件

安装 emailjs

yarn add -D emailjs

/config/jest.config.js

    [`${path.resolve(__dirname, '../test/e2e/config')}/email-report.js`, {
      Smtp: {
        user: '491766244@qq.com',
        password: '1111111',
        host: 'smtp.qq.com',
        ssl: true,
      },
      To: {
        to: 'zfcao@thomasho.cn',
        cc: 'czf2008700@163.com, 491766244@qq.com',
      },
      ReportUrl: 'http://localhost:8081/',
      date,
    }]

email-report.js

const fs = require('fs');
const _ = require('lodash');
const path = require('path');
const dayjs = require("dayjs");
const email = require('emailjs');

class EmailReport {
  constructor(globalConfig, options) {
    this._globalConfig = globalConfig;
    this._options = options;
  }
  onRunStart() {
    const { publicPath, filename } = this._globalConfig.reporters[1][1]
    const reportFile = path.resolve(publicPath, filename);
    try {
      if (fs.existsSync(reportFile)) {
        fs.unlinkSync(reportFile)
      }
    } catch(err) {
      console.error(err)
    }
  }
  onRunComplete(contexts, results) { 
    this.sendEmail(results);
  }

  sendEmail(results) {
    const reportFile = path.resolve(__dirname, './template.html');
    let html = fs.readFileSync(reportFile, 'utf8');
    const client = new email.SMTPClient(this._options.Smtp);
    results.moreUrl = `${this._options.ReportUrl}${this._options.date}`;
    results.report_time = dayjs().format('MM/DD/YYYY HH:mm:ss');
    results.failedTestPer = (results.numFailedTests / results.numTotalTests * 100).toFixed(2);
    results.failedTestSuitesPer = (results.numFailedTestSuites / results.numTotalTestSuites * 100).toFixed(2);
    const arr = html.match(/{[\d_\w]+}/gm);

    _.forEach(arr, item => {
      const key = _.trim(item, '{}');
      html = _.replace(html, item, results[key]);
    })

    client.send(
      {
        from: this._options.Smtp.user,
        ...this._options.To,
        subject: `End to End test --- ${results.report_time}`,
        attachment: [
          {data: html, alternative: true }
        ]
      },
      (err, message) => {
        console.log(err || message);
      } 
    );
  }
}

module.exports = EmailReport;

一开始用的时 sendemail.js,代码如下, 发现其发送邮件不稳定,发送失败的几率比较高,因此不采纳这个方案

sendmail({
      from: 'no-reply@yourdomain.com',
      to: 'zfcao@thomasho.cn, czf2008700@163.com',
      subject: `E2E test Reporters ------ ${dayjs().format('MM/DD/YYYY HH:mm:ss')}`,
      html: content,
    }, function(err, reply) {
      console.log('err:-------------:', err && err.stack);
      console.dir('reply:-------------:', reply);
    });