快速上手前端自动化测试

4,512 阅读21分钟

前言

最近团队落地实践了前端单元测试和 E2E (端到端) 测试方案,在此之前我基本没有在实际项目里用过自动化测试方案,总觉得这类方案耗时太大,没什么收益,但自从写自动化测试代码两个月以来,我发现还是有一定收益的,下面是我的一些思考和总结,希望对大家有一点点帮助。

什么是自动化测试

在正常的软件开发流程中,我们一定会有一个很重要的环节那就是测试,而对于一个大型项目来说,测试的环节尤为重要,它是软件发布前最后一个“关卡”,也是我们软件质量的重要保障,所以我们通常会留一些时间用于软件测试。测试的方式主要分为两大类:人工测试自动化测试

  • 人工测试:通过开发人员或测试人员与程序的交互来完成,即手动操作验证。
  • 自动化测试:通过自动化脚本与程序的交互来完成,除了刚开始编写的自动化脚本时间,基本上无需手动操作。

那自动化测试解决了什么问题呢?

解决了什么问题

假设有一个前端开发工程师 A 和一个测试人员 B,A 在开发完一个模块后往往会在页面上操作一下页面的流程,输入数据,查看页面是否正常工作,假如他开发的这个模块又依赖于之前已有的模块,于是他又测试了一下以前的模块,防止带来关联改动的 BUG。

A 测试完成之后,他推送了代码到远程仓库并合入稳定分支。测试 B 拉取仓库,然后部署一个测试环境,接着按照之前写的测试用例一条条去验证功能,测试 B 发现也有关联改动于是也顺带验证了之前的功能......

image.png

这种测试方式就是人工测试,简单直接,但是很繁琐,除了模块的基本功能测试,还需要对以前的功能做回归测试,如果这个模块的功能很复杂,比如一个很大数据量的表单输入,那耗费的时间就更多了,如果碰上项目紧急,就难免就导致天天加班去搞测试,而且很容易导致功能漏测,从而引发质量问题。

终于 A 实在忍受不了这种手动重复点击页面的操作了,于是决定在下一个迭代接入前端自动化测试。首先在确认了迭代二的需求之后,测试首先写好了测试需求用例,并且给产品线中的人做了评审,确定需求最终的验收条件。前端工程师 A 拿到测试给的需求后,并没有着急写代码,而是先做好相关的需求分析和模块设计,接着开始编写自动化脚本。然后完成所有的功能编码后,在本地触发脚本跑通所有的测试用例,确认没有问题后,推送到远程仓库。

回归测试是指 修改了旧代码后,重新进行测试以确认修改没有引入新的错误或导致其他代码产生错误

此时远程仓库也有一套流水线,会启动一个前端测试环境,然后自动打包前端代码,触发自动化测试脚本。如果该分支代码成功跑过了流水线,接着在 Code Review 环节(如果有)再审核代码,最后才会被合入主干稳定分支。

自动化测试.png

上面这种方式就是自动化测试的方案(前端),这种方案基本上能做到不漏测一个功能,而且不需要我们手动去输入数据和点击页面去验证了,提升了代码的质量。但是也是有缺点的:

  • 要保证写的测试脚本覆盖所有的需求点,不然就是自欺欺人了(比如 100% 的测试覆盖率,若漏写了测试用例就完犊子了...)。
  • 需求变更的时候要及时去更新需求测试脚本。
  • 整个自动化流程的基建设施要完善,比如 CI/CD 流程,只有自动化脚本没有 CI/CD 流程效率会低很多。
  • 业务繁忙,没时间写测试脚本。
  • 测试脚本有一定的上手成本。

即使满足了上述条件下,还是存在一些问题的:自动化测试那么好,但是要如何做呢?必须要全部测试吗?特殊场景的测试问题该如何解决呢?下面就来介绍一下前端测试的一些方法和工具使用。

自动化测试的应用场景

