【第八期】使用 Cypress 辅助前端 TDD 研发入门

3,814 阅读16分钟

我们来看一则小故事:

一个初创公司,在早期,所有的业务界面都在一个前端工程项目中,工程里有五个界面,一个研发加上一个测试,就能保证每次发布的新功能稳定可靠。

随着公司业务蓬勃发展,当年的初创公司慢慢变得越来越壮大,业务界面变得越来越多,原来的那个前端工程项目从只有五个界面,增长到现在的几十个界面。

并且,业务线也变多了。通过对业务线的划分,甚至同一个业务线内,不同类型的模块划分,公司的业务界面总量达到上千个,分别存放在几十个前端工程项目中。

相对的,前端团队的研发人员和测试人员的数量也由原来的两个人,增长到现在的几十人。

这时,团队遭遇了一个问题:每次发布上线的功能,变得不再稳定可靠,经常出现各种各样的问题

这个状态一直在持续,而公司也不得不持续不断得为这些问题造成的影响买单。

公司开始着手调查这个问题的本质原因。公司派出了两位调查员:小A 和 小B。

小A认为人才资源还是不足,需要招聘更多的人,让每个前端业务项目都能有足够的人来维护,借鉴早期的团队结构,也就是达到每个工程五个界面,一个研发和一个测试的人员规模。

小B认为现有前端团队的工作方式需要调整,以适应大规模的前端工程维护与建设。并给出了两个实际案例:

案例一:某个前端工程内有一百多个界面,最近用户反馈某个界面打开后没有内容。反馈信息到达研发团队,研发人员调查发现在几个月之前这个界面被其它界面的修改影响了,导致此问题。当时测试人员并没有发现这个问题,因为一方面测试人员并没有这个模块所有界面的名单;另一方面当时修改的是另外的界面,测试人员将精力放在那些被修改的界面上了。最后,研发人员修复了这个界面,并交给测试人员进行测试,测试人员测试通过后发布上线,修复了这个问题。

案例二:某个前端工程最近没有发布动作,但某天突然收到用户反馈:创建订单时,提示订单创建失败。反馈信息到达研发团队,研发人员调查发现最近安全策略模块上线更新了某条安全策略,新的安全策略规则过于宽泛,阻止了这位反馈用户的订单内容。一方面研发人员引导用户修复了非法的订单内容;另一方面,研发人员调整了导致问题的安全策略规则,最终解决了这个问题。

在参考了两位调查员的报告后,团队做出了如下调整:

在测试环境增加自动测试,自动测试分为两个类型:冒烟测试(探测界面是否有内容) 和 功能测试(探测功能是否畅通)

自动测试的时间设定为:上午10点 和 晚上6点

测试的结果以交通灯的方式展示,并挂钩到发布平台:红色灯代表测试失败、黄色灯代表测试未完成、绿色灯代表测试通过

发布平台只允许绿色灯的工程项目发布上线

这则小故事结束了,我们来回顾这则故事中出现的两个问题:

  • 随着工程模块内的复杂度不断增加,靠人的精力来覆盖工程内的方方面面,已不切实际。
  • 随着不同工程模块的规模不断壮大,靠人的精力来控制不同工程之间的关系和影响,已不切实际。

专业的工程师都明白一个道理:人是很容易犯错的,一个功能,靠人自觉或小心谨慎地来维持,无异于作茧自缚。

测试 是一个很大的话题,我们今天来聊一下 测试驱动开发

说起 测试驱动开发 (Test Driven Development, TDD),相信很多读者都并不陌生。

测试驱动开发(英语:Test-driven development,缩写为TDD)是一种软件开发过程中的应用方法,由极限编程中倡导,以其倡导先写测试程序,然后编码实现其功能得名。测试驱动开发始于20世纪90年代。测试驱动开发的目的是取得快速反馈并使用“illustrate the main line”方法来构建程序。

