UI自动化测试平台工程搭建

463 阅读7分钟

技术栈

前端

vite5 + React18 + KDesign + chrome插件开发

服务端

egg.js + mongodb + ejs模版

自动化测试

Cypress.js + mochawesome

阅读前准备

1、了解react技术栈开发

2、了解nodejs框架koa、egg.js

3、了解mongodb

4、了解chrome插件开发

5、了解Cypress.js自动化测试框架

工程搭建

需要创建两个项目,一个开发chrome插件,一个开发服务端,cypress.js直接集成到服务端项目里。

  • 前端

使用Vite创建一个React项目

npm create vite@latest

选择 reactTS

安装crxjs/vite-plugin插件

npm i @crxjs/vite-plugin@beta -D

创建 Manifest.json 文件

{
  "manifest_version": 3,
  "name": "CRXJS React Vite Example",
  "version": "1.0.0",
  "action": {
    "default_popup": "index.html"
  }
}

修改 Vite.config.ts 配置文件

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    crx({ manifest }),
  ],
})

运行 pnpm run dev 命令

可以看到多了个 dist 文件夹,这个就是构建好的插件安装包

.
├── dist
│   ├── assets
│   │   └── loading-page-1924caaa.js
│   ├── index.html
│   ├── manifest.json
│   ├── service-worker-loader.js
│   └── vite.svg

安装插件

打开浏览器输入:chrome://extensions,点击【加载已解压的扩展程序】选择 dist 文件夹进行安装

以上是使用crxjs/vite快速搭建一个chrome插件开发的项目工程的过程

  • 服务端

使用脚手架快速创建egg.js项目

mkdir egg-example && cd egg-example
npm init egg --type=simple
npm i

Egg.js 目录结构如下:

egg-example
├── app
|   ├── router.js
│   ├── controller
│   ├── service (可选)
│   ├── middleware (可选)
│   ├── schedule (可选)
│   ├── public (可选)
├── config
|   ├── plugin.js
|   ├── config.default.js
│   ├── config.prod.js
└── test
    ├── middleware
    |   └── response_time.test.js
    └── controller
        └── home.test.js
  • cypress.js

服务端项目录下通过npm安装cypress.js

cd egg-example
npm i cypress --save-dev

在package.json添加启动命令

 "scripts": {
    "cypress": "cypress run open"
  }

运行npm run cypress启动cypress程序,会弹出窗口进行初始化操作

选择E2E Testing后会自动在当前项目目录下创建cypress目录,里面已初始化好示例代码,目录结构如下

工程目录

上面创建工程后我们先初始化下项目目录文件,目录结构如下(具体目录文件功能后面会详细介绍)

  • 前端chrome插件项目目录

  • 服务端项目目录

Chrome插件实现

这部分开发分以下几个部分

1、插件名称图标等公共配置

2、插件权限配置

3、创建调试面板入口文件,并添加页签

4、devtool页面管理测试用例功能开发

5、插件与页面交互

  • 修改插件名称及图标

// manifest.json

"name": "精斗云开发测试小工具",
  "description": "精斗云开发测试小工具",
  "version": "1.0.1",
  "action": {
    "default_title": "精斗云开发测试小工具",
    "default_icon": "src/assets/logo.png",
    "default_popup": "index.html"
  },

  • 插件权限配置, manifest.json添加permissions和host_permissions配置如下
"host_permissions": [
    "<all_urls>",
    "https://*.jdy.com/*"
  ],
  "permissions": [
    "cookies",
    "tabs",
    "scripting",
    "clipboardWrite",
    "contextMenus",
    "storage",
    "webRequest",
    "<all_urls>",
    "*",
    "http://*/*",
    "https://*/*"
  ]
  • 创建调试面板入口文件,并添加页签

入口文件中添加判断是否载目标测试页面,如果是则通过chrome.devtools.panels.create创建一个调试面板页签 // devtools/App.tsx

 useEffect(() => {
    isJdyYkjOrJxc().then((value) => {
      if (value) {
        chrome.devtools.panels.create("精斗云自动化测试", "", "devtools.html");
      }
    });
  }, []);
  • devtool页面管理测试用例功能开发

这部分就是常规的页面业务功能开发,没什么好讲的

  • 插件与页面交互

如上图运行测试用例时,需要chrome插件操作页面的dom元素,这部分实现可以借助chrome插件content把交互需要的js注入到页面中。devtools模块通过Chrome message机制调用content来实现与页面的交互

在src/devtools下创建testCase.ts文件