前端自动化测试方法主要是两大类,白盒测试黑盒测试(当然还有其他更细分的测试比如灰盒测试,这里不多说了,因为大学的测试理论忘的差不多了😂)。

  • 白盒测试:说白了就是代码的逻辑是否正确,流程逻辑,函数调用,异常处理等等,比如常见的单元测试。
  • 黑盒测试:主要是对一个功能的验证,不关心代码的具体实现,比如端到端测试(E2E,也是集成测试的一种类别)。

当然,细分的类别我们能接触到的就是单元测试集成测试UI测试端到端测试了,它们各有千秋,但是在某些场景都有不可替代性。下面我通过几个场景来简单介绍一下它们的概念。

单元测试的场景

假如我们要写一个数字类型的加法函数,我们要先想好它的输入输出

  • 输入:接受两个数字参数。
  • 输出:输出两个参数的和。

于是我们的代码是:

function sum(a, b) {
    return a + b;
}

export default sum;

而对应的测试代码(以 Jest 为例)是:

import sum from './sum';

test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
});

当然你也可以做一些边界值的测试,比如输入非数字类型的参数和空值,有兴趣的同学可以继续补充。

这就是单元测试的一种表现形式,它一般是对程序最小单元运行测试的过程,除此之外我们可能会接触到的其它单测场景:

  • 组件的单元测试:UI 组件、无状态组件、基础组件。
  • 纯函数的单元测试,我们可能会封装一些 util 工具等等,但是要保证我们写的函数是易于测试的,即没有副作用,函数的输入和输出是稳定的。

单元测试的优点:

  • 测试速度很快。
  • 代码覆盖率较高。
  • 有助于模块的设计。
  • 易于代码维护。

而缺点就是无法验证多个单元运行到一起是否正确,能做到这一点的是我们要介绍的集成测试。

集成测试/端到端测试的场景

集成测试是把不同的模块集成在一起,来测试模块与模块之前的配合是否正常工作。比如在前端我们点击一个按钮会进行表单提交,而这涉及到按钮的点击事件是否正常,表单的校验或者发送请求是否正常触发。

E2E的定义和集成测试是差不多的,它们通常都是站在用户视角并且以真正的运行环境来测试整个流程和功能的。集成测试和端到端的定义的边界是较模糊的,所以我们可以放在一起介绍。下面是一个用户使用某个系统的简单场景:

  • 访问某个系统主页
  • 点击某个元素,然后进入另外一个页面

那么它的测试代码可以这么写(以 Cypress 为例):

describe('My First Test', () => {
  it('clicking "type" navigates to a new url', () => {
    // 1.访问 https://example.cypress.io -> 模拟用户输入 URL
    cy.visit('https://example.cypress.io')
    
    // 2.点击某个元素 -> 模拟用户点击
    cy.contains('type').click()

    // 3.跳转新页面的断言
    // includes '/commands/actions'
    cy.url().should('include', '/commands/actions')
  })
})

做自动化测试一般有一个流程:输入 - 断言 - 验证。不管是单测还是 E2E 都可以遵循这个原则,如果不知道如何开始,可以考虑我们的预期结果是什么,以终为始,再去慢慢实现函数或组件的具体功能,这就是 TDD 的一种模式,这在后面我们会介绍到。

相信大家看了上面两个案例之后对测试有了一定的感性认知了,那么前端自动化测试框架或工具有哪些,以及怎么上手使用呢?下面我给大家简单介绍一下我所了解的一些工具吧。

自动化测试框架介绍

单元测试框架

目前市面上比较流行的一些前端单元测试框架主要是:

上面的几个库都很优秀,但是目前大部分项目里可能比较常用的是 JestMochaVitest 这几个库。下面是它们的几个特点:

测试框架优点缺点
Jest功能很丰富,基本开箱即用。内置断言、快照、隔离环境、覆盖率、Mock 等功能,社区活跃,基于Jasmine,测试速度相对较快。对于较大的快照文件,无法进行。当然最重要的是无法共享项目的构建系统,这点可以在Vitest官网的介绍。
Vitest适用于 Vite 构建的项目,特点就是字:极快!。兼容大部分 Jest 的 API,如果原来是 Jest,则可以无缝切换到 Vitest框架比较新,生态还不是很成熟,主要应用于 Vite 的项目(非 Vite 构建的项目也是支持的)。
Mocha灵活自由,允许开发者自由配置,无内置断言,但可以自主选择断言库,支持浏览器和 Node灵活性高也带来了需要配合多个库使用,前期学习成本较高。