测试驱动开发是戴两顶帽子思考的开发方式:先戴上实现功能的帽子,在测试的辅助下,快速实现其功能;再戴上重构的帽子,在测试的保护下,通过去除冗余的代码,提高代码质量。测试驱动着整个开发过程:首先,驱动代码的设计和功能的实现;其后,驱动代码的再设计和重构。

--- 维基百科

要说在实际工作中使用 TDD 的方式进行开发,可能只有少数人会这么做。

因为,考虑到有限的、甚至急迫的业务研发周期,相信很多读者都会感到担忧:测试会占用开发周期,不如直接开发功能更 “省” 时间。

如果我们用更长远的视角来看研发这件事情,就会发现,实际上对于直接开发功能的方式,只是将软件工程的时间提前消费了,后期还是得将前期 “省” 去的那部分时间还回来,甚至还要支付昂贵的利息(当然了,重视短期利益的人并不会想那么远)。

测试的重要性不言而喻,我们发现,将测试放在软件工程的越靠前的环节中,它就越能帮助到工程本身,因为:

  1. 测试能在代码投入生产之前,发现其中潜在的问题,避免损失
  2. 测试能促使代码变得更加健壮,提高可维护性,从而降低成本
  3. 测试能发现设计的不良之处,促进改良设计,从而提高服务质量

今天,我们来学习一个叫做 Cypress 的测试工具,使用这个工具,来帮助我们进行前端功能研发。

我们按照下面的步骤逐步讲解:

  1. 什么是 Cypress
  2. 如何安装 Cypress
  3. 开发前:如何写基于 Cypress 的测试文件
  4. 开发中:如何使用 Cypress 测试文件辅助我们进行测试
  5. 如何更方便得写 Cypress 测试文件

1. 什么是 Cypress

cypress 是一个完整的,易用的测试框架

我们可以使用 Cypress 进行: e2e测试、集成测试、单元测试

Cypress 官网介绍了以下功能特点:

  • 时间旅行:Cypress 会在测试运行是拍摄快照,只需将鼠标悬停在命令日志中的命令上,即可确切了解每个步骤发生的情况。
  • 可调式:我们无需猜测测试用例为何失败,直接从熟悉的工具进行调试(例如:谷歌浏览器的开发者工具),可读错误和堆栈跟踪让调试更有效率。
  • 自动等待:再也无需在测试用例代码中添加 waitsleep 代码,Cypress 会自动等待命令和断言完成。
  • 函数间谍、响应劫持、时钟回拨:验证和控制函数、服务器响应和时钟。常用的单元测试功能,cypress 都已提供于指尖。
  • 网络通信控制:无需涉及服务器即可控制、保存和测试边缘情况。你可以根据需要保留网络流量。
  • 一致的结果:Cypress 的架构不使用 SeleniumWebDriver。让测试更快速,一致和可靠。
  • 视图快照和视频:从命令行运行测试时,我们可以查看失败用例的视图快照和整个测试过程的视频。

除了以上这些功能外,Cypress 还有如下不足之处:

  • 不擅长浏览器兼容性测试
  • 不擅长微信、微博等 Oauth2.0 授权登录测试
  • 只能测试 web 页面(不能测试小程序)
  • 尺寸较大,下载安装时间较长

2. 如何安装 Cypress

2.1 下载

因为 Cypress 的体积相对较大(接近 150mb),所以我们接下来通过浏览器下载安装 Cypress 的过程。

您也可以通过 npmyarn 来安装 Cypress

我们打开 Cypress 官网,点击官网首页的 Download Now 链接开始下载 Cypress

下载完成后,我们得到一个名为 cypress.zip 的压缩包文件。

解压缩后,我们会得到一个名为 Cypress 的可执行文件。

2.2 创建工作区

我们在 home 目录下创建一个名为 cypress_demo 的空文件夹(用于存放 cypress 测试文件),作为 Cypress 的工作区。

mkdir $HOME/cypress_demo

我们打开 Cypress 执行文件,会看到如下界面:

点击 select manually,手动找到并选择我们刚才创建的工作区文件夹 $HOME/cypress_demo,然后点击 打开

这时,我们会看到 Cypress 自动为我们在 cypress_demo 文件夹中生成了一个叫做 cypress 的文件夹和一个叫做 cypress.json 的配置文件。

cypress 文件夹中,有 4 个子文件夹,分别是:

  • fixtures 存放一些测试用例中需要用到的静态资源,比如:数据库模拟数据(json格式)、图片等信息
  • integration 存放 Cypress 测试文件
  • plugins 存放 Cypress 插件
  • support 存放 Cypress 自定义命令

其中,在 integration 文件夹中,Cypress 还为我们生成了一些测试样例文件,方便我们参考和学习。

我们在 Cypress 的运行界面中可以看到这些样例文件:

完整目录结构如下:

cypress_demo
├── cypress # Cypress 工作目录
│   ├── fixtures # 存放一些测试用例中需要用到的资源,比如:数据库模拟数据、图片、json信息等等
│   │   └── example.json
│   ├── integration # 存放 Cypress 测试文件
│   │   └── examples # 这个文件夹中存放了 Cypress 官方提供的一些测试样例
│   │       ├── actions.spec.js
│   │       ├── aliasing.spec.js
│   │       ├── assertions.spec.js
│   │       ├── connectors.spec.js
│   │       ├── cookies.spec.js
│   │       ├── cypress_api.spec.js
│   │       ├── files.spec.js
│   │       ├── local_storage.spec.js
│   │       ├── location.spec.js
│   │       ├── misc.spec.js
│   │       ├── navigation.spec.js
│   │       ├── network_requests.spec.js
│   │       ├── querying.spec.js
│   │       ├── spies_stubs_clocks.spec.js
│   │       ├── traversal.spec.js
│   │       ├── utilities.spec.js
│   │       ├── viewport.spec.js
│   │       ├── waiting.spec.js
│   │       └── window.spec.js
│   ├── plugins # 存放 Cypress 插件
│   │   └── index.js
│   └── support # 存放 Cypress 自定义命令
│       ├── commands.js
│       └── index.js
└── cypress.json # Cypress 配置文件

2.3 配置 Cypress

接下来,我们来配置 Cypress,根据上一小节的内容,我们知道,配置 Cypress,需要通过 cypress.json 这个文件。

那么我们具体能在里面做哪些配置呢?完整的配置内容,请参考官网 配置指南

这里,我们先关注下面两个配置项:

  1. chromeWebSecurity
  2. userAgent

2.3.1 chromeWebSecurity

chromeWebSecurity 决定是否开启 chrome 浏览器针对同源策略和不安全的混合内容的安全策略。

它默认是开启状态

实际上,这给我们的测试文件内容带来了一些限制,完整的限制名单请参考官网 Web安全限制

这里举个例子,我们在同一个测试用例中分别访问一个主域下的资源,Cypress 允许我们这么做:

cy.visit('https://www.cypress.io')
cy.visit('https://docs.cypress.io') // yup all good

可是,一旦我们在同一个测试用例中,分别访问不同主域下的资源,Cypress 默认会阻止我们:

cy.visit('https://apple.com')
cy.visit('https://google.com')      // this will immediately error

为了接近实际的测试场景,我们在 cypress.json 中将它关闭,以方便我们接下来的测试工作:

{
  "chromeWebSecurity": false
}

注意即使我们将 chromeWebSecurity 关闭,Cypress 也依然不允许在同一个测试用例中使用 cy.visit 访问两个不同的顶级域名,详情请参考 #944

2.3.2 userAgent

userAgent 决定 Cypress 访问任何网络资源时,来自哪个操作系统、哪个浏览器、浏览器版本。

这里假设我们平时使用微信开发者工具来开发微信内 h5 页面应用,所以我们将 userAgent 的值设置为微信开发者工具:

{
  "chromeWebSecurity": false,
  "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1 wechatdevtools/1.02.1904090 MicroMessenger/6.5.7 Language/zh_CN webview/15602362378809380 webdebugger port/39554"
}

至此,Cypress 的环境配置就完成了。

3. 开发前:如何写基于 Cypress 的测试文件

每个测试文件,都遵循一个模式,大致如下:

  • 代表一个测试组的名称和作用域,在 Cypress 中,它叫 describe
  • 代表整个测试组执行前,进行初始化工作的作用域,在 Cypress 中,它叫 before
  • 代表整个测试组执行后,进行收尾清理工作的作用域,在 Cypress 中,它叫 after
  • 代表每个测试用例执行前,进行处理工作的作用域,在 Cypress 中,它叫 beforeEach
  • 代表每个测试用例执行后,进行处理工作的作用域,在 Cypress 中,它叫 afterEach
  • 代表一个测试用例的名称和作用域,在 Cypress 中,它叫 it

以上这些抽象概念,在 Cypress 中以如下形式展示:

describe('测试组名称', () => {
 
  before(() => {
    console.log(' --- 在当前 describe 中所有 it 执行前,运行一次 --- ')
  })
 
  after(() => {
    console.log(' --- 在当前 describe 中所有 it 执行后,运行一次 --- ')
  })
 
  beforeEach(() => {
    console.log(' --- 在当前 describe 中每个 it 执行前,运行一次 --- ')
  })
 
  afterEach(() => {
    console.log(' --- 在当前 describe 中每个 it 执行后,运行一次 --- ')
  })
 
  it('测试用例1', () => {
    // 在这里写测试逻辑...
  })
 
  it('测试用例2', () => {
    // 在这里写测试逻辑...
  })
 
})

在开始学习写测试用例的内容之前,我们先思考一下:

开发一个简单的登陆页面,我们平时都是如何测试这个页面的呢?

假设:

  • 登录页面的地址为: http://www.demo.com/login
  • 登录成功后,会跳转到地址:http://www.demo.com/main
  • 用户名为:demo
  • 密码为: password
  • 用户名的表单 id 为: user
  • 密码的表单 id 为: pwd
  • 提交按钮的 id 为: submit

我们平时会手动执行的测试步骤:

  • 第一步:在浏览器地址栏输入登陆页面的地址
  • 第二步:在登陆页面中,填写用户名和密码,然后点击登录
  • 第三步:检查登录后的状态,看是否登录成功

将以上步骤转换为 Cypress 能理解的语言,如下:

describe('登录测试', function () {
  it('成功的 case', function() {
    // 打开登录页面
    cy.visit('http://www.demo.com/login')
    // 输入用户名
    cy.get('#user').type('demo')
    // 输入密码
    cy.get('#pwd').type('password')
    // 点击提交按钮
    cy.get('#submit').click()
    // 判断是否登录成功
    cy.url().should('include', '/main')
  })
})

Cypress 中:

  • 访问一个网络资源,使用 cy.visit
  • 获取网页上某个元素,使用 cy.get
  • 向某个表单元素输入内容,使用 cy.type
  • 在某个元素上触发点击事件,使用 cy.click
  • 获取当前 url 地址,使用 cy.url
  • 创建一个断言,使用 cy.should

4. 开发中:如何使用 Cypress 测试文件辅助我们进行测试

我们在文件夹 cypress_demo/cypress/integration/ 下新建一个名为 demo.js 的文件,将上一小节的代码拷贝进去,然后保存。

接着,让我们回到 2.2 节中打开的 Cypress 执行文件界面(如果您已经关闭了这个界面,只需要重新打开 Cypress 执行文件即可)。

可以看到我们刚才新建的 demo.js 文件了:

点击它,会开始运行测试文件

因为我们还没有开始写这个登录页面,所以运行会出错。

注意

请在本地 host 文件中将 www.demo.com 指向您的开发机器 ip,或使用您自己的域名;

我们在本篇文章中使用本地 ip: 127.0.0.1www.demo.com 域名