export let actionMap = {
    click: clickAction,
    dbclick: dbclickAction,
    mousehover: mousehoverAction,
    mouseleave: mouseleaveAction,
    type: typeAction,
    equalText: equalTextAction,
    hasText: hasTextAction,
    delay: delayAction,
    waitVisible: waitVisibleAction
}

chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
    // 用例操作
    if (message.action === "testAction" && message.testAction) {
        let actionFn = actionMap[message.testAction.type || '']
        if(!actionFn){
            sendResponse({type: "testActionResult", result: {state: false, msg: '未知操作'}});
            return
        }
        actionFn(message.testAction).then(res => {
            sendResponse({type: "testActionResult", result: res});
        })
    }
    // 查找元素
});

export async function clickAction(action: ICaseAction): Promise<IActionResult>{
    if(!action.argument1){
        return {state: false, msg: '元素选择器不能为空!'}
    }
    let targetElement
    try{
        targetElement = document.querySelector(action.argument1);
    }catch(e){
        return {state: false, msg: '未找到目标元素!'}
    }
    if(!targetElement){
        return {state: false, msg: '未找到目标元素!'}
    }
    try{
        var clickEvent = new MouseEvent('click', {
            'view': window,
            'bubbles': true, // 事件是否冒泡
            'cancelable': true // 事件是否可以取消
        });
        // 触发click事件
        targetElement.dispatchEvent(clickEvent);
    }catch(e){

        return {state: false, msg: '点击元素操作失败!'}
    }
    return {state: true, msg: '操作成功'}
}
export async function dbclickAction(action: ICaseAction): Promise<IActionResult>{
    // 省略....
}
export async function mousehoverAction(action: ICaseAction): Promise<IActionResult>{
   // 省略....
}
export async function mouseleaveAction(action: ICaseAction): Promise<IActionResult>{
    // 省略....
}
export async function typeAction(action: ICaseAction): Promise<IActionResult>{
    // 省略....
}
export async function equalTextAction(action: ICaseAction): Promise<IActionResult>{
    // 省略....
}
export async function hasTextAction(action: ICaseAction): Promise<IActionResult>{
    // 省略....
}
export async function waitVisibleAction(action: ICaseAction): Promise<IActionResult>{
    // 省略....
}
function fireKeyEvent(targetElement, eventKey, keyCode, extraEventOpt = {}){
    // 省略....
}

console.log('chrome devtools content')

配置manifest.json将testCase.ts注入到页面(添加content_scripts配置项)

"content_scripts": [{
      "js": [
        "src/devtools/testCase.ts"
      ],
      "matches": [
        "https://*.jdy.com/*"
      ],
      "run_at": "document_end",
      "match_about_blank": true
    }
  ],

然后就可以通过Chrome message调用testCase.ts提供的方法,封装个调用方法


export interface ICaseAction {
    type: string
    argument1: string
    argument2:string
    argument3: string
    [key: string]: any
}

export function contentBridgeExecuteAction(testAction) {
  return new Promise((resolve) => {
    chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
      chrome.tabs.sendMessage(
        tabs[0].id,
        { action: "testAction", testAction },
        function (response) {
          resolve(response.result);
        }
      );
    });
  });
}

服务端设计

这部分开发分以下几个部分

1、eggjs mongodb配置

2、jwt鉴权配置

3、自定义插件统一处理错误

4、允许跨域处理

5、配置静态资源

6、业务接口功能实现

7、ejs模版生成cypress.js脚本文件

  • eggjs mongodb配置

1、安装mongoose模块

npm install egg-mongoose --save

2、配置config文件

// config/plugin.js
exports.mongoose = {
    enable: true,
    package: 'egg-mongoose',
};

// config/config.default.js
config.mongoose = {
    url: 'mongodb://127.0.0.1:27017/jdy_ui_test',
    options: {},
};
  • 全局中间件和扩展配置

1、后端接口开发中我们需要一个统一得返回格式,可以在context对象下扩展个返回数据function用于统一处理接口response data

app下新建文件夹extend 新建context.js

// app/extend/context.js
module.exports = {
        /**
         * 返回客户端的内容
         * @param status // 接口是否成功
         * @param body // 返回数据
         * @param msg // 返回信息提示
         * @param code // 返回状态码
         */
        returnBody (status = true, body = {}, msg = 'success', code = 200) {
                this.status = code;
                this.body = {
                        status: status,
                        body: body,
                        msg,
                        code: code
                }
        }
}

调用

const { ctx } = this;

ctx.returnBody(true, {}, "成功");

2、添加统一处理错误得中间件 app文件夹下新建middleware文件夹,新建error_handler.js, 并配置congfig全局中间件配置

