自动化测试详细接入流程及踩坑记录

1,036 阅读6分钟
一,依赖安装
  • 安装JestVue Test Utils( Vue.js官方的单元测试实用工具库 )

    yarn add --dev jest @vue/test-utils
    
  • 安装vue-jest 预处理器(告诉Jest 如何处理 *.vue 文件)

    yarn add --dev vue-jest
    
  • 安装babel配置的相关依赖(jest基于node环境,为了在node中使用importES modules语法)

    yarn add --dev babel-jest @babel/preset-env// Babel 7 或更高版本,你需要在你的 devDependencies 里添加 babel-bridge
    yarn add --dev babel-core@^7.0.0-bridge.0// 对于使用import等方式导入*.ts文件,需要再安装babel-ts相关依赖
    yarn add --dev ts-babel ts-jest @babel/preset-typescript
    
  • 安装js-dom和 一些jest插件工具

    // 用 JSDOM 在 Node 虚拟浏览器环境运行测试
    yarn add --dev jsdom jsdom-global// 模拟代码里import的一些静态资源,如css,svg等
    yarn add --dev jest-transform-stub
    

 

二,Jest 及babel配置文件的创建和修改

  • 为了让jest开启ES modules的转译,新建一个.bablerc.js文件

    module.exports = {
      env: {
        // 为了仅在测试时应用这些选项,可以把它们放到一个独立的 env.test 配置项中 (这会被 babel-jest 自动获取)。
        test: {
          presets: [
            [
              '@babel/preset-env',
              {
                targets: {
                  // 告诉 babel-preset-env 面向我们使用的 Node 版本。这样做会跳过转译不必要的特性使得测试启动更快
                  node: 'current',
                },
              },
            ],
            '@babel/preset-typescript',
          ],
        },
      },
    };
    
  • 在项目根目录生成一个jest配置文件 jest.config.js

    module.exports = {
      // A set of global variables that need to be available in all test environments
      globals: {
        'ts-jest': {
          tsconfig: {
            target: 'ES2019',
          },
        },
      },
      // An array of file extensions your modules use
      moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'vue'],
    ​
      // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
      moduleNameMapper: {
        '^~/(.*)$': '<rootDir>/$1',
        '.*(css|less|sass|scss|styl)$': '<rootDir>/__tests__/__mocks__/stub.css',
      },
    ​
      // A list of paths to directories that Jest should use to search for files in
      roots: ['<rootDir>'],
    ​
      // The paths to modules that run some code to configure or set up the testing environment before each test
      setupFiles: ['<rootDir>/__tests__/global.js'],
    ​
      // The test environment that will be used for testing
      testEnvironment: 'node',
    ​
      // The glob patterns Jest uses to detect test files
      testMatch: ['**/__tests__/**/*.test.(js|jsx|ts|tsx)'],
    ​
      // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
      testURL: 'http://localhost',
    ​
      // A map from regular expressions to paths to transformers
      transform: {
        '^.+\.vue$': 'vue-jest',
        '.+\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'jest-transform-stub',
        '^.+\.js?$': 'babel-jest',
        '^.+\.(ts|tsx)$': 'ts-jest',
      },
    ​
      // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
      transformIgnorePatterns: ['/node_modules/'],
    };
    

     


    编写配置文件 jest.config.js时遇到的问题和解决方案:

    1. 在测试代码中导入带可选链语法(class?.pass?.finished_time)语法的typescript文件时,如:

      import clazz from '~/clazz.ts'
      

      时,jest会报错SyntaxError: Unexpected token '.'。原因是在ts的配置文件中:

      // ts.config.js:
      target: "esnext"
      

      所以jest 跑的时候,ts的编译器忽略了?.这个语法,因此要在配置文件中告诉jest, 把ts按照es2019的标准去编译

       // A set of global variables that need to be available in all test environments
        globals: {
          'ts-jest': {
            tsconfig: {
              target: 'ES2019',
            },
          },
        },
      
    2. jest无法识别由webpack等工具配置好的一些路径的别名, 如在项目代码中有~等路径别名:

      // vue.config.js
      // 自定义webpack配置
        configureWebpack: {
          resolve: {
            alias: {
              '~': path.resolve(__dirname, '../../'),
              'assets': path.resolve(__dirname, '../../assets'),
            },
          },
        },
      

      这里需要在配置文件jest.config.js中通过moduleNameMapper做一个映射:

       // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
        moduleNameMapper: {
          '^~/(.*)$': '<rootDir>/$1',
            // 同时,在实践中发现了通过import语法导入的css文件在jest运行时会报错说找不到文件,因此也做一个css文件的映射
          '.*(css|less|sass|scss|styl)$': '<rootDir>/__tests__/__mocks__/stub.css',
        },
      
    3. 对于每份测试代码来说,有一些公共的配置,为了不重复编写这些代码,我们可以利用setupFiles这个配置项:

      setupFiles: ['<rootDir>/__tests__/global.js'],
        
      // global.js'
        
      // 全局引入js-dom, vue, vue-test-utils等依赖
      require('jsdom-global')();
      const testUtils = require('@vue/test-utils');
      const Vue = require('vue');
      // 用jest模拟axios
      jest.mock('axios');
      // 定义一些windows上的属性/方法
      window.Bus = new Vue();
      // 一些公有工具函数的导入
      import('~/plugins/base/utils.ts').then(() => {
        // console.log('import utils ==> successed');
      });
      

     

    三, 利用jest模拟重点模块

    • 模拟一些挂载在Vue实例上的插件

      实际上,在项目中有许多像this.$toast, this.$http这样的挂载在全局Vue实例当中的插件。如果不对这些插件进行模拟的话,在运行测试脚本时,会出现找不到this.$toast这样的报错。我们可以使用jest来模拟这些插件,甚至随心所欲地改变他们的实现。

      import { createLocalVue } from '@vue/test-utils';
      const localVue = createLocalVue();
      ​
      const toast = {};
      toast.install = (Vue) => {
        // 把 this.$toast 模拟成 console.log
        Vue.prototype.$toast = jest.fn(function() {
          console.log('toast:', ...arguments);
        });
      };
      localVue.use(toast);
      ​
      const docWarpper = shallowMount(doc, {
        localVue,
      });
      
    • 模拟Axios的返回

      在课堂模块中,我们不希望测试脚本真正地发出一个学习上报的请求,因为这样前端无法对返回的数据进行干预和模拟。我们期望返回的数据可以在测试脚本中由前端来决定:比如,我们希望上报接口去返回当前课程的学习时长为1分钟

      import Axios from 'axios';
      jest.mock('axios');
      ​
      Axios.put.mockResolvedValue({
          data: {
            data: {
              id: 42,
              learn_time: 60,
            },
            code: 0,
          },
        };
      });
      

      当然,在项目的课堂模块中,axios其实被进一步封装成了this.$http,我们需要先简单地模拟$http的实现, 进一步优化为:

      import Axios from 'axios';
      import { createLocalVue } from '@vue/test-utils';
      jest.mock('axios');
      ​
      const mockLearnTimeReturn = (learnTime) => {
        return {
          data: {
            data: {
              id: 42,
              learn_time: learnTime,
            },
            code: 0,
          },
        };
      };
      ​
      const localVue = createLocalVue();
      const axios = {};
      axios.install = (Vue) => {
        Vue.prototype.$http = Axios;
      };
      localVue.use(axios);
      const docWarpper = shallowMount(doc, {
        localVue,
      });
      ​
      // 接口返回learn_time为 60
      Axios.put.mockResolvedValue(mockLearnTimeReturn(60));
      // 接口返回learn_time为 120
      Axios.put.mockResolvedValue(mockLearnTimeReturn(120));
      ​
      
    • 模拟组件中的函数并监听其调用情况

      当我们需要监控课堂中的report函数的调用次数时,我们可以使用jest.fn()对其进行包装:

      docWarpper = shallowMount(doc);
      // 包裹关键函数,来监控他们的调用情况
      docWarpper.vm.report = jest.fn(docWarpper.vm.report);
      // 期望report函数被调用了4次
      expect(docWarpper.vm.report).toHaveBeenCalledTimes(4);
      

      实际上,jest.fn还可以用于修改组件内函数的具体实现,使我们在不改变业务逻辑的情况下更随心所欲地进行测试

      docWarpper.vm.report = jest.fn(() => {
        console.log('Have been reported');
      });
      docWarpper.vm.report();   // console: Have been reported
      
    • 模拟定时器Timer

      课堂模块如今是每隔30s进行一次学习上报的,我们可以利用jest去模拟js Timer,可以做到一些意想不到的事情:

      jest.useFakeTimers();
      // 计时器快进120s
      jest.advanceTimersByTime(1000 * 120);
      // 用同步的方法执行所有计时器
      jest.runAllTimers();
      // 只执行pending状态下的计时器
      jest.runOnlyPendingTimers();
      

    四,代码逻辑梳理和测试样例编写

    在理解上面的一些基础API的作用和使用方法后,可以开始尝试梳理项目课堂模块的代码逻辑,并编写简单的测试脚本来验证成果:

    课堂模块的主要流程图为:

