Cypress介绍
Cypress是基于JavaScript语言的前端自动化测试工具,无需借助外部工具,自集成了一套完整的端到端测试方法,可以对浏览器中运行的所有内容进行快速、简单、可靠的测试,并且可以进行接口测试
Cypress特点
- 时间穿梭:Cypress会在测试运行时拍摄快照。只需将鼠标悬停在命令日志上,即可清楚了解每一步都发生了什么
- 可调试性:无需揣测测试失败原因。直接使用浏览器的DevTools进行调试。清晰的错误原因和堆栈跟踪让调试能够更加快速便捷
- 实时重载:每次对测试进行更改,Cypress都会实时执行新的命令,自动重新加载页面进行测试
- 自动等待:无需在测试中添加等待。在执行下一条命令或断言前Cypress会自动等待元素加载完成,异步操作不再是问题
- 间谍,存根和时钟:Cypress允许验证并控制函数行为,Mock服务器响应或更改系统时间,更便于进行单元测试
- 网络流量控制:Cypress可以Mock服务器返回结果,无须连接后端服务器即可实现轻松控制,模拟网络请求
- 运行结果一致性:Cypress架构不使用Selenium或Webdriver,在运行速度、可靠性、测试结果一致性上均有良好的保障
- 截图和视频:Cypress在测试运行失败时自动截图,在使用命令运行时录制整个测试套件的视频,轻松掌握测试运行情况
Cypress运行原理
Cypress测试代码和被测程序都运行在由Cypress全权控制的浏览器中,它们是运行在同一个域下的不同框架内,所以Cypress的测试代码可以直接操作DOM,也正如此Cypress相对于其它测试工具可以运行的更快,在开始执行Cypress脚本后它会自动运行浏览器,并将编写的代码注入到一个空白页,然后在浏览器中运行代码
在进行接口或数据库测试时,需要向服务端发送请求,此请求由Cypress生成,发送给Node.js Process,由Node.js转发给服务端,因此Cypress不仅可以修改进出浏览器的所有内容,还可以更改可能影响自动化操作浏览器的代码,所以Cypress能够从根本上控制自动化测试的流程,提高了稳定性,使得到测试结果更加可靠,如下图所示
Cypress安装
-
Cypress运行需要依赖Nodejs环境,Node.js安装很简单,官方下载安装即可,建议下载安装长期维护版(LTS) -
创建项目保存目录,示例目录是D:\Code\Cypress_test\UItest -
进入项目目录打开cmd命令行窗口,执行命令npm init -y 进行初始化操作,初始化后项目文件中会出现package.json 文件,此命令会让自定义名称、版本等信息,加上-y 参数是使用默认值,后续可在文件中修改 -
安装Cypress,此处临时使用了淘宝npm源,推荐使用,官方的下载太慢啦 npm install cypress --save-dev --registry=https://registry.npmmirror.com
也可以直接修改默认的npm源,修改命令如下 npm config set registry https://registry.npmjs.org
npm get registry
-
运行Cypress,每次运行都要在项目所在目录执行命令,运行命令npx cypress open ,运行成功会出现Cypress窗口 -
使用IDE工具打开项目目录,默认测试用例是在cypress/integration 下编写,其中的两个示例文件前期不建议删除,供学习使用
Cypress使用
Web页面测试
元素定位方法
Cypress更推荐使用Cypress专有选择器,更稳定,但是需要前端代码支持,尽管id、name、class等方法都是Cypress不推荐的,但目前元素定位还是依它们方式为主
cy.get("[data-cy=submit]").click()
cy.get("[data-test=submit]").click()
cy.get("[data-testid=submit]").click()
cy.contains("Submit").click()
cy.find("Submit").click()
cy.get("[name=submission]").click()
cy.get("#main").click()
cy.get(".btn.btn-large").click()
cy.get("button").click()
cy.get("button[id=\"main\"]").click()
cy.get("[data-row-key]>:nth-child(9)>:nth-child(5)").click()
还可以通过辅助方法定位元素,如下
cy.get(".btn-large").first()
cy.get(".btn-large").last()
cy.get(".btn-large").children()
cy.get(".btn-large").parents()
cy.get(".btn-large").parent()
cy.get(".btn-large").siblings()
cy.get(".btn-large").next()
cy.get(".btn-large").nextAll()
cy.get(".btn-large").nextUntil()
cy.get(".btn-large").prev()
cy.get(".btn-large").prevAll()
cy.get(".btn-large").prevUntil()
cy.get(".btn-large").each()
也可以在Cypress运行的浏览器窗口定位元素,可以做参考,不推荐直接复制定位信息
元素常用操作
更多操作命令及使用方法查看官方介绍吧
cy.screenshot()
cy.viewport(550, 750)
cy.visit("https://www.baidu.com/")
cy.visit("https://www.baidu.com/").reload()
cy.go("back").go("forward")
cy.get("[type=\"text\"]").type("JavaScript")
cy.get("[type=\"text\"]").type("123{enter}")
cy.get("[type=\"text\"]").clear()
cy.get("button").click()
cy.get("button").dbclick()
cy.get("[type="checkbox"]").check()
cy.get("[type="checkbox"]").uncheck()
cy.get("[type="radio"]").first().check()
cy.get("[type="radio"]").check("CN")
cy.get("#saveUserName").check()
cy.get("select").select("下拉选项的值")
cy.get("select").select(["value1","value2"])
cy.get("title").should("have.text","Halo").and("contain","仪表盘")
cy.get("title").then(($title)=> {
let Txt = $title.text()
cy.log(Txt)})
示例演示
新建一个js文件,编写一个简单的登录脚本,然后打开Cypress窗口,点击文件名就开始自动运行浏览器并进行测试啦,脚本每次修改都会自动运行,若不想运行某个用例,可以使用it.skip() 表示,只想运行某条用例则使用it.only() 表示
it("输入正确的账号和密码,应登录成功", function () {
cy.visit("/login")
cy.get("[type=\"text\"]").type("admin")
cy.get("[type=\"password\"]").type("admin123{enter}")
cy.url().should("include", "/dashboard")
cy.get("title").should("have.text", "仪表盘 - Halo")
})
参数化测试
使用describe 命令,类似于创建了一个套件,用例在测试套件中编写,使用forEach 遍历数据,进而实现参数化,before 表示在测试用例运行前中执行一次
describe("参数化测试搜索功能",function () {
before("先登录成功",function (){
cy.visit("http://192.166.66.24:8090/admin/index.html#/login")
cy.get("[placeholder="用户名/邮箱"]").type("admin")
cy.get("[type=\"password\"]").type("admin123{enter}")
cy.visit("/posts/list")
});
["test","java","python","JavaScript"].forEach((INFO) => {
it("搜索" + INFO, () => {
cy.get(".ant-form-item-children>.ant-input").type(INFO)
cy.get("[style=\"margin-right: 8px;\"]>.ant-btn").click()
cy.get(".ant-form-item-children>.ant-input").clear()
})
})
})
业务流测试
如下示例,是一个完整的业务流测试,具体步骤含义已做注释
describe("文章管理业务流测试",function (){
before("此处是前置操作,当前模块下执行一次!",function (){
cy.log("****** 开始测试文章管理模块喽! ******")
cy.visit("/login")
cy.get("[type=\"text\"]").type("admin")
cy.get("[type=\"password\"]").type("admin123{enter}")
cy.url().should("include", "/dashboard")
cy.get("title").should("have.text", "仪表盘 - Halo")
cy.visit("/posts/list")
})
after("此处是后置操作,当前模块下执行一次!",function (){
cy.log("****** 文章管理模块用例执行完毕! ******")
})
it("查看文章列表", function () {
cy.get(".ant-table-column-title").should("have.text", "标题状态分类标签评论访问发布时间操作")
});
it("写文章并保存为草稿", function () {
cy.get("a > .ant-btn").click()
cy.get("[placeholder=\"请输入文章标题\"]").type("寄黄几复")
cy.get(".CodeMirror-line").type("桃李春风一杯酒,江湖夜雨十年灯。")
cy.get(".ant-space-item").children(".ant-btn-primary").click()
cy.get(".ant-btn-danger").click().should("have.text", "保存成功")
cy.get(".no-underline").first().should("have.text", " 寄黄几复 ")
});
it("发布文章", function () {
cy.get("[data-row-key]>:nth-child(9)>:nth-child(5)").first().click()
cy.get(".ant-modal-footer>:nth-child(3)").click().should("have.text", "保存成功")
cy.get(".ant-modal-footer>:nth-child(5)").click()
cy.get("[style=\"margin-right: 8px;\"]>.ant-btn").click()
cy.get(".ant-badge-status-text").first().should("have.text", "已发布")
});
it("文章移到回收站并删除", function () {
cy.get("[data-row-key]>:nth-child(9)>:nth-child(3)").first().click()
cy.get(".ant-popover-buttons>.ant-btn-primary").as("OK").click()
cy.get(".ant-message-notice-content").as("Tips").should("have.text", "操作成功!")
cy.get(".mb-5>.ant-space>:nth-child(2)>.ant-btn").click()
cy.get("[data-row-key]>:nth-child(7)>:nth-child(3)").first().click()
cy.get("@OK").click()
cy.get("@Tips").should("have.text", "删除成功!")
cy.get(".ant-table-row-cell-ellipsis").should("not.contain.text", " 寄黄几复 ")
cy.get(".ant-modal-footer>.ant-btn").click()
})
})
describe("页面管理",function (){
beforeEach("此处也是前置操作,与上文的before不同的,在每条用例前都会执行一次!",function (){
cy.log("~~~~~~ 开始执行新的用例!~~~~~~")
cy.visit("/login")
cy.get("[type=\"text\"]").type("admin")
cy.get("[type=\"password\"]").type("admin123{enter}")
})
afterEach("此处也是后操作,与上文的after不同的,在每条用例后都会执行一次!",function (){
cy.log("~~~~~~ 此用例执行完毕!~~~~~~")
})
it("查看独立页面",function (){
cy.visit("/sheets/list")
cy.get(".ant-table-column-title").should("have.text","页面名称访问地址状态操作")
cy.wait(4000).log("固定等待4s,否者报“访问过于频繁,请稍后再试!”")
});
it("查看新建页面", function () {
cy.get("[aria-label=\"图标: read\"]").click()
cy.contains("新建页面").click()
cy.get(".ant-page-header-heading-title").should("have.text","新页面")
});
})
运行结果如下图所示
使用PO模型
通过上面示例可以看出,大量的定位元素和数据都耦合到整个测试步骤中,会增加后期维护难度,所以尽可能拆分出来,结合PO模型思想,将数据、定位、页面和步骤进行拆分,实现解耦合,以登录为例
-
先将定位分离出来,创建locator.json 文件,使用json格式定义登录的定位元素信息
{
"login": {
"username": "[type=\"text\"]",
"passwd": "[type=\"password\"]",
"submit": ".ant-btn"
}
}
-
然后定义页面层,创建login_page.js 文件,封装页面对象及业务流程
import locator from "./data/locator.json"
export default class Login_page {
constructor() {
this.url = "http://192.166.66.24:8090/admin/index.html#/login"
}
visit(){
cy.visit(this.url)
}
get username(){
return cy.get(locator.login.username)
}
get passwd(){
return cy.get(locator.login.passwd)
}
get submit(){
return cy.get(locator.login.submit)
}
loginhalo(user,pwd){
if(user !== ""){
this.username.type(user)
}
if(pwd !== ""){
this.passwd.type(pwd)
}
this.submit.click()
}
-
最后定义用例层,创建login_case.js 文件,编写测试用例
describe("登录测试",function (){
it("输入正确的账号密码,登录成功", function () {
let login = new Login_page()
login.visit()
login.loginhalo("admin","admin123")
cy.url().should("include", "/dashboard")
});
})
至此元素定位与测试步骤拆分完成,还可以继续将步骤中的测试数据进行拆分,更方便进行参数化测试 -
继续分离测试数据,并实现参数化,创建login.json 文件,定义登录信息及对应的断言
{
"success": [{
"name": "输入正确的账号和密码,应登录成功",
"username": "admin",
"password": "admin123",
"validate": {
"checkpoint": ["url","include","/dashboard"]}}],
"fail": [{
"name": "输入错误的账号和密码,应提示“用户名或者密码不正确”",
"username": "admin",
"password": "123456",
"validate": {"checkpoint": [".ant-message-custom-content>span","contain","用户名或者密码不正确"]}},
{
"name": "输入登录密码,账号为空,应提示“用户名不能为空”",
"username": "",
"password": "123456",
"validate": {"checkpoint": [".ant-form-explain","contain","* 用户名/邮箱不能为空"]}},
{
"name": "输入用户名,密码为空,应提示“密码不能为空”",
"username": "admin",
"password": "",
"validate": {"checkpoint": [".ant-form-explain","contain","* 密码不能为空"]}}]
}
-
修改测试用例login_case.js 文件,代码如下 import data from "./data/login.json"
import Login_page from "./login_page"
describe("登录功能验证", function (){
beforeEach(function (){
let loginHL = new Login_page()
loginHL.visit()
cy.wrap(loginHL).as("testlogin")
})
afterEach(function (){
cy.wait(4000)
})
data.success.forEach(item => {
it(item.name,function () {
this.testlogin.loginhalo(item.username,item.password)
cy.url().should(item.validate.checkpoint[1],item.validate.checkpoint[2])
})
})
data.fail.forEach(item => {
it(item.name, function () {
this.testlogin.loginhalo(item.username,item.password)
cy.get(item.validate.checkpoint[0]).should(item.validate.checkpoint[1],item.validate.checkpoint[2])
})
})
})
至此实现数据、定位、页面对象和测试用例实现分离,当定位信息和数据发生变化时,只需修改locator.json 和login.json 两个文件中的json数据,下图是运行结果
命令运行测试用例
使用命令行运行会自动保存视频,视频保存在cypress/integration/videos/ 目录下,如果存在失败的用例,则同时会保存失败截图,截图保存cypress/integration/screenshots/ 目录下
npx cypress run
npx cypress run --browser chrome
npx cypress run --spec "cypress/integration/HL_login.js"
生成测试报告
-
先安装mochawesome相关模块 npm install --save-dev mochawesome mochawesome-merge mochawesome-report-generator
-
在cypress.json 文件中添加以下信息 {
"reporter": "mochawesome",
"reporterOptions": {
"reportDir": "cypress/results",
"overwrite": false,
"html": false,
"json": true
}
}
-
生成测试报告 npx cypress run --reporter mochawesome
npx cypress run --reporter mochawesome --spec "cypress/integration/HL_login.js"
npx mochawesome-merge "cypress/results/*.json" > mochawesome.json
cd cypress/results
npx marge mochawesome001.json
报告结果如下图所示 也可以使用JUnit/Allure生成报告,具体看官网介绍吧!
API接口测试
语法
在Cypress中发起HTTP请求需使用cy.request() ,语法如下
cy.request(method,url,headers,body)
单接口
如下示例登录接口测试
it("登录接口", function () {
cy.request({
method:"post",
url:"http://192.166.66.24:8090/api/admin/login",
body:{"username": "admin","password": "admin123","authcode": null}
}).then(response =>{
expect(response.status).to.be.equal(200)
}).its("body").should("contain",{"status":200,"message":"OK"})
})
接口关联
在接口自动化中肯定会有参数关联的情况,例如登录成功获取的token给后面的接口使用,在cypress中可以使用.as()、sessionStorage.setItem()或定义公共函数的方法保存数据给后面到的接口使用,
-
使用.as() 方法,只能在同一个用例下使用,示例如下 it("查看管理文章列表", function () {
cy.request({
method:"post",
url:"/api/admin/login",
body:{"username": "admin","password": "admin123","authcode": null}
})
.its("body.data.access_token").as("token")
.then(function (){
cy.log(this.token)
cy.request({
method:"get",
url:"/api/admin/posts",
headers:{"Content-Type": "application/json","Admin-Authorization":this.token}
}).its("body").should("contain",{"status":200,"message":"OK"})
})
})
-
使用sessionStorage.setItem 设置token,其它接口用例都可以调用,更推荐此方式,有利于后面做接口自动化 describe("接口测试",function (){
it('登录成功,并提取token给其它的接口使用', function () {
cy.request({
method:"post",
url:"/api/admin/login",
body:{"username": "admin","password": "admin123","authcode": null}
})
.its("body.data.access_token").as("token")
.then(function (){
cy.wrap(sessionStorage.setItem("Token",this.token))
})
});
it('查看文章管理列表', function () {
const token = sessionStorage.getItem("Token")
cy.request({
method:"get",
url:"/api/admin/posts",
headers: {"Content-Type": "application/json","Admin-Authorization":token},
})
});
})
-
定义公共函数,生成token,供其它接口调用
export default class {
generateToken(){
cy.request({
method:"post",
url:"http://192.166.66.24:8090/api/admin/login",
body:{"username": "admin","password": "admin123","authcode": null}
}).then(resp=>{
cy.wrap(resp.body.data.access_token).as("token")
})
}
}
编写用例时导入定义的公共函数,就可以使用token啦,示例如下 import Token from "./Token"
describe("文章管理->增删改查操作", function () {
before( function () {
let token = new Token()
token.generateToken()
})
it("查看文章管理列表", function () {
cy.request({
method: "get",
url: "/api/admin/posts",
headers: {"Admin-Authorization": this.token}
}).its("body").should("contain",{"status":200,"message":"OK"})
});
it("发布文章", function () {
cy.request({
method:"post",
url:"/api/admin/posts",
headers: {"Content-Type": "application/json", "Admin-Authorization": this.token},
body:{"title":"test321","content":"<p>皮之不存,毛将焉附。</p>","status":"PUBLISHED"}
}).its("body.data.id").as("articleID").then(function (){
cy.wrap(sessionStorage.setItem("ID",this.articleID))
})
});
it("将文章放到回收站", function () {
let artId = sessionStorage.getItem("ID")
cy.request({
method:"put",
url:"/api/admin/posts/"+artId+"/status/RECYCLE",
headers: {"Content-Type": "application/json", "Admin-Authorization": this.token},
}).its("body").should("contain",{"status":200,"message":"OK"})
});
it("从回收站删除", function () {
let deleteArtId = sessionStorage.getItem("ID")
cy.request({
method:"delete",
url:"/api/admin/posts",
headers: {"Content-Type": "application/json", "Admin-Authorization": this.token},
body:[deleteArtId]
}).its("body").should("contain",{"status":200,"message":"OK"})
});
})
接口参数化
-
使用数组做参数化,创建param_API.js 文件
import Token from "./Token"
describe("查看列表并发布文章",function () {
before(function () {
let token = new Token()
token.generateToken()
})
let testdatas = [
{
"casename": "查看文章管理列表",
"url": "/api/admin/posts",
"method": "get",
"headers": {"Content-Type": "application/json"},
"body": "",
"status": 200,
"message":"OK"
},
{
"casename": "发布文章",
"url": "/api/admin/posts",
"method":"post",
"headers": {"Content-Type": "application/json"},
"body":{"title":"yadian","content":"<p>皮之不存,毛将焉附。</p>","status":"PUBLISHED"},
"status": 200,
"message":"OK"
}
]
for (const data in testdatas) {
it(`${testdatas[data].casename}`, function () {
let url = testdatas[data].url
let method = testdatas[data].method
let header = testdatas[data].headers
let body = testdatas[data].body
let status = testdatas[data].status
let message = testdatas[data].message
cy.request({url: url, method: method, headers: {header,"Admin-Authorization": this.token}, body: body}).then(function (resp) {
expect(resp.status).to.eq(status)
expect(resp.body.message).to.eq(message)
})
});
}
})
-
使用JSON文件做参数化 也可以将数据单独分离出来,使用json文件做参数化,创建testdata.json 文件,保存测试数据,如下
[
{
"casename": "查看文章管理列表",
"url": "/api/admin/posts",
"method": "get",
"headers": {"Content-Type": "application/json"},
"body": "",
"status": 200,
"message":"OK"
},
{
"casename": "发布文章",
"url": "/api/admin/posts",
"method":"post",
"headers": {"Content-Type": "application/json"},
"body":{"title":"yadian","content":"<p>皮之不存,毛将焉附。</p>","status":"PUBLISHED"},
"status": 200,
"message":"OK"
}
]
在用例脚本导入数据即可使用,修改param_API.js 文件
import Token from "./Token"
import testdatas from "./testdata.json"
describe("查看列表并发布文章",function () {
before(function () {
let token = new Token()
token.generateToken()
})
for (const data in testdatas) {
it(`${testdatas[data].casename}`, function () {
let url = testdatas[data].url
let method = testdatas[data].method
let header = testdatas[data].headers
let body = testdatas[data].body
let status = testdatas[data].status
let message = testdatas[data].message
cy.request({url: url, method: method, headers: {header,"Admin-Authorization": this.token}, body: body}).then(function (resp) {
expect(resp.status).to.eq(status)
expect(resp.body.message).to.eq(message)
})
});
}
})
其它
对于Web页面测试,Cypress是支持录制功能的,但是不推荐使用,一些可变元素可能会出现在录制脚本中导致回放失败,写此文章时该功能处于试验阶段,因此默认是隐藏的,需要自行开启,不排除后续平台放弃此功能,开启方法:在cypress.json 文件中添加以下信息
{"experimentalStudio": true}
开启后页面就会出现录制入口啦!如下图演示:
对于非Cypress造成的报错,报uncaught:exception ,此时用例无法完成,可以先忽略应用程序的报错。忽略方法:打开support目录下的index.js 文件,添加以下忽略命令
Cypress.on('uncaught:exception', (err, runnable) => {
return false
})
Cypress.on('uncaught:exception', (err, runnable) => {
if (err.message.includes('HaloRestAPIError')) {
return false
}
})
这个Cypress官方文档还是蛮详细的,其它功能请自行探索吧!
|