// app/middleware/error_handler.js
module.exports = () => {

        return async function errorHandler(ctx, next) {
                try {
                        await next();
                } catch (err) {
                        // 所有的异常都会在app上出发一个error事件,框架会记录一条错误日志
                        ctx.app.emit('error', err, ctx);

                        const status = err.status || 500;

                        // 如果时生产环境的时候 500错误的详细错误内容不返回给客户端
                        const error = status === 500 && ctx.app.config.env === 'prod' ? '网络错误' : err.message;

                        ctx.body = {
                                msg: error,
                                status: false,
                                body: {},
                                code: status
                        };
                }
        };
};

// app/middleware/error_handler.js
// config/config.default.js 配置全局中间件
config.middleware = [ 'errorHandler'];
  • jwt鉴权登录认证

1、安装egg-jwt token生成以及验证包

npm install egg-jwt --save

2、安装完成后在根目录下的 config/plugin.js 配置一下,如:

'use strict';

/** @type Egg.EggPlugin */
module.exports = {
        jwt: {
                enable: true,
                package: "egg-jwt"
        },
  mongoose: {
    enable: true,
    package: 'egg-mongoose',
  }
};

3、接下来在 config/config.default.js 里面继续配置:

config.jwt = {
  secret: "123456"//自定义 token 的加密条件字符串
};

4、在context上扩展两个function, getToken 和 checkToken用于生成token和验证token

// app/extend/context.js
async getToken(data) {
        return await this.app.jwt.sign(data, this.app.config.jwt.secret, {expiresIn: 30* 24 * 60 * 60 + 's'});
},
async checkToken(token) {
        return await this.app.jwt.verify(token, this.app.config.jwt.secret)
}

5、编写个中间件实现登录验证拦截 在app/middleware文件夹下新建auth.js

// app/middleware/auth.js
module.exports = () => {
        return async function(ctx, next) {
                let token = '';
                if (
                        ctx.headers.authorization && ctx.headers.authorization.split(' ')[0] === 'Bearer'
                ) {
                        token = ctx.headers.authorization.split(' ')[1];
                } else if (ctx.query.accesstoken) {
                        token = ctx.query.accesstoken;
                } else if (ctx.request.body.accesstoken) {
                        token = ctx.request.body.accesstoken;
                }
                let user;
                try{
                        user = await ctx.checkToken(token);
                }catch (e) {
                        ctx.returnBody(false,{}, 'Token 无效,请重新登录', 401);
                }
                if (!user) {
                        ctx.returnBody(false,{}, 'Token 无效,请重新登录', 401);
                        return;
                }
                ctx.request.user = user;
                await next();
        };
};

好了以上配置完成后就开始接下来的核心注册功能相关操作了。

首先我在根目录下的 app/router.js 创建访问路由:

import { Application } from 'egg';

export default (app: Application) => {
  const { controller, router, jwt } = app;
  //正常路由
  router.post('/auth/register', controller.auth.register);
  
  // 只有在需要验证 token 的路由上添加jwt
  router.post('/user/infor',jwt, controller.user.infor); 
};

接下来我去编写我的控制器,在根目录下的 app/controller/home.ts 编写内容: 这里使用了两个我们在app/extend/context.js上扩展的两个通用方法

  1. 通过ctx.getToken(用户信息object对象)将用户信息通过jwt生成token返回给前端
  2. 通过ctx.returnBody返回数据
// app/controller/auth.js
const Controller = require('egg').Controller
class AuthController extends Controller {
        async login() {
                //... 略
        }
        async register() {
                const { ctx, service } = this;
                const { username, password, email } = ctx.request.body
                let userData = await ctx.service.user.createUser(username, password, email);
                userData = userData.toObject();
                let userDataStr = JSON.parse(JSON.stringify(userData));
                // 生成token
                let token =await ctx.getToken(userDataStr);
                ctx.returnBody(true, {access_token: token, userInfo: userData}, "注册成功!")
        }
}

module.exports = AuthController;
  • 前端请求的时候需要在 headers 里面上默认的验证字断 Authorization 就可以了,如:
axios({
 method: 'get',
  url: 'http://127.0.0.1:7001/user/info',
  headers:{
      // 切记 token 不要直接发送,要在前面加上 Bearer 字符串和一个空格
    'Authorization':`Bearer ${token}`
  }
})
  • 接口从toen获取加密信息
  1. 在app/extend/context.js再扩展个getUser方法获取token加密信息
