@duanyubin
2018-08-29T10:10:29.000000Z
字数 6919
阅读 1600
测试
你是否遇到如下问题:
1. 担心一个页面改动会导致另一个页面挂掉
2. 一直想重构但是没有时间测试,不想一遍遍的走测试用例
3. 改动了多个页面,上线前却又懒得挨个的回归测试,结果上线果然出问题了
4. 想写测试,却又不知如何下手,担心成本大 效果差
如果你遇到上述问题,那就该用cypress搭建一套前端自动化测试流程了。
前端的测试一直都是较为麻烦的,本文探讨如何以最小的成本实现最佳的效果。
不覆盖兼容性测试:由于cypress运行在puppeteer之上,所以不能验证兼容性。
官网https://www.cypress.io,号称Test your code, not your patience.在构建、编写、运行、调试测试等方面都较为简单,提供了等待请求完成、自动化执行输入点击等操作,对每一步操作都可回退查看效果。
vue-cli 3.0也使用了cypress作为测试框架之一。
常见的界面是这样的
左边是测试用例执行,点击每一步可查看当时的截图。
右边是页面内容,可自动执行,输入内容点击按钮。
以下将使用cypress来作为基础工具。
npm i cypress -D
npx run cypress open # 打开GUI界面,能看到测试的每一步执行
npx run cypress run # 命令行执行,有错误会失败,产出截图和视频
安装完毕后项目目录如下
project
|-- 其他文件夹
|-- cypress.json # cypress 配置文件
|-- cypress
|-- fixtures # 放置止mock数据,可以包含js json png等
|-- plugins # 插件,具体的看这里https://on.cypress.io/plugins-guide
|-- integration # 所有的测试用例存放位置,最近常操作的文件夹
|-- screenshots # 错误时的截图
|-- supports
|-- commands.js # 放置自定义指令
最常见也最有效的测试方式。
渲染页面整体,通过测试用例保证DOM结构的正确及部分CSS属性的正确(如display:none),通过肉眼判断布局样式是否正确
一般的流程代码为:
describe('My First Test', function() {
it("Gets, types and asserts", function() {
// 1. 访问页面
cy.visit('https://example.cypress.io')
// 2. 选择dom,并点击
cy.contains('type').click()
// 3. 断言
// 跳转到一个包含'/commands/actions'的url
cy.url().should('include', '/commands/actions')
// 4. 继续添加断言
// 获取input,输入内容并断言input的value
cy.get('.action-email')
.type('fake@email.com')
.should('have.value', 'fake@email.com')
// 5. 可以使用暂停
cy.pause()
// 6. 使用debug
cy.debug()
})
})
通过上述测试用例可以看到,用例没有类似与sleep或waiting的代码,不需要等待操作或 请求的完成,这样大大简化了编码的复杂度。
原理是cypress内部有个循环机制,如果超过默认时间(5s)还没有找到对应的元素,则提示失败。
举例说明:
所有的数据都是异步ajax获取,需要测试的功能点如下:
beforeEach(() => {
cy.visit('http://localhost:2999')
})
it('渲染基础', () => {
// 1. 顶部的品牌和车系列表渲染正确
cy.get('.brand-list').find('li').should('have.length', 4)
cy.get('.series-list').find('li').should('have.length', 6)
// 2. 所有品牌列表
cy.get('.g-list').find('.item').should('have.length.above', 20)
})
it('图片懒加载', () => {
// 3. 图片懒加载,找到可视区域中的一张图,确保已经存在img标签
cy.get('.series-list')
.find('img').eq(0)
.should('have.attr', 'src')
// 且具有loading-end类
cy.get('.series-list')
.find('.img').eq(0)
.should('have.class', 'loading-end')
cy.get('.js-brand-item').find('.img:first img').should('have.prop', 'src')
// 找到列表中第31个元素,如果正确渲染,它的高度肯定在首屏之外
// 设置别名为hiddenImg,此时应该不存在img标签
cy.get('.js-brand-item .img').eq(30).as('hiddenImg')
cy.get('@hiddenImg').find('img').should('not.exist')
// 滚动页面到此元素
cy.get('@hiddenImg').scrollIntoView()
// 此时应该存在img标签,则表明图片加载完毕
.find('img').should('exist')
})
it('点击搜索跳转', () => {
cy.get('.js-redirect.input').click()
// 4. 要跳转的URL包含search/index,注意跳转路径的测试尽量单独放到一个用例里
cy.url().should('include', '/search/index')
})
it('点击或滑动字母表', () => {
cy.get('.js-category').find('span').should('have.length.above', 10)
// 5. 出发touchstart事件
cy.contains('.js-category span', 'D').trigger('touchstart')
cy.contains('.js-list .title .inner', 'D').then(($element) => {
// 注意通过then方法获取真实的jquery实例
expect($element[0].getBoundingClientRect().y).to.be.lt(4)
})
})
it('点击弹出汽车列表', () => {
cy.get('.js-brand-item:first').click()
// 点击弹出车系列表,visible要求必须在页面可是范围内且不能是display:none的元素。
cy.get('.g-brand-detail').should('be.visible')
// 延迟1s后,关闭弹窗
cy.get('.g-brand-detail .mask').wait(1000).click({ force: true })
cy.get('.g-brand-detail').should('not.be.visible')
})
除了常规的e2e测试,单元测试也很重要,尤其是对于一些utils类的工具方法。
单元测试一般的粒度是函数,在函数实现过程中,尽量减少副作用,可简化测试。
但前端经常依赖一些DOM API,常规的单元测试很难编写用例,但是通过cypress就很简单,请看例子:
// 仿zepto的选择器,但返回的是NodeList
export const $ = document.querySelector.bind(document)
export const $$ = selector => Array.prototype.slice.call(document.querySelectorAll(selector))
// 测试用例
it('正常的object', () => {
cy.server()
cy.route('/index.html', `
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</ul>
`)
expect($$('li')).to.be.eq(4)
})
这一步的要求是尽量使用本地测试时的用例,一般是要更改baseUrl和访问的页面地址
配置如下:
// package.json
// 更改baseUrl,且增加环境变量 prod=1
"cypress:online": "CYPRESS_BASE_URL=https://m.dcdapp.com npx cypress open --env prod=1",
// 测试用例中
if (Cypress.env('prod') === 1) {
// 线上地址
cy.visit('/motor/static/brand', { onBeforeLoad })
} else {
// 本地地址
cy.visit('/frontend-test.html', { onBeforeLoad })
}
这样执行npm run cypress:online
可对线上的页面进行测试。
小流量的话需要配合charles,手动的配置代理页面地址到小流量机器的IP,目前没有自动配置的方案。
假设业务有一段代码如下
function fetchData() {
return window.fetch('/fake/data')
.then(res => res.json())
.then((result) => {
if (!result || !result.key) {
return '数据为空'
}
return result
})
.catch(() => '数据异常')
}
很简单的一段获取请求,但其中包含了异常的处理,在编写用例时,需要尽量考虑到这部分的代码。
用例实现如下:
// 先来一段正常逻辑的测试,可以使用mock数据
it('模拟正常数据返回', () => {
cy.server(
// 使用**route**方法,可拦截请求,使用本地数据
cy.route('/fake/data', 'fx:fake.json').as('fetch')
cy.get('.js-button-2.normal').click()
cy.wait('@fetch')
cy.get('#fake')
.should('contain', 'key')
.and('contain', 'value')
})
// 接口返回为空
it('模拟返回数据为空', () => {
cy.server()
cy.route('/fake/data', '{}').as('fetch')
cy.get('.js-button-2.empty').click()
cy.wait('@fetch')
cy.get('#fake')
.should('contain', '数据为空')
})
// 接口错误500
it('模拟接口错误', () => {
cy.server()
cy.route({
url: '/fake/data',
status: 500
}).as('fetch')
cy.get('.js-button-2.error').click()
cy.wait('@fetch')
cy.get('#fake')
.should('contain', '数据异常')
})
业务代码如下:
$('.js-delegate').addEventListener(
'click',
(e) => {
if (e.target.classList.contains('js-button-1')) {
// 执行完jsbridge之后,还有后续的操作
window.ToutiaoJSBridge.call('appInfo', {}, (appInfo) => {
$('#deviceid').textContent = JSON.stringify(appInfo)
})
}
}
}
用例:
it('模拟端的能力', () => {
// 拿到window,注意要在then方法的回调里
cy.window().then((win) => {
// 通过stub方法,代理jsbridge,内容可自定义
cy.stub(win.ToutiaoJSBridge, 'call', (method, _, func) => {
if (method === 'appInfo') {
func({ deviceId: 'deviceid' })
}
})
})
cy.get('.js-button-1').click()
cy.get('#deviceid').should('contain', 'deviceid')
})
before(() => {
// 使用unfetch,强行将window.fetch改成xhr实现
const polyfillUrl = 'http://sf1-ttcdn-tos.pstatp.com/obj/ttfe/motor/lib/unfetch.umd.js'
cy.request(polyfillUrl)
.then((response) => {
polyfill = response.body
})
})
beforeEach(() => {
const onBeforeLoad = (win) => {
/* eslint-disable no-param-reassign */
delete win.fetch
// 执行polyfill代码
win.eval(polyfill)
// 直接替换为xhr版本
win.fetch = win.unfetch
/* eslint-enable */
}
cy.visit('/frontend-test.html', { onBeforeLoad })
})
当然是不需要,想登陆这种操作,经常被其他操作所依赖,可以将登陆封装为custom command
// cypress/support/commands.js
Cypress.Commands.add('login', (userType, options = {}) => {
// 跳过UI操作,直接发送登陆请求
const types = {
admin: {
name: 'Jane Lane',
password: '123',
admin: true,
},
user: {
name: 'Jim Bob',
password: '123',
admin: false,
}
}
const user = types[userType]
cy.request({
url: '/login',
method: 'POST',
body: {
name: user.name,
password: user.password,
}
})
})
// 使用
cy.login()
// 会报错
const ele = cy.get('#element')
if (ele) {
dosomething()
}
// 这样才是正确的
cy.get('#element').then((ele) => {
dosomething(ele)
})
官方的视频很好的介绍了cypress的特点
https://youtu.be/5XQOK0v_YRE
DOM操作: https://docs.cypress.io/guides/core-concepts/interacting-with-elements.html
API文档: https://docs.cypress.io/api/introduction/api.html
网络相关:https://docs.cypress.io/guides/guides/network-requests.html
断言写法:https://docs.cypress.io/guides/references/assertions.html
使用stub、spy、clock进行单元测试:https://docs.cypress.io/guides/guides/stubs-spies-and-clocks.html