接下来,让我们完成登录页的开发工作,这些工作不在本篇文章的范围内。

在我们完成登录页开发工作后,点击 Cypress 界面的刷新按钮,再次运行测试文件,可以看到,测试已经通过了:

5. 如何更方便得写 Cypress 测试文件

在第 4 节中的例子,实际上是非常简单的

我们实际开发中,可能需要直接测试某个受限资源(需要登录后才能访问),假设我们的登录信息都存储在 cookie 中。

对于表单,除了简单的文字输入,我们也可能需要上传图片(比如:上传头像),假设我们要上传的图片名称为 f.png,我们需要提前将其拷贝到文件夹 cypress_demo/cypress/fixtures/ 中。

甚至我们可能需要为某个前端工程内所有的页面写冒烟测试(smoke testing)。

Cypress 中,以上这些通用的功能,都可以通过自定义命令来方便得使用:

cypress_demo
├── cypress
│   └── support
│       ├── commands.js # 在这个文件里手动添加下面的命令内容

我们在 commands.js 中添加我们需要的命令:

// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
 
Cypress.Commands.add('uploadImage', (fileName, fileType = ' ', selector) => {
  cy.get(selector).then(subject => {
    cy.fixture(fileName, 'base64')
      .then(Cypress.Blob.base64StringToBlob)
      .then(blob => {
        const el = subject[0]
        const testFile = new File([blob], fileName, { type: fileType })
        const dataTransfer = new DataTransfer()
        dataTransfer.items.add(testFile)
        el.files = dataTransfer.files
        subject.trigger('change')
      })
  })
})
 
Cypress.Commands.add('login', (metaData) => {
  Object.keys(metaData).forEach(key => {
    cy.setCookie(key, JSON.stringify(metaData[key]))
  })
})

Cypress.Commands.add('smoke', selector => {
  cy.get(selector).then($app => {
    let text = String.prototype.trim.call($app.text())
    if (text.length) {
      // 冒烟测试的标准为: 页面是否有文本内容
      expect(text.length).to.be.gt(0)
    } else {
      // 或者是否有图片
      expect($app.find('img').length).to.be.gt(0)
    }
  })
})

然后,我们就可以在测试文件中使用这些命令了:

describe('测试组标题', () => {
 
  before(() => {
    cy.login({token:'您的token', refreshToken: '您的refreshToken'})
  })
 
  it('测试登录后才能访问的资源', () => {
    // 在这里写您的测试逻辑
  })

  it('测试上传图片', () => {
    cy.visit('https://www.yourdomain.com/page2')

    const fileName = 'f.png'
    const fileType = 'image/png'
    const uploadFileSelector = 'input[type=file]'

    cy.uploadImage(fileName, fileType, uploadFileSelector)
    
    ...

  })
 
  it('冒烟测试', () => {
    let pages = [
      'https://www.yourdomain.com/page1',
      'https://www.yourdomain.com/page2',
      'https://www.yourdomain.com/page2'
    ]

    pages.forEach(page => {
      cy.visit(page)
      cy.wait(1000)
      cy.smoke('body div[id]')
    })
  })
 
})

除了自定义命令外,Cypress 还支持请求拦截、截图快照和视频,另外,我们还可以通过插件来扩展 Cypress

以上这些功能,本篇文章不再展开,感兴趣的读者可以阅读下面列出的文章。

关于请求拦截,请参考官网 请求拦截器

关于截图快照和视频,请参考官网 截图快照和视频

关于插件,请参考官网 如何写插件

最后,让我们思考下面几个问题:

  • Cypress 的测试文件,可以反复使用么?
  • 可以定时运行 Cypress 的测试文件么?
  • 我们如何利用 Cypress 让我们的工作变得更加轻松?

感谢您花时间阅读这篇文章,希望这篇文章能对您有所帮助。


水滴前端团队招募伙伴,欢迎投递简历到邮箱:fed@shuidihuzhu.com