由于本篇文章的主题不是深入某个测试框架的研究,大家初始接触上面的概念可能也会和我一样比较模糊,所以我建议直接上手一个工具来体验,这样理解概念就会容易一些了。下面我以 Vitest 为例,带大家快速体验一下它的魅力。

体验 Vitest

集成 Vitest 到项目

  • 先初始化一个 vite 项目
 # 创建项目模板
 pnpm create vite test-demo --template vue-ts
  • 安装 vitestvue 官方封装的一些测试函数
pnpm add -D vitest @vue/test-utils jsdom

vitest 会使用项目根目录下的 vite.config.ts ,当然也可以自己创建一个 vitest.config.ts ,它的优先级最高。为了共享 vite 的配置,就在 vite.config.ts 简单配置一下测试的环境。

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  resolve: {
    // vite 中的别名解析
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  plugins: [vue()],
  test: {
    include: ['test/**/*.test.ts'],
    environment: 'jsdom',
  }
})

defineConfig 中的 test 配置就是用于 vitest 的。其中 include 用于告诉 vitest 去找哪些测试文件,而 environment 用于模拟环境的,比如我们接下来要进行组件的单元测试是跑在浏览器的,但是 vitest 的运行时是 Node.js ,所以我们需要使用 jsdom 来进行浏览器的模拟。

当然,类型提示也是很重要的,我们刚刚创建的模板是包括两个 tsconfig 的:tsconfig.jsontsconfig.node.json,你可以理解为一个通用的配置,一个用于 Node 环境。

tsconfig.json

{
  "compilerOptions": {
    // (省略已有配置)...
    // 添加下面的别名解析
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": [
    // (省略已有配置)...
    // 添加下面这一行,告诉 ts 解析 test 目录下的 ts 文件。
    "test/**/*.ts",
  ],
  "references": [{ "path": "./tsconfig.node.json" }]
}

tsconfig.node.json

{
  "compilerOptions": {
    // (省略已有配置)...
    // 添加 types 获取 vite 和 vitest 的提示
    "types": ["vite/client", "vitest"]
  },
  "include": ["vite.config.ts"]
}

最后把测试的命令配置到 package.json 就完成环境的准备了。

package.json

{
  "scripts": {
    "test": "vitest",
  },
}

组件单元测试

搞定了环境,我们先来看看 Vue 组件的单元测试怎么搞。假设我们现在有一个 Stepper 组件,具有单步加减功能,我们简单实现如下:

src/components/Stepper.vue

<template>
  <div>
    <!-- tid 属性用于测试,可以快速获取 DOM -->
    <button @click="count--" tid="dec-btn">-</button>
    <span>{{ count }}</span>
    <button @click="count++" tid="inc-btn">+</button>
  </div>
</template>

<script setup lang="ts">
  import { ref } from 'vue'

  interface Props {
      initial: number;
  }

  const props = withDefaults(defineProps<Props>(), {
    initial: 0
  })

  const count = ref(props.initial)
</script>

效果如图:

Kapture 2022-09-04 at 18.13.51.gif

此时我们要想一下测试代码要怎么写?前文我们有提过,测试大体上有三步:输入 - 断言 - 验证。在 vitest 中你可以获得与 jest expect 一样的体验。

Stepper 组件中,它的输入是 initial ,那它的输出是什么呢?

对于组件来说它的单元测试比较特殊,因为它有一个 “渲染” 的过程,所以我们也可以把渲染的过程当成一次特殊的输出。这里我们借助 @vue/test-utils 中的 mount 方法来获取渲染的状态。

test/Stepper.test.ts

import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import Stepper from '@/components/Stepper.vue'