3.png

根据流程图,编写出以下的测试样例,此测试样例主要用于验证:

  1. 此文档课程的剩余学习时间为2min

  2. 2min内,学习上报了4次

  3. 在倒计时为0时,checkCountDownAccuracy函数被调用了,用于检查后端返回的时间是否与前端倒计时一致

  4. 检查完成后,前端能正常显示剩余学习时间为0,学习结束

  5. 学习结束后,refreshPointStatus函数应该被调用了1次, 用于轮询关卡状态

    test('测试文档课堂学习上报-(120秒内应该上报4次)', (done) => {
      jest.useFakeTimers();
      // 渲染文档课堂组件, getDocMountOptions中初始化了组件的propsData, $route, $store等数据
      docWarpper = shallowMount(doc, getDocMountOptions());
      // 学习时长为120秒
      expect(docWarpper.vm.remainTime).toBe(120);
      // 开始学习倒计时
      docWarpper.vm.countdown();
      // 倒计时结束时Axios应该返回learn_time为 120
      Axios.put.mockResolvedValue(mockLearnTimeReturn(120));
      // 手动模拟已经过去了120s
      jest.advanceTimersByTime(1000 * 120);
      // report函数应该被调用了4次
      expect(docWarpper.vm.report).toHaveBeenCalledTimes(4);
      // checkCountDownAccuracy函数应该被调用了一次
      expect(docWarpper.vm.checkCountDownAccuracy).toHaveBeenCalledTimes(1);
      // 结束后,剩余学习时间变为0
      expect(docWarpper.vm.remainTime).toBe(0);
      // 学习结束后应该轮询关卡状态, refreshPointStatus应该被调用了1次
      expect(docWarpper.vm.refreshPointStatus).toHaveBeenCalledTimes(1);
      done();
    });
    

    然而,此测试样例实际上是存在问题的,执行yarn test时控制台报错如下:

4.png

  • 可以看到,checkCountDownAccuracy的确被调用了一次,但之后剩余学习时间remainTime并没有设置为0,而是为1。为了探究这个问题,我详细了解了jestmock Timer的实现。

    实际上,jest内部对于Timer模拟有一个不完善的地方:当调用jest.advanceTimersByTime这个API时,所有的Timer中的回调函数是会从异步执行转化为同步执行的。所以,jest并不会等待Timer的回调中的await, promise等异步代码的resolved。然而,我们的代码在Timer的回调函数中,其实发起了ajax异步请求,并等待接口的返回值learn_time来重置倒计时的。这就导致了测试脚本中的剩余时间remainTime永远停留在了剩余1秒时,无法被正确地修改,导致测试结果不符合预期。

    通过查找jest官方代码库上的issue, 找到了暂时的解决方案:通过await让测试代码等待所有的promise resolve

    // jest.advanceTimersByTime 会让 timer 变成同步的方式执行,但是不会等待timer中的 promise resolve,应该每次都应该手动 resove 队列中的 promise
    const flushPromises = () => new Promise((res) => setImmediate(res));
    ​
    test('测试文档课堂学习上报-(120秒内应该上报4次)', async (done) => {
      jest.useFakeTimers();
      // 渲染文档课堂组件, getDocMountOptions中初始化了组件的propsData, $route, $store等数据
      docWarpper = shallowMount(doc, getDocMountOptions());
      // 学习时长为120秒
      expect(docWarpper.vm.remainTime).toBe(120);
      // 开始学习倒计时
      docWarpper.vm.countdown();
      // 倒计时结束时Axios应该返回learn_time为 120
      Axios.put.mockResolvedValue(mockLearnTimeReturn(120));
      // 手动模拟已经过去了120s
      jest.advanceTimersByTime(1000 * 120);
      // 等待所有的promise resolve
      await flushPromises();
      // report函数应该被调用了4次
      expect(docWarpper.vm.report).toHaveBeenCalledTimes(4);
       // checkCountDownAccuracy函数应该被调用了一次
      expect(docWarpper.vm.checkCountDownAccuracy).toHaveBeenCalledTimes(1);
      // 结束后,剩余学习时间变为0
      expect(docWarpper.vm.remainTime).toBe(0);
      jest.runOnlyPendingTimers();
      // 学习结束后应该轮询关卡状态, refreshPointStatus应该被调用了1次
      expect(docWarpper.vm.refreshPointStatus).toHaveBeenCalledTimes(1);
      done();
    });