// app/extend/context.js
// 获取用户信息
        async getUserData() {
                var token = this.headers.authorization ? this.headers.authorization : '';
                token = token.substring(7) //把Bearer 截取掉,解析的时候不需要加上Bearer
                let user = {}
                try {
                        user = this.app.jwt.verify(token, this.app.config.jwt.secret);
                } catch (err) {
                        user = {}
                }
                return user;
        }

2. 实现获取个人信息接口

// app/controller/user.js
'use strict';

const Controller = require('egg').Controller;

class UserController extends Controller {
        async info() {
                let {ctx} = this;
                let user = await this.ctx.getUserData()
                ctx.returnBody(true, user)
        }
}

module.exports = UserController;

至此我们就实现了jwt生成token, 然后通过前端传过来的token获取当前登录用户的信息, jwt登录授权这块应该是讲完了,其他的业务接口应该实现起来难度不大

剩下的就是具体业务的代码实现逻辑了,业务逻辑实现这块就略过了

  • ejs模版生成cypress.js脚本文件

在新建generate目录用于放生成cypress.js脚本文件的代码,目录结构和文件如下所示:

ejs模版示例:case.cy.ejs

/// <reference types="cypress" />
import "cypress-wait-until";
describe('<%= testGroupName %>', () => {
  // 在所有测试用例执行前执行
  before(() => {
    cy.setCookie('ysSession', '<%= cookies.ysSession %>')
    cy.setCookie('app-token', '<%= cookies.appToken %>')
    cy.visit('<%= projectUrl %>')
<% if (beforeActions && beforeActions.length > 0) { %>
  <% beforeActions.forEach(function(action) { %>
    <%= action %>
  <% }) %>
<% } else { %>  <% } %>
  })
  //在所有测试用例执行完后执行
  after(() => {
<% if (afterActions && afterActions.length > 0) { %>
  <% afterActions.forEach(function(action) { %>
    <%= action %>
  <% }) %>
<% } else { %>  <% } %>
  })
<% cases.forEach(function(item, index, array) { %>
  //测试用例<%= index+1 %>
  it('<%= item.name %>', () => {
    // 用测beforeEach步骤
    <% if (item.beforeEachActions && item.beforeEachActions.length > 0) { %>
      <% item.beforeEachActions.forEach(function(action) { %>
        <%= action %>
      <% }) %>
    <% } %>
    // 用测操作步骤
    <% if (item.actions && item.actions.length > 0) { %>
      <% item.actions.forEach(function(action) { %>
        <%= action %>
      <% }) %>
    <% } %>
    // 用测afterEach步骤
    <% if (item.afterEachActions && item.afterEachActions.length > 0) { %>
      <% item.afterEachActions.forEach(function(action) { %>
        <%= action %>
      <% }) %>
    <% } %>
  })
<% }) %>
})

生成脚本文件方法示例: // app/generate/helps/generateCases.js

async function generateCasesFile(caseGroup, cypressData) {
  let outputPath = path.resolve(
    pathConfig.e2e,
    `${cypressData.reportId}/${caseGroup._id}.cy.js`
  );
  let caseEjsData = await getCaseEjsData(caseGroup, cypressData);
  let template = await getCaseEjsTemplate(caseEjsData);
  await createFile(outputPath, template);
}

function getCaseEjsTemplate(caseData) {
  let ejsCasePath = path.resolve(pathConfig.ejs, "case.cy.ejs");
  return new Promise((resolve) => {
    // 读取模板文件
    fs.readFile(ejsCasePath, "utf8")
      .then((template) => {
        // 使用EJS渲染模板(返回Promise)
        return ejs.render(template, caseData);
      })
      .then((result) => {
        result = result
          .replace(/&#39;/g, "'")
          .replace(/&#34;/g, '"')
          .replace(/&gt;/g, ">");
        result = removeEmptyLines(result);
        resolve(result);
      })
      .catch((err) => {
        console.log(err);
        resolve("");
      });
  });
}

Cypress.js集成

这部分开发分以下几个部分

1、自定义cypress.config配置

2、mochawesome插件生成测试报告

3、mochawesome-merge, mochawesome-report-generator合并测试报告文件

4、服务端接口调用shelljs执行测试任务

这部分的要点在于我们可以通过cypress命令指定cypress.config.js配置文件位置和测试用例脚本的位置

cypress run --headless --config-file "${cypressConfigPath}" --spec "${cypressCasePath}"

在运行测试单时只要把配置文件和测试用例脚本生成到指定位置即可。具体代码实现参见generate目录代码

项目部署

启动服务 npm run start

停止服务npm run stop