describe('Stepper.vue 组件单元测试', () => {
  it('should render', () => {
    // 输入:传递初始值 10
    const wrapper = mount(Stepper, { props: { initial: 10 } })
    // 断言 -> 验证:渲染的 DOM 中是否包含 10
    expect(wrapper.text()).toContain('10')
    // 生成快照
    expect(wrapper.html()).toMatchSnapshot()
  })
})

注意,我们在最后一行添加了一个快照断言,一般组件的单元测试都会去对 UI 进行比对,比对前后两次快照来判断 UI 是否发生变化,这通常在组件库比较常见。

接下来我们需要验证该组件的交互性功能,拆分成单元测试即:

  • 按钮是否正常渲染
  • 点击两个按钮,断言文本的变化
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import Stepper from '@/components/Stepper.vue' // 直接通过别名引,从这就可以说明 vitest 是和 vite 共享配置的

describe('Stepper.vue 组件单元测试', () => {
  // 提前定义选择器
  const SELECTOR = {
    dec: '[tid=dec-btn]',
    inc: '[tid=inc-btn]',
  }
  
  // 测试默认渲染
  it('should render', () => {
    const wrapper = mount(Stepper, { props: { initial: 10 } })
    expect(wrapper.text()).toContain('10')
    expect(wrapper.html()).toMatchSnapshot()
  })
  
  // 测试交互性
  it('should be interactive', async () => {
    const wrapper = mount(Stepper, { props: { initial: 0 } })
    
    // 判断两个按钮是否正常渲染
    expect(wrapper.text()).toContain('0')
    expect(wrapper.find(SELECTOR.dec).exists()).toBe(true)
    expect(wrapper.find(SELECTOR.dec).exists()).toBe(true)
    
    // 点击增加按钮(异步事件所以用 await)
    await wrapper.get(SELECTOR.inc).trigger('click')
    expect(wrapper.text()).toContain('1')
    
    // 点击减少按钮(异步事件所以用 await)
    await wrapper.get(SELECTOR.dec).trigger('click')
    expect(wrapper.text()).toContain('0')
  })
})

如果你的控制台一片 “绿” 说明你的组件通过单元测试啦。

image.png

当然如果你注意到你每次修改代码时,你一定会有一个感觉:vitest 是真的快!

util 单元测试

我们平时在项目里通常比较常见的有日期格式化、正则校验等工具函数,我们就以密码的校验为例。函数实现功能如下:

  • 最小 8 位
  • 至少一个字母
  • 至少有一位数字
  • 至少有一个特殊字符
  • 不符合要返回错误消息

我们简单实现如下:

src/util/validator.ts

const DEFAULT_MESSAGE = '密码至少八个字符,至少一个字母,一个数字和一个特殊字符'

export const passwordValidator = (str: string, msg = DEFAULT_MESSAGE) => {
  const reg = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/

  if (!reg.test(str)) {
    return msg
  }

  return true
}

测试代码:

test/validator.test.ts

import { passwordValidator } from '@/util/validator'
import { describe, expect, it } from 'vitest'

describe('密码校验', () => {
  const DEFAULT_MESSAGE = '密码至少八个字符,至少一个字母,一个数字和一个特殊字符'

  it('正常情况', () => {
    expect(passwordValidator('As12345@')).toBe(true)
  })

  it('密码长度不低于8位', () => {
    expect(passwordValidator('As1.')).toBe(DEFAULT_MESSAGE)
  })

  it('密码至少包含一个字母', () => {
    expect(passwordValidator('1234567@')).toBe(DEFAULT_MESSAGE)
  })

  it('密码至少包含一个数字', () => {
    expect(passwordValidator('abcdefg@')).toBe(DEFAULT_MESSAGE)
  })

  it('密码至少包含一个特殊字符', () => {
    expect(passwordValidator('1abcdefg')).toBe(DEFAULT_MESSAGE)
  })
})

Vitest 的案例是不是很简单呢?上面我们只用到了Vitest 的一小部分功能,如果想要学习更多的用法可以直接看文档学习哦。

E2E 测试框架

Cypress 是一款很具有代表性的 E2E 测试工具,可以模拟出用户与浏览器的交互行为,但是除了 E2E 测试其实它也可以做组件的单元测试,而且比 VitestJest 这类框架更加有说服力,因为它是真实的浏览器环境,这是 jsdomhappydom 这类库无法媲美的,毕竟它们只是模拟出来的。

体验 cypress

集成 cypress

  • 安装(建议开启代理,或者设置 CYPRESS_INSTALL_BINARY环境变量)
pnpm i cypress -D
  • 启动 cypress
pnpm exec cypress open

cypress 默认会读取项目根目录下的 cypress.config.js ,如果项目没有任何配置,则会在启动的时候让你选择:

image.png

左边是做 E2E 测试,而右边是做组件的单元测试,我们随便选择一个开始就可以了,这里我选择的是组件测试(Component Testing),接下来 cypress 会识别我们的框架和构建工具以及是不是 TS 项目,点击 Continue 之后会帮我们在根目录下创建一个 cypress 目录 和一个 cypress.config.ts

cypress 目录会包含以下目录结构:

cypress
├── fixtures # 存放测试数据
│   └── example.json
└── support # 增强 Cypress ,在所有测试用例之前加载
    ├── commands.ts # 可以自定义命令或者覆盖内置已有的命令
    ├── component-index.html # 测试启动的入口文件,可以配置全局的样式、CDN
    └── component.ts # 添加了组件测试命令支持,这里增加了 `mount` 命令 

我们再看到 cypress.config.ts 也增加了一些默认配置:

cypress.config.ts

import { defineConfig } from "cypress";

export default defineConfig({
  // 组件测试相关配置
  component: {
    // 配置组件测试的开发服务器(框架是 Vue,使用 Vite 构建的项目)
    devServer: {
      framework: "vue",
      bundler: "vite",
    },
  },
});

如果看不懂上面的配置没关系,刚开始入门我们也不需要太关注这些东西,重点是来明白 E2E 的测试是怎么做的,我们接下来动手来实践一下。

如果你是其他类型项目可以参考cypress-component-testing-examples 中的 demo 进行配置。

使用 cypress 进行 组件测试

为了方便下次使用,我们可以在 package.json 中配置一个组件测试命令:

  "scripts": {
    "test:component": "cypress open --component -b chrome",
  },
  • --component 表示测试的类型。
  • -b 表示使用浏览器环境,并指定 chrome 浏览器。

有了这些准备后,我们稍微改造一下上面的 Stepper.vue 组件,让其支持传递事件。这里我采用就近原则,把文件放到了组件同级目录。

components/test/Stepper.cy.ts

<template>
  <div>
    <!-- tid 属性用于测试,可以快速获取 DOM -->
    <button @click="decrement" tid="dec-btn">-</button>
    <span tid="counter">{{ count }}</span>
    <button @click="increment" tid="inc-btn">+</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

interface Props {
  initial?: number;
}

const emit = defineEmits<{
  (e: 'change', val: number): void
}>()

const props = withDefaults(defineProps<Props>(), {
  initial: 0
})

const count = ref(props.initial)

const increment = () => {
  count.value++
  emit('change', count.value)
}

const decrement = () => {
  count.value--
  emit('change', count.value)
}
</script>

我们需要测试几个功能:

  • 组件的默认渲染
  • 传递初始值
  • 事件是否正常触发

测试代码如下:

import Stepper from '../Stepper.vue';

describe('Stepper.vue Unit Test', () => {
  // 定义选择器
  const SELECTOR = {
    counter: '[tid=counter]',
    dec: '[tid=dec-btn]',
    inc: '[tid=inc-btn]',
  }

  it('组件默认渲染', () => {
    cy.mount(Stepper)
    cy.get(SELECTOR.counter).should('have.text', '0')
  })

  it('传递 initial', () => {
    cy.mount(Stepper, {
      props: {
        initial: 10
      }
    })
    cy.get(SELECTOR.counter).should('have.text', '10')
  })

  it('事件测试', () => {
    cy.mount(Stepper, {
      props: {
        initial: 10,
        onChange: cy.spy().as('change')
      },
    })
    cy.get(SELECTOR.inc).click()
    cy.get('@change').should('have.been.calledWith', 11)
    cy.get(SELECTOR.dec).click()
    cy.get('@change').should('have.been.calledWith', 10)
  })
})

我们可以看到 Cypress 给的 API 非常语义化,基本上没有上手成本,步骤一般都是这样:

  • 获取某个 DOM
  • 对 DOM 进行操作
  • 断言

这个步骤和我们之前介绍的测试方法都是一样的。

接着启动 pnpm test:component 就可以看到测试的结果了:

image.png

cy.spy() 在上面案例中用于监听事件,当然还可以监听值的变化,详情请戳

使用 cypress 进行 E2E 测试

上面我们只对组件测试进行的配置,而 E2E 还没有配置,当然这也无需我们手动去配置,点击 CypressSwitch Test Type 之后,点击 E2E Testing 就可以了,操作方式和组件测试差不多这里就不演示了。

为了区分单元测试的文件,我们这里配置 E2E 测试文件的后缀为 .e2e.ts

cypress.config.ts

import { defineConfig } from "cypress";

export default defineConfig({
  component: {
    devServer: {
      framework: "vue",
      bundler: "vite",
    },
  },
+
+  e2e: {
+    setupNodeEvents(on, config) {
+      // implement node event listeners here
+   },
+   specPattern: '**/*.e2e.ts'
+ },
});

然后我们创建一个 index.e2e.ts 的测试文件,意思是针对首页的测试,这个我们团队是放在页面组件的同级,当然这个没有严格的限制,遵循自己的团队规范即可。

我们案例首页比较单调,为了测试方便,我们可以以掘金的登录和搜索功能为例子(免费帮掘金做测试,只写其中的小流程。

登录测试:

  • 进入首页
  • 点击登录
  • 切换密码登录
  • 输入用户名和密码(故意输错)
  • 弹框提示

搜索测试:

  • 进入首页
  • 聚焦搜索框
  • 输入 “不烧油的小火柴”
  • 切换搜索的结果

这里的代码比较简单,我就直接贴代码了:


describe('掘金首页 E2E 测试', () => {
  const JUEJIN_PAGE_URL = {
    index: 'https://juejin.cn/',
  }
  // 定义选择器
  const SELECTOR = {
    loginButton: '.login-button', // 登录/注册按钮
    noteMask: '.flash-note-lead.mask .icon-close', // 闪念笔记 mask
    authForm: '.auth-form', // 登录弹窗
    userNameInput: '[name=loginPhoneOrEmail]', // 用户名输入 input
    passwordInput: '[name=loginPassword]', // 密码输入 input
    loginSubmit: '.panel .btn', // 登录按钮
    errMessage: '.alert-list .alert', // 错误信息
    searchInput: '.search-input',
    navBanner: '.nav-banner .nav-item',  // 导航
    resultList: '.result-list .main-list .item', //搜索的结果
  }

  beforeEach(() => {
    cy.visit(JUEJIN_PAGE_URL.index)
    cy.get(SELECTOR.noteMask).click() // 先关掉 mask
  })


  it('登录功能 - 异常情况', () => {
    // 用户点击右上方登录/注册按钮
    cy.get(SELECTOR.loginButton).click()

    // 用户此时看到登录弹窗
    cy.get(SELECTOR.authForm).should('be.visible')

    // 用户选择其他登录方式
    cy.get(`${SELECTOR.authForm} .clickable`).click()

    // 输入用户名和密码
    cy.get(SELECTOR.userNameInput).focus().type('admin')
    cy.get(SELECTOR.passwordInput).focus().type('admin')

    // 提交
    cy.get(SELECTOR.loginSubmit).click()

    // 报错
    cy.get(SELECTOR.errMessage).should('exist')
    cy.get(SELECTOR.errMessage, { timeout: 3000 }).should('not.exist')
  })

  it('搜索功能测试', () => {
    cy.get(SELECTOR.searchInput).type('不烧油的小火柴').type('{enter}')
    cy.get(SELECTOR.navBanner).last().click()
    cy.get(SELECTOR.resultList).eq(0).should('contain.text', '不烧油的小火柴')
  })
})

我有一个小建议,那就是大家做测试的时候一定要把选择器提取放到前面去,这样下次网站内容变化的时候我们只需要改常量就行了。

我们都有一个通用流程:进入首页,弹出“闪念笔记”的 mask。所以我将这两个步骤放到了 beforeEach 钩子里面,它会在所有测试用例执行之前执行。

下面看一下 Cypress 的执行效果吧(我的鼠标是没动过的):

Kapture 2022-10-03 at 16.07.59.gif

如果我们测试的场景有一个超级大的表单输入,那么 Cypress 就会大大提升测试的效率。

留个小作业吧:在登录的情况下,测试一下关注功能吧,嘿嘿。

如何衡量测试指标

通过上面的案例,我们对单元测试和 E2E 测试的区别和场景有所了解了吧,那在项目里面我该如何选择呢?选择的依据又是什么?如何知道测试的好坏?

测试金字塔

Mike Cohn 在他的著作《Succeeding with Agile》一书中提出了 测试金字塔 的概念,即测试需要分层:

image.png

怎么理解呢?

从图可以看出,底层的单元测试占比越高,越往上的集成测试占比越低,我们应该遵循这个原则。从实际场景出发的话,按照下面的建议去执行:

  • 开发纯函数/hook库(LodashVueUse 这类的),建议写大量的单元测试,少量的集成测试。
  • 开发组件库,建议写大量的组件单元测试,每个组件编写快照测试(测试组件本身功能),少量的集成测试和 E2E(测试用户使用场景)。
  • 开发业务,建议为每个核心模块编写少量的 E2E 测试。

建议归建议,任何事物都没有银弹。

测试覆盖率

如果说测试金字塔可以指导我们去更好的去写测试代码,那么测试覆盖率就是检验最终成果的一个很重要的指标。

测试覆盖率分为代码覆盖率和需求覆盖率。前者好说我们可以通过一些工具(istanbul)去计算代码的执行行数来得出,而后者就比较麻烦了,这个需要人工计算了,或者通过一些标记手段(需求 ID)来计算得出。

很多测试工具都内置代码的覆盖率的支持,比如 Vitest 内部默认用的就是 c8 来统计的,当然也可选择 istanbul,下图就很好的展示了执代码的执行情况:

image.png

当然,如果团队的代码提交是比较严格,可以在 CI 流水线设置门禁,比如单测覆盖率 80% ,用例通过率 100%。但这个世界上没有十全十美的事情,代码覆盖率也是如此,要做到全覆盖还是比较难的,尤其是需求的覆盖率,我们一定要量力而行。

测试模式

目前测试有两种模式,一个是被大家所知的 TDD(测试驱动开发),另一个则是 BDD(行为驱动开发)。

  • TDD(Test-driven development):测试驱动开发,先写测试后实现功能。
  • BDD(Behavior-driven development):行为驱动开发,先实现功能后写测试。

TDD

开发流程

TDD 的核心要点在于在编码之前先编写测试用例,由测试来决定代码,整个流程如下:

  • 编写测试用例
  • 运行测试
  • 编码使测试用例通过
  • 重构/优化代码
  • 新增功能,重复流程

image.png

优点

TDD 的有很多优点:

  • 功能代码未动,测试先行,能够用 “以终为始” 的开发思路来保证代码的质量。
  • 可以促进开发人员去思考模块设计和重构代码。
  • 测试的覆盖率较高,因为编写的代码需要按照测试的用例去跑,基本上每个用例都要考虑到。

缺点

TDD 当然也有缺点比如:

  • 测试代码量增多,比如 Vue 2.x 中 的 keep-alive 源码实现只有 100 多行,而单元测试代码有 800 多行。

image.png

  • 当代码调整时,测试代码也要调整,比如函数加了参数,函数里面加了 if 语句(这说明代码的设计不好)。
  • 最终做出来的东西和实际功能需求可能相偏离。

如果大家对概念性的知识不是很理解,大家可以看看这篇文章如何使用 Cypress 进行 TDD 组件测试,作者很详细地介绍了这种开发模式,并且有相关案例来实践。

BDD

为了解决上面问题,BDD 应用而生,它是测试驱动开发延伸出来的一种敏捷软件开发技术。它的特点是:

  • 解决 TDD 模式下开发和实际功能需求不一致而诞生。
  • 不需要面向细节设计测试,而是面向行为测试。
  • 从产品的角度出发,鼓励开发与非开发人员之间的协作。
  • 注重功能测试,所以 BDD 更多结合的是集成测试,是黑盒的。

开发流程

BDD 的开发流程大致为:

  • 需求确认(一般是从 PM 那获取需求)。
  • 以自动化的方式将需求建立起来(比如将需求录入某个迭代系统)。
  • 实现每个文档示例描述的行为,并从自动化测试开始以指导代码的开发。

BDD 的代表目前最流行的是Cucumber,它使用描述性语言定义所有人员能看懂的语法,这种语法叫做 Gherkin Syntax,小黄瓜语法。下面是一个示例:

Feature: 添加任务

  Scenario: 在输入框中输入任务名敲回车确定,输出到任务列表中
    Given "Hello World"
    When 在输入框中敲回车
    Then 任务列表增加一个名称为 "Hello World" 的任务

  Scenario: 在输入框中输入空内容,不输出到任务列表中
    Given ""
    When 在输入框中敲回车
    Then 任务列表中不增加任何内容

之后的步骤就跟 TDD 很类似了,开发人员根据这个来编写测试用例,再开发源代码,新增功能再重复上述流程。

优点

  • 由于侧重于需求功能的完整度,所以能给开发人员增加更多对程序的信心。
  • 由于仅关注功能,不关注实现细节,有利于测试代码和实际代码解耦。
  • 由于大多数为编写集成测试,相比 TDD 有更好的开发效率。

缺点

  • 以功能性的集成测试为主,因此不是那么关注每个函数功能,测试覆盖率比较低。
  • 没有 TDD 那么严格的保证代码质量。

如何权衡

这两种模式都有优缺点,那我们该如何选择呢?

  • TDD 这种开发模式更加注重代码的质量、更高的测试覆盖率,如果开发功能函数库、框架类(LodashVueReact)就很适合用这种模式。
  • BDD 更加关注需求的完成度,不关心代码实现的细节,对于业务系统来说这是很好的一种模式。

当然也没有绝对,其实这两种测试模式可以并行,我们在开发业务系统的时候难免要自己封装一些 hook、组件、工具函数,那么我们可以使用 TDD 来做(借助 Vitest 或者 Cypress 的单元测试功能),开发业务代码的时候使用 BDD 来做(借助 CypressE2E 测试功能 )。

不管怎么样,一切都是为了高质量和高效率,不能为了某种模式或工具去死搬硬套,假如你的开发周期很短、业务繁忙,你强上这套工具和模式,你觉得你的老板会认可吗?

总结

本篇文章是笔者在实践了一段时间自动化测试之后的总结,主要讲述了自动化测试出现的原因,以及自动化测试一些流行的工具库,但是衡量测试的指标也是很重要的,所以也简单介绍了一下 “测试金字塔” 和测试覆盖率的概念,它们是衡量代码的最后一道关卡。最后介绍了 TDDBDD 的概念和优缺点,两种测试模式都值得学习。但是记住:任何工具或模式的出现一定是解决某个问题出现的,任何的解决方案都不是银弹! 下期文章会结合前端模块设计与 TDD 来分享一个真实场景,帮助大家更好去践行这种开发模式。

希望这篇文章对大家有所收获,笔者初入茅庐,文章难免有些瑕疵,恳请大家指正!

参考