测试自动化

基本概念

自动化测试,也叫软件测试自动化。要学习软件测试自动化,首先就需要清楚什么是软件测试。

参考内容

软件测试

因为当局者迷,旁观者清的道理,软件开发是个复杂而周期性的过程,期间很容易产生或遗留下错误,而对于开发人员自己所编写与开发的应用程序(软件),往往有很多问题是他们自己发现不了,所以如果直接把存在不足的、有错误、有漏洞的应用程序直接运营上线提供给用户使用,那么很可能会给企业带来商业风险或影响企业受益,所以就需要软件测试人员进行软件测试了。

而软件测试(Software Testing)就是为了尽快尽早地发现软件的各种软件缺陷而展开的贯穿整个软件生命周期、对软件(包括阶段性产品)进行验证和确认的活动过程。这个过程是在规定的条件下对程序进行测试操作并对其是否能满足设计要求进行评估,以达到发现、纠正程序错误,衡量和提升软件质量的目的。通俗点说,软件测试就是通过各种各样的手段或工具来尽可能的找到软件的不足和错误。

软件测试只能查找出软件中的错误或不足,但不能证明程序中没有错误,而且软件测试不能完全消灭软件的错误,只能尽早尽量多的发现软件中的错误与不足。

软件生命周期是指从软件产品的可行性分析到软件不再使用而结束的时间。如果把软件看成是有生命的事物,那么软件的生命周期可分为6个阶段:需求分析、计划、设计、编码开发、测试、运行维护

测试分类

详情xmind参考:https://www.processon.com/mindmap/638c3ccc254ef949c5cf53d0

软件测试从不同的角度有着不同的分类方式。

image-20220513075004943

在实际开发中,往往我们都是根据实际情况采用多种不同的测试手段、测试方式来对软件测试测试的。

软件缺陷

软件缺陷,通常又被叫做bug或者defect,即为软件或程序中存在的某种破坏正常运行能力的问题、错误,其存在的最终表现为用户所需要的功能没有完全实现,不能满足或不能全部满足用户的需求。

  • 产品内部来说,软件缺陷是软件产品开发或维护过程中所存在的错误、误差等各种问题。

  • 产品外部来说,软件缺陷是系统所需要实现的某种功能的没有或不足。

出现原因

bug出现的原因一般有如下几种情况,也就是说符合以下情况的问题都属于bug:

原因 描述
功能遗漏 软件未实现用户或产品需求要求的或应有的功能。
异常错误 软件出现了不应该出现的错误。
功能冗余 软件出现了用户或产品需求没有要求的功能。
体验度低 软件的使用过程过于复杂或难以理解、软件运行缓慢导致用户体验不好。

缺陷管理

缺陷管理也叫bug管理,一般会集成到项目管理工具中,常用的项目管理工具:Teambition禅道pingcode、飞书、钉钉等。大部分的项目管理工具内置的缺陷管理功能都会对缺陷划分成不同类型、严重等级、优先级别,以及不同的状态。

缺陷类型
bug类型 描述
功能缺陷 软件中的功能没有实现或不完善而导致
使用过程出现异常错误、逻辑错误等问题。
界面缺陷 用户界面外观缺失或不足,影响了用户正常使用的问题。
如:名称过长时被遮挡、文字部分被遮挡、图片只展示部分等。
需求缺陷 需求规格说明书未明确或存在遗留需求的问题。
性能问题 不满足系统可测量的属性值,如执行时间、处理速度等。
如:一个功能被用户使用时没有响应,或需要用户等待时间过久等。
接口缺陷 与其他组件、模块或程序、调用参数或参数列表等不匹配、出现冲突问题。
如传参个数与接口不匹配、传参类型与接口不匹配等。
兼容性缺陷 软件运行环境不匹配的问题
如操作系统、浏览器、网络环境等不匹配
易用性缺陷 新用户对软件难以快速熟悉或难以快速上手使用的问题。
代码错误 不满足需求、功能实现错误;对产品或项目质量有影响的bug
配置相关 由于提供的配置不当或者配置不能够满足实际要求而出现的问题
安装部署 由于部署安装引起的问题
安全相关 出现安全隐患问题,如存在SQL注入,xss攻击等。
标准规范 不符合相关的国际、国家标准规范或业界规范等
严重等级
等级 描述
致命缺陷(S1) 软件任何一个主要功能完全丧失,用户数据受到破坏,软件崩溃、
悬挂或者危及用户人身安全。如软件崩溃造成硬件设备漏电等
严重缺陷(S2) 软件的主要功能部分丧失,数据不能保存,软件的次要功能完全丧失,
系统所提供的功能或服务受到明显的影响。如软件的某个菜单不起作用
一般缺陷(S3) 软件的次要功能没有完全实现,但不影响用户的正常使用。
如软件内的某些内容输入有误或无法输入。
较小缺陷(S4) 用户体验不好或操作不方便,但不影响功能使用和运行。
如软件内出现错别字或排版有问题等。
优先级别
优先级 描述
立即解决(P1) 针对软件的致命缺陷,往往需要立即修复。
优先解决(P2) 针对软件的严重缺陷,影响了测试,需要优先修复。
等候解决(P3) 针对软件的一般缺陷,需要正常排队等待修复。
建议解决(P4) 针对软件的较小缺陷,可以在开发人员有时间时再进行修复。
生命周期

从发现bug到关闭bug的这个时间段,我们称之为缺陷(bug)的生命周期。

image-20220513060547806

在整个bug处理的流程上,一般会把bug划分成多个不同状态。

状态 描述
新建(New) 当bug首次被发现时,测试人员会确认并记录下来,并将bug的状态为New
已指派(Assigned) 当bug被指认为New之后,将其传递给开发组,开发组将确认这是否是bug,如果是则开发组的leader会将bug指派给某位开发人员处理,并将bug的状态
设定为“Assigned”。
重新指派(Reassigned) bug被重新指派给某位开发人员处理处理。
已打开(Open) 一旦开发人员开始处理bug,就将bug的状态设为“Open”。
已修复(Fixed) 当开发人员进行处理(并认为已经解决)之后,就可以将bug的状态设置为“Fixed”并将其提交给开发组leader,然后leader将bug返还给测试组。
等待再测试(Pending Reset) 当bug被返还到测试组后,会将bug的状态设置为“Pending Reset”
再测试(Reset) 测试组的leader将bug指定给某位测试人员进行再测试,并将bug的状态设置为“Reset”。
已关闭的(Closed) 如测试人员经过再次测试之后确认bug已被解决,会将bug的状态设置为 “Closed”。
再次打开的(Reopen) 如果经过再次测试发现bug仍然存在的话,测试人员将bug再次传递给开发组,并将bug的状态设置为“Reopen”
拒绝中(Pending Reject) 如果测试人员传递到开发组的bug被开发组认为不是bug时,这种情况下开发组可以拒绝,将bug的状态设置为“Pending Reject”并返还给测试组。
被拒绝的(Rejected) 测试组的负责人接到拒绝的bug时,如果发现并不能算作bug时,测试组负责人将bug的状态设置为“Rejected”。当然,无法重现,bug信息不足或重复的bug,有时候也会被拒绝。
延期(Postponed) 对于一些特殊的bug的测试需要搁置一段时间,这种情况下,bug的状态就被设置为“Postponed“。

缺陷报告

缺陷报告,也叫bug报告,是软件测试人员重要的产出物之一,也是主要工作之一。一份高质量的缺陷报告可以帮助开发人员快速定位问题,修复Bug;也便于测试人员对缺陷进行统计、分析和跟踪管理,是测试人员和开发人员重要的沟通工具。开发中针对需求,测试bug,最怕的就是口口相传。

缺陷报告的基本组成:缺陷ID缺陷标题,发现者,前置条件,是否可重现,操作系统,发现时间,所属项目,所属模块,所属版本,缺陷状态,严重等级优先级别附件描述重现步骤,预期效果,实际效果等。注意:加粗部分为BUG六要素。

参考模板:

img

缺陷报告就是软件测试的结果产出物,而如何验证和测试缺陷?那就要继续往下学习更多内容了。

测试原则

原则 描述
测试显示软件存在缺陷 测试只能证明软件中存在缺陷,但并不能证明软件中不存在缺陷,即零缺陷是不可能的。
软件测试是为了降低存在缺陷的可能性,即便是没有找到缺陷,也不能证明软件是完美的。
穷尽测试是不可能的 现在软件的规模越来越大,复杂度越来越高,想做到完全性的测试是不可能的。
测试人员可以根据严重等级、优先级、场景、目的来分类别进行集中和高强度的测试,从而保证软件的质量。
测试尽早介入 测试人员越早介入软件开发流程越好,最好在需求阶段就开始介入,使缺陷在需求或设计阶段就被发现,
缺陷发现越早,修复的成本就越小,反之,越晚发现修复成本就越高。
缺陷存在集群现象(二八定律) 80%的缺陷往往存在于20%的模块中。一般项目复杂功能往往会占据所有功能的20%左右,而这20%的复杂功能往往有可能会包含大部分的缺陷。一个功能模块发现的缺陷频率越高,那存在的未被发现的缺陷出现频率也越高,故发现的缺陷与未发现的缺陷成正比。开发团队里面最菜的人写的代码bug最多,开发团队里面负责开发功能最复杂难度最高的人bug最多
杀虫剂悖论 反复使用相同的杀虫剂会导致害虫对杀虫剂产生免疫而无法杀死害虫,软件测试也一样。如果一直使用相同的测试方法或手段,可能无法发现新的bug。为了解决这个问题,测试用例应当定期修订和评审,增加新的或不同的测试用例帮助发现更多的缺陷。
测试依赖于环境 测试在不同环境(操作系统,浏览器,解释器)下是不同的。所以不应该以完全相同的⽅法去测试两个不同的系统。
不存在缺陷的谬论 与第一条类似,期望仅仅发现并修复⼤量缺陷就能确保系统的成功,这是⼀个谬论。

测试自动化

著名的敏捷开发布道师 Mike Cohn(迈克·科恩) 在他的着作《Succeeding with Agile》(中文名:《Scrum敏捷软件开发》)一书中提出了测试金字塔的概念。

测试金字塔.jpg

根据 Mike Cohn 的测试金字塔,测试的组合应该至少由以下三层组成 (自下往上分别是):

  • 单元测试(Unit Tests)
  • 服务测试(Services Tests)
  • 用户界面测试(UI Tests)

意思是,应该把测试不同粒度的测试分布到整个软件不同层次中,而随着层次越高,编写的测试内容应该越少,也就是写许多小而快的低层次单元测试,适当写一些更粗粒度的中层次接口测试或集成测试,写很少的高层次UI测试、系统测试或验收测试。

所以,根据测试金字塔理论,接下来我们按部就班对测试自动化的内容进行学习。

禅道

禅道的介绍

禅道项目管理软件(简称:禅道)集产品管理、项目管理、质量管理、文档管理、组织管理和事务管理于一体,是一款功能完备的项目管理软件,完美地覆盖了项目管理的核心流程。

禅道的主要管理思想基于国际流行的敏捷项目管理方式—Scrum。Scrum是一种注重实效的敏捷项目管理方式,它规定了核心的管理框架 ,但具体的细节还需要团队自行扩充。禅道在遵循其管理方式基础上,又融入了国内研发现状的很多需求,比如bug管理,测试用例管理,发布管理,文档管理等。因此禅道不仅仅是一款scrum敏捷项目管理工具,更是一款完备的项目管理软件。基于scrum,又不局限于scrum。

禅道最大的特色是创造性的将产品、项目、测试这三者的概念明确分开,互相配合,又互相制约。通过需求、任务、bug来进行交相互动,最终通过项目拿到合格的产品。

目前,禅道和JIRA用的人较多。我们这里以禅道为例。

禅道项目管理软件是做什么的?

禅道由青岛易软天创网络科技有限公司开发,国产开源项目管理软件。它集产品管理、项目管理、质量管理、文档管理、组织管理和事务管理于一体,是一款专业的研发项目管理软件,完整覆盖了研发项目管理的核心流程。禅道管理思想注重实效,功能完备丰富,操作简洁高效,界面美观大方,搜索功能强大,统计报表丰富多样,软件架构合理,扩展灵活,有完善的API可以调用。禅道,专注研发项目管理!

为什么用禅道这个名字?

禅和道这两个字含义极其丰富,有宗教方面的含义,也有文化层面的含义。禅道项目管理软件取其文化含义,期望通过这两个字来传达我们对管理的理解和思考。这个名字是受《编程之道》和《编程之禅》这两本书的启发。英文里面的禅为Zen,道为Tao,所以我们软件的英文名字为zentao。

Windows平台禅道的下载安装

我们可以在Windows,Mac,linux平台去搭建禅道。

下载地址:https://www.zentao.net/download/zentaopms12.2-80207.html

如果是Windows平台,安装目录一定是磁盘的根目录

Windows平台的默认账号密码:

账号:admin
密码:123456

docker部署禅道

  1. 新建一个容器卷挂载目录
[root@C /]# mkdir -p /docker_data/zento_data

2.拉取镜像

[root@C ~]# docker pull idoop/zentao:12.0.1
  1. 启动禅道
docker run -d -p 6003:80  --restart=always -e ADMINER_USER="root" -e ADMINER_PASSWD="password" -e BIND_ADDRESS="false" -v /docker_data/zentao_data:/opt/zbox/ --add-host smtp.exmail.qq.com:163.177.90.125 --name zentao-server idoop/zentao:12.0.1
  1. 现在就浏览器访问ip:6003端口即可,然后会让你修改密码,默认账号和密码:
账号:admin
密码:123456

禅道使用流程

创建角色

角色:

  • 产品经理
  • 项目经理
  • 开发经理(主管)
    • 开发团队1
      • 开发人员1
      • 开发人员2
    • 开发团队2
      • 开发人员1
  • 测试经理(主管)
    • 测试团队
      • 测试人员
      • ...
角色 密码 备注
admin root!1234 管理员
chanpinjingli1 root!1234 产品经理1
xiangmujingli1 root!1234 项目经理1
yanfazhuguan1 root!1234 研发主管1
chanpinzhuguan1 root!1234 产品主管1
ceshizhuguan1 root!1234 测试主管1
gaocengguanli1 root!1234 高层管理1
kaifa1 root!1234 开发人员1
kaifa2 root!1234 开发人员2
ceshi1 root!1234 测试人员1
ceshi2 root!1234 测试人员2

为了方便,角色密码以都为root!1234

组织 -- 添加用户/批量添加用户

image-20200421102131760

创建产品计划

产品经理登录,选择产品--创建产品

填写:

  • 计划名称
  • 开始/结束日期
  • 计划的描述

image-20200421103300277

image-20200421103332680

创建计划的好处有:

  • 可以帮助产品人员控制产品的研发过程。
  • 帮助相关人员了解产品进度,便于后续的工作安排。

创建需求和需求变更

创建需求,由产品经理登录,选择产品--创建需求:

  • 如果该需要评审,就不要勾选不需要评审选择框。需要评审:
    • 指派给谁评审,如指派给产品主管评审,注意,评审是一个线下活动,禅道这里只是记录评审结果
    • 产品主管登录后,可以进行评审结果的操作
      • 确认通过,该需求没有问题,可以搞。
      • 有待明确,需求本身存在问题,需要继续完善。
        • 当产品经理完善之后,由产品主管继续进行评审。
      • 拒绝,要给出拒绝的理由。
        • 已完成
        • 已细分
        • 重复
        • 不做
        • ...
  • 只有经过评审通过的需求,状态是激活状态,即,可以去实现该需求。

image-20200421110933950

需求变更

  1. 由产品经理登录,选择产品 --- 需求,指定一个激活状态的需求进行变更流程:

image-20200421112949733

要在变更流程之后,指定有谁评审,这里指定项目主管评审。

  1. 产品主管登录禅道,产品 --- 需求 --- 指派给我;选择评审按钮,选择评审结果和备注。关于评审结果:
    1. 确认通过,该需求可以变更,后续工作,按照新需求完成。
    2. 撤销变更,需求变更失败,后续工作,按照原需求完成。
    3. 有待明确,需求变更描述有问题,打回去重新编写描述,然后再来进行评审。

image-20200421113237526

当需求变更通过后,该需求的状态变成激活状态。

创建项目及关联需求

现在,关于产品相关的需求已经评审结束,就可以进入到项目立项阶段,也就是即将进入到实际的编码阶段了。

项目立项一般都是开个立项会:

  • 由产品人员把产品的需求与项目组成员沟通,必要时调整需求;
  • 项目组成员估算完成需求的工作量;
  • 对需求进行分解,任务分配;

完事之后,一般由项目经理在禅道中建立项目。

PS:项目组成员在线下已经分配好了,但还需要在项目创建后,手动的关联,所以,称这个过程为创建项目和创建团队。

以项目经理角色登录

image-20200421113913254

编写项目信息:

image-20200421114205958

当项目创建后,需要设置项目团队(开发组/测试组),进行项目与产品及产品计划进行关联。注意,关联操作会自动的关联在产品计划中已激活的需求。也可以手动的关联需求。

开发完成需求

首先项目经理/项目主管/研发主管登录,项目 -- 需求列表中,可以为将需求进行分解,分成若干粒度更小的任务,便于开发进行开发工作。

image-20200421143431566

开发在任务列表中,能看到指派给自己的任务都有哪些,并且任务的时长。

开发在开发该任务时,要每天填写工时,能及时查看任务进度。

当该任务已经完成后,即剩余工时为0时,可以选择结束该任务,那此时的任务就处于已完成的状态。

image-20200421143738496

当所有的任务都完成后,并且开发自测通过后,就可以进行提测,在提测前,要进行构建版本的过程。

构建版本

开发人员登录,访问项目 --- 版本 -- 创建版本

image-20200421144044348

编写版本信息。

image-20200421144349936

在版本信息的描述中,要注意:

  1. 该版本的实现哪些功能
  2. 解决哪些问题?
  3. 测试中,有些注意事项和建议

版本构建成功后,如下图

image-20200421144454032

版本关联需求

该版本实现了哪些需求

赋予研发角色权限

默认的,开发人员无权做给版本关联需求的操作,该权限只有研发主管、项目经理有权限。

如何给开发赋予该权限:

  1. 用admin账号登录,组织 -- 权限 --- 找到研发角色,点击权限维护按钮。

image-20200421145741649

  1. 下拉选择版本,勾选关联需求选项。

image-20200421145637641

  1. 此时,以开发人员的角色登录禅道,就可以发现已经有了该权限,也就是有了关联需求的按钮。

image-20200421145913522

注意,该操作适用于为所有的角色赋予指定权限。

版本关联需求

开发人员(研发主管/项目经理登录也行)登录,项目视图 ---- 版本选项,点击指定版本的关联需求按钮。

image-20200421150355985

勾(多)选需求,然后点击关联需求按钮。

image-20200421150159345

关联需求成功后,在版本详情中,能看到关联的需求:

image-20200421150452033

开发提测

此时的项目,在当前产品计划中,开发阶段基本完成, 可以向测试提测。

image-20200421150753376

编写提测单:

image-20200421151049986

用户创建用例

测试人员身份登录禅道,访问测试视图下的用例选项。

image-20200421151933883

编写测试用例。

image-20200421151824722

创建成功:

image-20200421151854802

版本关联用例

测试视图 --- 测试单 --- 点击关联用例

image-20200421152452728

勾选用例并点击保存。

image-20200421152531948

关联成功:

image-20200421152658170

注意,只有用例的状态是正常的,才能被关联。

后台开启用例评审功能

一般的,我们写的测试用例都是需要评审的,但是默认的,禅道并没有开启用例评审权限。

admin账号登录,后台 --- 自定义 --- 用例 ---- 评审流程,勾选开启,然后点击保存。

image-20200421153136849

接下来 ,由测试人员登录,在创建用例详情中,可以勾选该评审功能。

image-20200421153529406

需要评审的用例的状态是待评审状态。

image-20200421153607309

注意,测试人员也能评审自己的用例(也可以使用admin账号取消测试人员的评审测试用例的权限),但一般选择测试主管来评审。

执行用例并且提交bug

首先明确,用例执行是一个实际的操作过程,只是在禅道中记录这一过程,并且进行bug管理。

执行用例

测试人员登录:

测试视图 -- 用例列表 ---- 选择执行用例

image-20200421160021907

提交bug

测试人员登录,点击执行用例,当用例执行失败,在禅道中需要记录并且提交bug:

image-20200421160320797

编辑bug信息:

image-20200421160853434

开发人员访问测试 --- bug,点击指派给我的,bug详情中,点击确认按钮。

image-20200421161107443

回归测试

当开发修复bug后,我们测试人员需要进行回归测试。

如果回归测试成功,选择关闭bug

如果回归测试失败,重新激活该bug,开发继续修复,我们在进行回归测试,直到回归测试成功,然后关闭该bug。

参考:https://www.cnblogs.com/Neeo/articles/12620853.html

postman

下载和安装

官网下载,然后点击安装,会自动完成安装,无需别的配置,当安装成功后,会提示你登录/注册账号,建议用账号登录

基本使用

  1. postman面板介绍

  2. 常见的请求:

    1. 无参get请求
    2. 有参数的get请求
    3. k:v格式的post请求
    4. k:json格式的post请求
    5. json格式的post请求

    image-20200423085638586

  3. 文件上传

image-20200423085735748

  1. postman console,请求的历史记录,有完整的:
    1. 请求头
    2. 请求体
    3. 响应头
    4. 响应体

image-20200423085956857

  1. 如何导出请求数据

image-20200423090139386

  1. 集合:将同一类型或者同一项目的接口收集到一个集合中,便于统一管理和自动化执行。

    1. 如何创建集合

    image-20200423090248539

image-20200423090337698

  1. 将接口收集到集合中。

image-20200423090441809

环境管理

参考:https://www.cnblogs.com/Neeo/articles/12186481.html

创建环境:

image-20200423093233834

编辑当前环境的变量:

image-20200423093253678

如何在请求中应用上环境中的变量?

image-20200423093329401

使用{{变量名}}的方式使用环境中的变量值。

变量的作用域

全局变量:作用于全局,postman中的任何地方

集合变量:变量只作用于集合中的接口

内置动态变量:内置的一些时间戳,UIID变量。

自定义环境变量:自己定义的环境,在该环境中创建的变量,只要你使用该环境,就能使用其中变量值。

全局变量

如何创建全局变量

image-20200423100600938

image-20200423100654101

使用{{变量名}}来使用。

集合变量

只作用于当前的集合中。

创建

image-20200423101434085

image-20200423101928301

使用{{变量名}}来使用。

内置的变量

作用于所有地方,使用{{$内置的变量名}}

image-20200423102224089

常用的:

  • timestap
  • guid

更多内置的变量参考:https://learning.postman.com/docs/postman/variables-and-environments/variables-list/

断言

参考:https://www.cnblogs.com/Neeo/articles/12186485.html

python中的断言,assert,断定结果是怎样的,否则就断言失败。

import requests

response = requests.get("https://www.baidu.com", timeout=5)


try:
    assert response.status_code == 201
    print('断言成功')
except AssertionError as e:
    print('断言失败')

在postman中,如何使用断言。

image-20200423103634765

postman中,可用的模板?

模板 描述 示例
clear a global variable 清除全局变量 pm.globals.unset("variable_key");
Clear an environment variable 清除一个环境变量 pm.environment.unset("variable_key");
get a global variable 得到一个全局变量 pm.globals.get("variable_key");
get a variable 得到一个变量 pm.variables.get("variable_key");
Get an environment variable 得到一个环境变量 pm.environment.get("variable_key");
response body:contains string 检查response body包含字符串 pm.test("Body matches string", function () { pm.expect(pm.response.text()).to.include("string_you_want_to_search"); });
response body:convert XML body to a JSON object response body:将XML转换为JSON对象 var jsonObject = xml2Json(responseBody);
response body:is equal to a string 检查响应体等于一个字符串 pm.test("Body is correct", function () { pm.response.to.have.body("response_body_string"); });
response body:JSON value check 检查response body中JSON某字段值 pm.test("Your test name", function () { var jsonData = pm.response.json(); pm.expect(jsonData.value).to.eql(100); });
response headers:content-Type header check 检查content-Type是否包含在header返回 pm.test("Content-Type is present", function () { pm.response.to.have.header("Content-Type"); });
response time is than 200ms 响应时间超过200ms pm.test("Response time is less than 200ms", function () { pm.expect(pm.response.responseTime).to.be.below(200); });
send s request 发送一个请求 pm.sendRequest("https://postman-echo.com/get", function (err, response) { console.log(resp onse.json()); });
set a global variable 设置一个全局变量 pm.globals.set("variable_key", "variable_value");
set an environment variable 设置一个环境变量 pm.environment.set("variable_key", "variable_value");
status code:Code is 200 状态码:代码是200 pm.test("Status code is 200", function () { pm.response.to.have.status(200); });
status code:code name has string 状态码:代码中有指定字符串 pm.test("Status code name has string", function () { pm.response.to.have.status("Created"); });
status code:successful POST request 状态码:成功的post请求 pm.test("Successful POST request", function () { pm.expect(pm.response.code).to.be.oneOf([201,202]); });
use tiny validator for JSON data 为json数据使用tiny验证器 var schema = { "items": { "type": "boolean" } }; var data1 = [true, false]; var data2 = [true, 123]; pm.test('Schema is valid', function() { pm.expect(tv4.validate(data1, schema)).to.be.true; pm.expect(tv4.validate(data2, schema)).to.be.true; });

常用的断言

// 状态码断言

pm.test("判断响应状态码是否是 200", function () {
    pm.response.to.have.status(200);
});

// 判断json中的key value是否符合预期,如果你的key是嵌套结构,就点下去

pm.test("Your test name", function () {
    var jsonData = pm.response.json();
    pm.expect(jsonData.args.k2).to.eql("v2");
});


// 判断文本类型的响应文本中,是否包含指定的字段
pm.test("判断响应体是否包含指定的字段", function () {
    pm.expect(pm.response.text()).to.include("百度一下");
});

集合公共断言

创建集合公共断言。

image-20200423110040817

可以将集合中的一些公共的断言写到集合配置中,在集合中的接口在执行时,会自动的应用上集合配置中的断言。

特殊接口

签名接口

  1. 知道签名接口的加密规则
    1. sign:md5(手机号 + 盐 + 时间戳)
  2. 知道,在请求之前处理相关的数据

image-20200423113527845

// 获取手机号

var phone = "1212113";

// 获取盐
var optCode = "testfan";


// 获取时间戳

var timestamp = new Date().getTime();

// console.log(timestamp);

// md5 加密

var md5 = CryptoJS.MD5(phone + optCode + timestamp).toString();
// console.log(md5);

// 将加密后的字符串set回环境中
pm.globals.set("phone", phone);
pm.globals.set("timestamp", timestamp);
pm.globals.set("md5", md5);

cookies

处理cookie有两种方式:

  • 第一种,手动的获取cookies值,然后在需要cookies的请求中,手动携带,注意:你要保证cookie值是有效的。
  • 第二种,postman在发送请求的时候,如果响应中有cookies返回,就会自动的帮我们保存在cookie管理器中。当有向该域名发送请求的时候,postman会自动的帮我们携带cookie值。

token

postman不会帮我们处理token,只能我们自己来完成:

  • 第一种,用例浏览器登录账号,获取token值,在有需要的接口中,手动添加。

  • 第二种:

    • 在登录接口访问成功后,从请求体中获取token字段,并且set到全局变量中。
    // 从响应体中获取token字段: data
    var token = pm.response.json().data;
    
    // 将token值set到全局的环境中去。
    pm.globals.set("my_token", token);
    
    
    • 在需要使用token的接口中,在该接口请求发送之前, 从环境中获取token值,填写在请求头中。

    image-20200423143551211

webservice接口

参考:http://www.webxml.com.cn/zh_cn/index.aspx

简单来说,webservice是通过xml进行交互的web请求,本质上也是HTTP请求。

Web Service也叫XML Web Service WebService是一种可以接收从Internet或者Intranet上的其它系统中传递过来的请求,轻量级的独立的通讯技术。是通过SOAP在Web上提供的软件服务,使用WSDL文件进行说明,并通过UDDI进行注册。

如何调试webservice接口

  1. 在post请求的raw中输入请求的参数,并且,格式选择xml:

image-20200423145110486

  1. 由于上一步中,选择xml格式的时候,postman会自动的在请求头中,添加上Content-Type:application/xml,但是webservice接口的content-type不是application/xml而是text/xml.

image-20200423145323669

  1. 修改请求content-type值。

image-20200423145428803

  1. 就正常的发请求就完了。

image-20200423145443892

集合自动化

image-20200423150420293

如上图,在启动集合的时候,有如下参数配置:

  • Environment:选择接口执行时依赖的环境。
  • Iterations:表示该集合内的接口运行的次数。
  • Delay:接口执行前延时多少毫秒。
  • Log Response:默认记录所有的响应日志。
  • Data:参数数据,暂时先不管它。
  • Keep variable values:保存在运行过程中产生的变量或者值,选择默认即可。
  • Run collection without using stored cookies:运行中不使用cookie,这里也保持默认。
  • Save cookies after collection run:在集合执行之后,保存cookies,选择默认。
  • Run 集合名称:运行集合。

数据驱动这里:一般轮训次数会自动的根据你的数据行数来定。如果轮训次数大于数据行数,那么在剩余的轮训中,提取的数据就是数据文件的最后一行。

命令行测试

环境配置

  1. 安装nodejs:https://www.cnblogs.com/Neeo/articles/11637320.html,注意,nodejs版本不低于10。

  2. 由于自带的npm命令受网络影响严重,我们需要配置一个国内的淘宝镜像:cnpm,终端(管理员权限)执行如下命令:

npm install -g cnpm --registry=https://registry.npm.taobao.org

  1. 测试vnpm是否安装成功:
cnpm -v

  1. 使用cnpm来安装Newman插件:
cnpm install newman --global
newman -v


  1. 安装reporter插件,用来生成测试报告
cnpm install -g newman-reporter-html


  1. 执行终端命令,获取html类型的报告
newman run 脚本 -d 参数文件 -e 环境文件 -n 循环次数 -r html --reporter-html-export 测试报告路径


question

newman required node version >=10

意思是nodejs版本太低了,需要10及以上的版本。

img

nodejs需要win8以上的系统

这个意思是新版本的nodejs要求的Windows系统在win8以上,你可以安装一个稍微低一点的nodejs,如12.x版本

image-20200423154900800

本地部署Java项目

  1. 要安装java jdk(Windows/Mac):java jdk安装:https://www.cnblogs.com/Neeo/articles/11954283.html
  2. 下载apache-tomcat-8.5.38.zip压缩包,解压到你的本机上,一个没有中文,空格的目录中。
  3. 配置相关的环境变量。
变量名
JAVA_HOME java jdk 的安装目录
PATH %JAVA_HOME%\bin
CATALINA_HOME tomcat的安装目录
PATH tomcat的bin目录
  1. 启动,在任意目录打开终端,输入pinter,启动过程稍慢,选哟静等一会儿

  2. 浏览器访问127.0.0.1:8231/pinter/index

image-20200423161322659

其他平台配置方式:https://www.cnblogs.com/Neeo/articles/12168651.html

问题

打开tomcat的启动文件,一闪即逝,意思是你的Java环境配置的有问题,解决办法:

  1. 检擦你的环境变量是否配置好了。
  2. 重启电脑

单元测试自动化

所谓的单元测试(Unit Test)是根据特定的输入数据,针对程序代码中的最小实体单元的输入输出的正确性进行验证测试的过程。所谓的最小实体单元就是组织项目代码的最基本代码结构:函数,类,模块等。在Python中比较知名的单元测试模块:

  • unittest
  • pytest
  • nose
  • doctest
php phpunit
java javaunit

参考内容:

测试用例

所谓的测试用例(Test Case),就是执行测试的依据和记录,把测试应用程序的操作步骤用文档的形式描述出来的一份文档。文档的格式可以是Excel、markdown、html、xmind网页。

一份合格的测试用例有利于测试人员理清测试思路,确保需要测试的功能周全没有遗漏,方便测试工作的开展和评估测试工作量,同时还可以便于测试人员记录测试数据和测试工作进度,为后续的回归测试提供样本参考,提升测试效率以及后续测试工作的交接。

那么一份合格的测试用例长什么样子或有什么内容呢?

一份合格的测试用例,应该包含测试时间、测试人员、测试模块名功能点名称用例ID用例说明(测试目的)前置条件输入数据预期结果测试结果(输出结果、实际结果)等。注意:加粗内容为必备的测试用例八要素。

参考文档:

image-20220515072055298

在实际工作中,因为缺陷报告与测试用例作用相似,因此有时候会合并一起或只选择其中一种。

设计方法

那么在工作中,我们一般都应该编写测试用例或者应该怎么设计测试用例来完成我们的测试工作呢?实际上在工作中,测试人员都是基于测试用例的7种基本设计方法来设计与编写测试用例的:

  • 等价类划分法:根据输入数据的有效性与无效性设计测试用例。
  • 边界值分析法:对等价类划分法的一个补充,从等价类的边缘值(临界点)去寻找错误,基于这些错误来设计测试用例。
  • 判定表法:把输入数据的各种可能情况进行组合罗列成一个判断表,以判断表来设计测试用例。
  • 因果图法:用图解的方式表示输入数据的各种组合关系,以此写出判定表,从而设计相应的测试用例。
  • 正交表法:基于正交表来设计测试用例。
  • 场景法:基于流程图展示业务流程或功能的调用流程,对流程图的走向路径设计测试用例。
  • 错误推测法:基于经验和直觉,找出程序中认为可能出现的错误来设计测试用例。

一般在工作中,我们比较常用的是等价类划分法与判定表法。

等价类划分法

等价类划分法就是按照测试要求,把具有共同特征的测试数据划分为2类:有效等价类和无效等价类,把测试数据进行分类以后设计测试用例。

  • 有效等价类,就是符合程序使用要求或调用代码要求的,能正确使用程序或调用代码的一类数据。
  • 无效等价类,就是不符合程序使用要求或调用代码要求的,会导致程序出现异常或结果不正确的一类数据。

使用等价类划分法,可以让我们设计的测试工作更加科学有依据,避免出现穷举测试的情况,减少测试用例的数量。

例如,注册功能中用户名的测试用例,如果功能需求中,要求用户名必须长度为3-11个长度的字符。

系统模块 功能点 用例ID 测试目的 前置条件 输入 预期 结果
会员模块 用户注册 01 验证用户名 打开用户注册页面 "abc" 正确
会员模块 用户注册 02 验证用户名 打开用户注册页面 "abdefgthssaaaaa" 错误

判定表法

判定表是分析和表达多逻辑条件下执行不同操作的情况的工具。而软件测试中的判定表法,就是把输入数据的各种可能情况进行组合罗列成一个判断表格,以判断表来设计测试用例。

判定表的表结构一般有如下2种:横向判断表与纵向判定表。

横向判断表:

条件桩 条件项
动作桩 动作项

纵向判定表:

条件桩 动作桩
条件项 动作项

例子,测试一个功能是否能修改文件。

如果使用纵向判定表:
条件1:是否有权限 条件2:是否存在 结果1:可以修改 结果2:不能修改
如果使用横向判断表:
条件桩:是否有权限
条件桩:是否存在
动作桩:可以修改
动作桩:不能修改

单元测试框架-Unittest

Unittest是Python开发中常用于单元测试的内置框架,免安装使用简单方便,其设计的灵感来源于Java的单元测试框架-Junit。

Unittest具备完整的测试结构,支持自动化测试的执行,对测试用例进行组织,并且提供了丰富的断言方法,还提供生成测试报告。

官方文档:https://docs.python.org/zh-cn/3/library/unittest.html

import unittest
print(dir(unittest))

上面的代码中,我们就引入了Unittest模块, 同时可以通过打印发现Unittest框架中内置了大量的工具成员。这些工具成员中除了以下5个以外,其他的都不怎么常用。

  • TestCase(测试用例)

    是unittest中最重要的一个类,用于编写测试用例类,是所有测试用例类的父类,实现了测试用例的基本代码。

  • TestSuite(测试套件、测试集)

    可以把多个TestCase组织、打包集成到一个测试集中一起执行,TestSuite可以实现多个测试用例的执行。

  • TextTestRunner(测试运行器)

    TestSuite本身不具备执行的功能,所以使用TextTestRunner执行测试套件和输出测试结果。

  • TestLoader(测试加载器)

    用于加载测试用例TestCase,并生成测试套件TestSuite,实现自动从代码中加载大量测试用例到测试套件中。

  • TestFixture(测试脚手架)

    所谓的测试脚手架就是为了开展一项或多项测试所需要进行的准备工作,以及所有相关的清理操作。测试脚手架实际上会在执行一些测试代码之前与之后,让我们编写一些初始化和销毁的代码。

快速入门

测试用例-TestCase

前面讲到TestCase就是提供给我们编写测试用例的测试代码的,那么怎么编写一个测试用例?需要4个步骤即可。

  1. 导入unittest模块

    import unittest
    
    
  2. 定义测试用例类

    import unittest
    class 测试用例类名(unittest.TestCase): # 所有的测试用例类都必须直接或者间接继承unittest.TestCase.
        """测试用例"""
        pass
    
    
  3. 定义测试用例方法(此处的测试用例方法,就是上面所说的测试用例设计方法中的一行信息的测试代码)

    import unittest
    class 测试用例类名(unittest.TestCase):
        """测试用例"""
        # ....
        def test_测试方法名(参数):  # 测试方法必须以test开头或test_开头
            pass
        # ....
    
    
  4. 执行测试用例

    unittest.main()
    
    

在实际工作中,我们肯定是在项目中进行测试代码的编写或单独编写一个测试项目,但是我们现在刚开始学习,所以我们可以先编写一个例子代码,对其进行测试,以达到学习的目的。

unittest_01_测试用例的编写.py,代码:

import unittest

# 被测试的代码单元
def add(x,y):
    return x+y

class FuncTest(unittest.TestCase):
    """测试用例"""
    def test_01(self):
        print(add(10, 20))

    def test_02(self):
        print(add("hello", "world"))

    # def test_03(self):
    #     print(add("hello", 20))


# 因为pycharm本身内置了执行unittest的功能,所以不适用以下代码也能执行,但是终端下或者使用其他的代码编辑器时,则需要加上。
if __name__ == '__main__':
    unittest.main()

测试套件-TestSuite

前面我们将到测试套件,主要用于把多个测试用例类打包集成到一个测试集中一起执行。工作中,一个项目往往需要编写非常多的测试用例,而那么多的测试用例也不可能只编写在一个文件中,此时就需要使用测试套件了。2个步骤:

  1. 通过unittest.TestSuite实例化测试套件对象

    suite = unittest.TestSuite()
    
    - addTest,一个一个添加
    - addTests,批量添加
    - suite.countTestCases(),返回suite中用例个数
    
    
  2. 通过addTest方法添加测试用例

    • 添加测试用例方法

      # 添加测试用例方法
      suite.addtest(测试用例类名("测试用例方法名"))
      
      # 批量添加测试用例方法
      test_data = (测试用例类名("测试用例方法名1"), 测试用例类名("测试用例方法名2"))
      suite.addtests(test_data)
      
      
    • 添加测试用例类(一次性添加测试用例的所有test_方法)

      # 添加测试用例类
      suite.addtest(unittest.makeSuite(测试用例类名))
      
      # 批量添加测试用例类
      test_data = (unittest.makeSuite(测试用例类名1),  unittest.makeSuite(测试用例类名2))
      suite.addTests(test_data)
      
      

unittest_02_测试套件的基本使用.py,代码:

import unittest

import unittest_01_测试用例的编写 as unittest_01

suite = unittest.TestSuite()

# # 1. 添加测试用例方法
# suite.addTest(unittest_01.FuncTest("test_01"))
# suite.addTest(unittest_01.FuncTest("test_02"))

# # 2. 批量添加测试用例方法
# test_data = (unittest_01.FuncTest("test_01"), unittest_01.FuncTest("test_02"))
# suite.addTests(test_data)


# # 3. 添加测试用例类
# suite.addTest(unittest.makeSuite(unittest_01.FuncTest))


# 4. 批量添加测试用例类
test_data = (unittest.makeSuite(unittest_01.FuncTest),  unittest.makeSuite(unittest_01.FuncTest))
suite.addTests(test_data)

TestSuite的作用仅仅是把多个测试用例打包集成到一块,但是并没有提供批量执行测试用例的方法,所以我们需要使用TextTestRunner了。

测试运行器-TextTestRunner

前面说过,TextTestRunner是用于执行测试用例、测试套件和输出测试结果的。2个步骤:

  • 实例化运行器对象

  • 通过run方法执行测试

unittest_03_测试运行器基本使用.py,代码:

import unittest

import unittest_01_测试用例的编写 as unittest_01

suite = unittest.TestSuite()

# # 添加测试用例方法
# suite.addTest(unittest_01.FuncTest("test_01"))
# suite.addTest(unittest_01.FuncTest("test_02"))

# # 批量添加测试用例方法
# test_data = (unittest_01.FuncTest("test_01"), unittest_01.FuncTest("test_02"))
# suite.addTests(test_data)


# # 添加测试用例类
# suite.addTest(unittest.makeSuite(unittest_01.FuncTest))


# 批量添加测试用例类
test_data = (unittest.makeSuite(unittest_01.FuncTest),  unittest.makeSuite(unittest_01.FuncTest))
suite.addTests(test_data)

if __name__ == '__main__':
    runner = unittest.TextTestRunner()
    runner.run(suite)

测试加载器-TestLoader

前面说过,用于加载测试用例TestCase,并生成测试套件TestSuite,实现自动从代码中加载大量测试用例到测试套件中。2个步骤:

  • 实例化unittest.TestLoader对象

    loader = unittest.TestLoader()
    
    
  • 使用discover方法自动搜索指定目录下指定文件格式的python模块,并把查找到的测试用例组装打包集成到测试组件作为返回值。

    loader.discover(目录路径, pattern="文件名格式")
    
    

    注意:pattern支持*号表示0到多个字符。

发现其他目录中的脚本用例:

  • unittest.TestLoader().loadTestsFromModule,找到指定模块下面的TestCase的子类,获取其中以test开头的用例。
  • unittest.TestLoader().loadTestsFromModule,获取指定模块中的,指定的用例
import unittest
suite = unittest.TestLoader().loadTestsFromModule(ff_case)
    suite = unittest.TestLoader().loadTestsFromName(
        name="ff_case.TestCase.test_case_01",
        module=ff_case.TestCase
    )
    suite = unittest.TestLoader().loadTestsFromNames(
        names=[
            "ff_case.TestCase.test_case_01",
            "ff_case.TestCase.test_case_02",
        ],
        module=ff_case.TestCase
    )

  • suite = unittest.TestLoader().discover(
    top_level_dir=index_dir, # 顶级目录
    start_dir=SCRIPTS_DIR, # 起始目录
    pattern='unittest_*' # 以unittest开头的python文件
    )

  • top_level_dir和start_dir的关系:

    • top_level_dir == start_dir,没问题
    • top_level_dir > start_dir, 没问题
    • top_level_dir < start_dir, 有问题
  • 注意,discover只会收集Python的包(__init__.py)中以pattern开头的脚本,再找脚本中unittest.TestCase的子类中的以test开头的测试用例

unittest_04_测试加载器基本使用.py,代码:

import unittest
loader = unittest.TestLoader()
# 在当前目录下,搜索以unittest开头作为文件名的所有python文件,并把文件中的测试用例类打包集成到测试套件中
suite =loader.discover("./", pattern="unittest*.py")

if __name__ == '__main__':
    runner = unittest.TextTestRunner()
    runner.run(suite)

测试脚手架-TestFixture

前面提到,测试脚手架会在执行一些测试代码之前与之后,让我们编写一些初始化和销毁的代码,主要分三个级别:

  • 方法级别:在方法执行前与执行后都提供自动调用的实例方法

    setUp和tearDown

  • 类级别:在类执行前与执行后都提供自动调用的类方法,不管类中有多少方法,只执行一次。

    setUpClass和tearDownClass

  • 模块级别:在模块执行前与执行后都提供自动调用的函数,不管模块中有多少类或方法,只执行一次。

    setUpModule和tearDownModule

方法级别的脚手架setUp与tearDown

在测试用例类中提供了2个固定名字的实例方法(setUp与tearDown),用于完成方法执行前与执行后的操作。

unittest_05_测试脚手架_方法级别的脚手架.py,代码:

import unittest

# 被测试的代码单元
def add(x,y):
    return x+y

class AddTest(unittest.TestCase):
    """测试用例"""
    def setUp(self):
        print("每个方法执行前都会执行一遍setUp实例方法,用于完成通用的前置操作或初始化工作")

    def tearDown(self):
        print("每个方法执行后都会执行一遍tearDown实例方法,用于完成通用的后置操作或销毁工作")

    def test_01(self):
        print(add(10, 20))

    def test_03(self):
        print(add("hello", 20))


# 因为pycharm本身内置了执行unittest的功能,所以不适用以下代码也能执行,但是终端下或者使用其他的代码编辑器时,则需要加上。
if __name__ == '__main__':
    unittest.main()


类级别的脚手架setUpClass与tearDownClass

在测试用例类中提供了2个固定名字的类方法(setUpClass与tearDownClass),用于完成类执行前与执行后的操作。

unittest_06_测试脚手架_类级别的脚手架.py,代码:

import unittest

# 被测试的代码单元
def add(x,y):
    return x+y

class AddTest(unittest.TestCase):
    """测试用例"""
    @classmethod
    def setUpClass(cls):
        print("当前类执行前都会执行一遍setUpClass类方法,用于完成通用的前置操作或初始化工作")

    @classmethod
    def tearDownClass(cls):
        print("当前类执行后都会执行一遍tearDownClass类方法,用于完成通用的后置操作或销毁工作")

    def test_01(self):
        print(add(10, 20))

    def test_03(self):
        print(add("hello", 20))


# 因为pycharm本身内置了执行unittest的功能,所以不适用以下代码也能执行,但是终端下或者使用其他的代码编辑器时,则需要加上。
if __name__ == '__main__':
    unittest.main()


模块级别的脚手架setUpModule与tearDownModule

在测试用例类中提供了2个固定名字的函数(setUpModule与tearDownModule),用于完成类执行前与执行后的操作。

unittest_07_测试脚手架_模块级别的脚手架.py,代码:

import unittest


def setUpModule():
    print("当前模块执行前都会执行一遍setUpModule函数,用于完成通用的前置操作或初始化工作")


def tearDownModule():
    print("当前模块执行前都会执行一遍tearDownModule函数,用于完成通用的前置操作或初始化工作")


# 被测试的代码单元
def add(x, y):
    return x + y


class AddTest1(unittest.TestCase):
    """测试用例"""

    @classmethod
    def setUpClass(cls):
        print("当前类执行前都会执行一遍setUpClass类方法,用于完成通用的前置操作或初始化工作")

    @classmethod
    def tearDownClass(cls):
        print("当前类执行后都会执行一遍tearDownClass类方法,用于完成通用的后置操作或销毁工作")

    def test_01(self):
        print(add(10, 20))


class AddTest2(unittest.TestCase):
    """测试用例"""

    @classmethod
    def setUpClass(cls):
        print("当前类执行前都会执行一遍setUp方法,用于完成通用的前置操作或初始化工作")

    @classmethod
    def tearDownClass(cls):
        print("当前类执行后都会执行一遍tearDown方法,用于完成通用的后置操作或销毁工作")

    def test_03(self):
        print(add("hello", 20))


# 因为pycharm本身内置了执行unittest的功能,所以不适用以下代码也能执行,但是终端下或者使用其他的代码编辑器时,则需要加上。
if __name__ == '__main__':
    unittest.main()


生成HTML格式测试报告(很少使用,基本不使用)

参考博客

使用HTMLTestRunner模块可以直接生成HTML格式的报告。HTMLTestRunner是一个不再维护的第三方的模块,通过pip工具安装不了,只能下载后手动导入。

HTMLTestRunner官网:http://tungwaiyip.info/software/HTMLTestRunner.html

HTMLTestRunner下载:http://tungwaiyip.info/software/HTMLTestRunner_0_8_2/HTMLTestRunner.py

因为HTMLTestRunner是python2模块的,所以在python3.x以后需要做些修改才可以使用,修改后版本代码如下:

"""
A TestRunner for use with the Python unit testing framework. It
generates a HTML report to show the result at a glance.

The simplest way to use this is to invoke its main method. E.g.

    import unittest
    import HTMLTestRunner

    ... define your tests ...

    if __name__ == '__main__':
        HTMLTestRunner.main()


For more customization options, instantiates a HTMLTestRunner object.
HTMLTestRunner is a counterpart to unittest's TextTestRunner. E.g.

    # output to a file
    fp = file('my_report.html', 'wb')
    runner = HTMLTestRunner.HTMLTestRunner(
                stream=fp,
                title='My unit test',
                description='This demonstrates the report output by HTMLTestRunner.'
                )

    # Use an external stylesheet.
    # See the Template_mixin class for more customizable options
    runner.STYLESHEET_TMPL = '<link rel="stylesheet" href="my_stylesheet.css" type="text/css">'

    # run the test
    runner.run(my_test_suite)


------------------------------------------------------------------------
Copyright (c) 2004-2007, Wai Yip Tung
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

* Redistributions of source code must retain the above copyright notice,
  this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
  notice, this list of conditions and the following disclaimer in the
  documentation and/or other materials provided with the distribution.
* Neither the name Wai Yip Tung nor the names of its contributors may be
  used to endorse or promote products derived from this software without
  specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""

# URL: http://tungwaiyip.info/software/HTMLTestRunner.html

__author__ = "Wai Yip Tung"
__version__ = "0.8.2"


"""
Change History

Version 0.8.2
* Show output inline instead of popup window (Viorel Lupu).

Version in 0.8.1
* Validated XHTML (Wolfgang Borgert).
* Added description of test classes and test cases.

Version in 0.8.0
* Define Template_mixin class for customization.
* Workaround a IE 6 bug that it does not treat <script> block as CDATA.

Version in 0.7.1
* Back port to Python 2.3 (Frank Horowitz).
* Fix missing scroll bars in detail log (Podi).
"""

# TODO: color stderr
# TODO: simplify javascript using ,ore than 1 class in the class attribute?

import datetime
import io
import sys
import time
import unittest
from xml.sax import saxutils


# ------------------------------------------------------------------------
# The redirectors below are used to capture output during testing. Output
# sent to sys.stdout and sys.stderr are automatically captured. However
# in some cases sys.stdout is already cached before HTMLTestRunner is
# invoked (e.g. calling logging.basicConfig). In order to capture those
# output, use the redirectors for the cached stream.
#
# e.g.
#   >>> logging.basicConfig(stream=HTMLTestRunner.stdout_redirector)
#   >>>

class OutputRedirector(object):
    """ Wrapper to redirect stdout or stderr """
    def __init__(self, fp):
        self.fp = fp

    def write(self, s):
        self.fp.write(s)

    def writelines(self, lines):
        self.fp.writelines(lines)

    def flush(self):
        self.fp.flush()

stdout_redirector = OutputRedirector(sys.stdout)
stderr_redirector = OutputRedirector(sys.stderr)



# ----------------------------------------------------------------------
# Template

class Template_mixin(object):
    """
    Define a HTML template for report customerization and generation.

    Overall structure of an HTML report

    HTML
    +------------------------+
    |<html>                  |
    |  <head>                |
    |                        |
    |   STYLESHEET           |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |  </head>               |
    |                        |
    |  <body>                |
    |                        |
    |   HEADING              |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |   REPORT               |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |   ENDING               |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |  </body>               |
    |</html>                 |
    +------------------------+
    """

    STATUS = {
    0: 'pass',
    1: 'fail',
    2: 'error',
    }

    DEFAULT_TITLE = 'Unit Test Report'
    DEFAULT_TESTER = 'Unit Test'
    DEFAULT_DESCRIPTION = ''

    # ------------------------------------------------------------------------
    # HTML Template

    HTML_TMPL = r"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>%(title)s</title>
    <meta name="generator" content="%(generator)s"/>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    %(stylesheet)s
</head>
<body>
<script language="javascript" type="text/javascript"><!--
output_list = Array();

/* level - 0:Summary; 1:Failed; 2:All */
function showCase(level) {
    trs = document.getElementsByTagName("tr");
    for (var i = 0; i < trs.length; i++) {
        tr = trs[i];
        id = tr.id;
        if (id.substr(0,2) == 'ft') {
            if (level < 1) {
                tr.className = 'hiddenRow';
            }
            else {
                tr.className = '';
            }
        }
        if (id.substr(0,2) == 'pt') {
            if (level > 1) {
                tr.className = '';
            }
            else {
                tr.className = 'hiddenRow';
            }
        }
    }
}


function showClassDetail(cid, count) {
    var id_list = Array(count);
    var toHide = 1;
    for (var i = 0; i < count; i++) {
        tid0 = 't' + cid.substr(1) + '.' + (i+1);
        tid = 'f' + tid0;
        tr = document.getElementById(tid);
        if (!tr) {
            tid = 'p' + tid0;
            tr = document.getElementById(tid);
        }
        id_list[i] = tid;
        if (tr.className) {
            toHide = 0;
        }
    }
    for (var i = 0; i < count; i++) {
        tid = id_list[i];
        if (toHide) {
            document.getElementById('div_'+tid).style.display = 'none'
            document.getElementById(tid).className = 'hiddenRow';
        }
        else {
            document.getElementById(tid).className = '';
        }
    }
}


function showTestDetail(div_id){
    var details_div = document.getElementById(div_id)
    var displayState = details_div.style.display
    // alert(displayState)
    if (displayState != 'block' ) {
        displayState = 'block'
        details_div.style.display = 'block'
    }
    else {
        details_div.style.display = 'none'
    }
}


function html_escape(s) {
    s = s.replace(/&/g,'&amp;');
    s = s.replace(/</g,'&lt;');
    s = s.replace(/>/g,'&gt;');
    return s;
}

/* obsoleted by detail in <div>
function showOutput(id, name) {
    var w = window.open("", //url
                    name,
                    "resizable,scrollbars,status,width=800,height=450");
    d = w.document;
    d.write("<pre>");
    d.write(html_escape(output_list[id]));
    d.write("\n");
    d.write("<a href='javascript:window.close()'>close</a>\n");
    d.write("</pre>\n");
    d.close();
}
*/
--></script>

%(heading)s
%(report)s
%(ending)s

</body>
</html>
"""
    # variables: (title, generator, stylesheet, heading, report, ending)


    # ------------------------------------------------------------------------
    # Stylesheet
    #
    # alternatively use a <link> for external style sheet, e.g.
    #   <link rel="stylesheet" href="$url" type="text/css">

    STYLESHEET_TMPL = """
<style type="text/css" media="screen">
body        { font-family: verdana, arial, helvetica, sans-serif; font-size: 80%; }
table       { font-size: 100%; }
pre         { }

/* -- heading ---------------------------------------------------------------------- */
h1 {
  font-size: 16pt;
  color: gray;
}
.heading {
    margin-top: 0ex;
    margin-bottom: 1ex;
}

.heading .attribute {
    margin-top: 1ex;
    margin-bottom: 0;
}

.heading .description {
    margin-top: 4ex;
    margin-bottom: 6ex;
}

/* -- css div popup ------------------------------------------------------------------------ */
a.popup_link {
}

a.popup_link:hover {
    color: red;
}

.popup_window {
    display: none;
    position: relative;
    left: 0px;
    top: 0px;
    /*border: solid #627173 1px; */
    padding: 10px;
    background-color: #E6E6D6;
    font-family: "Lucida Console", "Courier New", Courier, monospace;
    text-align: left;
    font-size: 8pt;
    width: 500px;
}

}
/* -- report ------------------------------------------------------------------------ */
#show_detail_line {
    margin-top: 3ex;
    margin-bottom: 1ex;
}
#result_table {
    width: 80%;
    border-collapse: collapse;
    border: 1px solid #777;
}
#header_row {
    font-weight: bold;
    color: white;
    background-color: #777;
}
#result_table td {
    border: 1px solid #777;
    padding: 2px;
}
#total_row  { font-weight: bold; }
.passClass  { background-color: #6c6; }
.failClass  { background-color: #c60; }
.errorClass { background-color: #c00; }
.passCase   { color: #6c6; }
.failCase   { color: #c60; font-weight: bold; }
.errorCase  { color: #c00; font-weight: bold; }
.hiddenRow  { display: none; }
.testcase   { margin-left: 2em; }


/* -- ending ---------------------------------------------------------------------- */
#ending {
}

</style>
"""



    # ------------------------------------------------------------------------
    # Heading
    #

    HEADING_TMPL = """<div class='heading'>
<h1>%(title)s</h1>
%(parameters)s
<p class='description'>%(description)s</p>
</div>

""" # variables: (title, parameters, description)

    HEADING_ATTRIBUTE_TMPL = """<p class='attribute'><strong>%(name)s:</strong> %(value)s</p>
""" # variables: (name, value)



    # ------------------------------------------------------------------------
    # Report
    #

    REPORT_TMPL = """
<p id='show_detail_line'>Show
<a href='javascript:showCase(0)'>Summary</a>
<a href='javascript:showCase(1)'>Failed</a>
<a href='javascript:showCase(2)'>All</a>
</p>
<table id='result_table'>
<colgroup>
<col align='left' />
<col align='right' />
<col align='right' />
<col align='right' />
<col align='right' />
<col align='right' />
</colgroup>
<tr id='header_row'>
    <td>Test Group/Test case</td>
    <td>Count</td>
    <td>Pass</td>
    <td>Fail</td>
    <td>Error</td>
    <td>View</td>
</tr>
%(test_list)s
<tr id='total_row'>
    <td>Total</td>
    <td>%(count)s</td>
    <td>%(Pass)s</td>
    <td>%(fail)s</td>
    <td>%(error)s</td>
    <td>&nbsp;</td>
</tr>
</table>
""" # variables: (test_list, count, Pass, fail, error)

    REPORT_CLASS_TMPL = r"""
<tr class='%(style)s'>
    <td>%(desc)s</td>
    <td>%(count)s</td>
    <td>%(Pass)s</td>
    <td>%(fail)s</td>
    <td>%(error)s</td>
    <td><a href="javascript:showClassDetail('%(cid)s',%(count)s)">Detail</a></td>
</tr>
""" # variables: (style, desc, count, Pass, fail, error, cid)


    REPORT_TEST_WITH_OUTPUT_TMPL = r"""
<tr id='%(tid)s' class='%(Class)s'>
    <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
    <td colspan='5' align='center'>

    <!--css div popup start-->
    <a class="popup_link" onfocus='this.blur();' href="javascript:showTestDetail('div_%(tid)s')" >
        %(status)s</a>

    <div id='div_%(tid)s' class="popup_window">
        <div style='text-align: right; color:red;cursor:pointer'>
        <a onfocus='this.blur();' onclick="document.getElementById('div_%(tid)s').style.display = 'none' " >
           [x]</a>
        </div>
        <pre>
        %(script)s
        </pre>
    </div>
    <!--css div popup end-->

    </td>
</tr>
""" # variables: (tid, Class, style, desc, status)


    REPORT_TEST_NO_OUTPUT_TMPL = r"""
<tr id='%(tid)s' class='%(Class)s'>
    <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
    <td colspan='5' align='center'>%(status)s</td>
</tr>
""" # variables: (tid, Class, style, desc, status)


    REPORT_TEST_OUTPUT_TMPL = r"""
%(id)s: %(output)s
""" # variables: (id, output)



    # ------------------------------------------------------------------------
    # ENDING
    #

    ENDING_TMPL = """<div id='ending'>&nbsp;</div>"""

# -------------------- The end of the Template class -------------------


TestResult = unittest.TestResult

class _TestResult(TestResult):
    # note: _TestResult is a pure representation of results.
    # It lacks the output and reporting ability compares to unittest._TextTestResult.

    def __init__(self, verbosity=1):
        TestResult.__init__(self)
        self.stdout0 = None
        self.stderr0 = None
        self.success_count = 0
        self.failure_count = 0
        self.error_count = 0
        self.verbosity = verbosity

        # result is a list of result in 4 tuple
        # (
        #   result code (0: success; 1: fail; 2: error),
        #   TestCase object,
        #   Test output (byte string),
        #   stack trace,
        # )
        self.result = []


    def startTest(self, test):
        TestResult.startTest(self, test)
        # just one buffer for both stdout and stderr
        self.outputBuffer = io.StringIO()
        stdout_redirector.fp = self.outputBuffer
        stderr_redirector.fp = self.outputBuffer
        self.stdout0 = sys.stdout
        self.stderr0 = sys.stderr
        sys.stdout = stdout_redirector
        sys.stderr = stderr_redirector


    def complete_output(self):
        """
        Disconnect output redirection and return buffer.
        Safe to call multiple times.
        """
        if self.stdout0:
            sys.stdout = self.stdout0
            sys.stderr = self.stderr0
            self.stdout0 = None
            self.stderr0 = None
        return self.outputBuffer.getvalue()


    def stopTest(self, test):
        # Usually one of addSuccess, addError or addFailure would have been called.
        # But there are some path in unittest that would bypass this.
        # We must disconnect stdout in stopTest(), which is guaranteed to be called.
        self.complete_output()


    def addSuccess(self, test):
        self.success_count += 1
        TestResult.addSuccess(self, test)
        output = self.complete_output()
        self.result.append((0, test, output, ''))
        if self.verbosity > 1:
            sys.stderr.write('ok ')
            sys.stderr.write(str(test))
            sys.stderr.write('\n')
        else:
            sys.stderr.write('.')

    def addError(self, test, err):
        self.error_count += 1
        TestResult.addError(self, test, err)
        _, _exc_str = self.errors[-1]
        output = self.complete_output()
        self.result.append((2, test, output, _exc_str))
        if self.verbosity > 1:
            sys.stderr.write('E  ')
            sys.stderr.write(str(test))
            sys.stderr.write('\n')
        else:
            sys.stderr.write('E')

    def addFailure(self, test, err):
        self.failure_count += 1
        TestResult.addFailure(self, test, err)
        _, _exc_str = self.failures[-1]
        output = self.complete_output()
        self.result.append((1, test, output, _exc_str))
        if self.verbosity > 1:
            sys.stderr.write('F  ')
            sys.stderr.write(str(test))
            sys.stderr.write('\n')
        else:
            sys.stderr.write('F')


class HTMLTestRunner(Template_mixin):
    """
    """
    def __init__(self, stream=sys.stdout, verbosity=1, title=None, description=None, tester=None):
        self.stream = stream
        self.verbosity = verbosity
        if title is None:
            self.title = self.DEFAULT_TITLE
        else:
            self.title = title
        if description is None:
            self.description = self.DEFAULT_DESCRIPTION
        else:
            self.description = description

        self.startTime = datetime.datetime.now()
        if tester is None:
            self.tester = self.DEFAULT_TITLE
        else:
            self.tester = tester

    def run(self, test):
        "Run the given test case or test suite."
        result = _TestResult(self.verbosity)
        test(result)
        self.stopTime = datetime.datetime.now()
        self.generateReport(test, result)
        print (sys.stderr, "\nTime Elapsed: %s" %(self.stopTime-self.startTime))
        return result


    def sortResult(self, result_list):
        # unittest does not seems to run in any particular order.
        # Here at least we want to group them together by class.
        rmap = {}
        classes = []
        for n,t,o,e in result_list:
            cls = t.__class__
            if not cls in rmap:
                rmap[cls] = []
                classes.append(cls)
            rmap[cls].append((n,t,o,e))
        r = [(cls, rmap[cls]) for cls in classes]
        return r


    def getReportAttributes(self, result):
        """
        Return report attributes as a list of (name, value).
        Override this to add custom attributes.
        """
        startTime = str(self.startTime)[:19]
        duration = str(self.stopTime - self.startTime)
        status = []
        if result.success_count: status.append('Pass %s'    % result.success_count)
        if result.failure_count: status.append('Failure %s' % result.failure_count)
        if result.error_count:   status.append('Error %s'   % result.error_count  )
        if status:
            status = ' '.join(status)
        else:
            status = 'none'
        return [
            ('Start Time', startTime),
            ('Duration', duration),
            ('Status', status),
            ('Tester', self.tester),
        ]


    def generateReport(self, test, result):
        report_attrs = self.getReportAttributes(result)
        generator = 'HTMLTestRunner %s' % __version__
        stylesheet = self._generate_stylesheet()
        heading = self._generate_heading(report_attrs)
        report = self._generate_report(result)
        ending = self._generate_ending()
        output = self.HTML_TMPL % dict(
            title = saxutils.escape(self.title),
            generator = generator,
            stylesheet = stylesheet,
            heading = heading,
            report = report,
            ending = ending,
        )
        self.stream.write(output.encode('utf8'))


    def _generate_stylesheet(self):
        return self.STYLESHEET_TMPL


    def _generate_heading(self, report_attrs):
        a_lines = []
        for name, value in report_attrs:
            line = self.HEADING_ATTRIBUTE_TMPL % dict(
                    name = saxutils.escape(name),
                    value = saxutils.escape(value),
                )
            a_lines.append(line)
        heading = self.HEADING_TMPL % dict(
            title = saxutils.escape(self.title),
            parameters = ''.join(a_lines),
            description = saxutils.escape(self.description),
        )
        return heading


    def _generate_report(self, result):
        rows = []
        sortedResult = self.sortResult(result.result)
        for cid, (cls, cls_results) in enumerate(sortedResult):
            # subtotal for a class
            np = nf = ne = 0
            for n,t,o,e in cls_results:
                if n == 0: np += 1
                elif n == 1: nf += 1
                else: ne += 1

            # format class description
            if cls.__module__ == "__main__":
                name = cls.__name__
            else:
                name = "%s.%s" % (cls.__module__, cls.__name__)
            doc = cls.__doc__ and cls.__doc__.split("\n")[0] or ""
            desc = doc and '%s: %s' % (name, doc) or name

            row = self.REPORT_CLASS_TMPL % dict(
                style = ne > 0 and 'errorClass' or nf > 0 and 'failClass' or 'passClass',
                desc = desc,
                count = np+nf+ne,
                Pass = np,
                fail = nf,
                error = ne,
                cid = 'c%s' % (cid+1),
            )
            rows.append(row)

            for tid, (n,t,o,e) in enumerate(cls_results):
                self._generate_report_test(rows, cid, tid, n, t, o, e)

        report = self.REPORT_TMPL % dict(
            test_list = ''.join(rows),
            count = str(result.success_count+result.failure_count+result.error_count),
            Pass = str(result.success_count),
            fail = str(result.failure_count),
            error = str(result.error_count),
        )
        return report


    def _generate_report_test(self, rows, cid, tid, n, t, o, e):
        # e.g. 'pt1.1', 'ft1.1', etc
        has_output = bool(o or e)
        tid = (n == 0 and 'p' or 'f') + 't%s.%s' % (cid+1,tid+1)
        name = t.id().split('.')[-1]
        doc = t.shortDescription() or ""
        desc = doc and ('%s: %s' % (name, doc)) or name
        tmpl = has_output and self.REPORT_TEST_WITH_OUTPUT_TMPL or self.REPORT_TEST_NO_OUTPUT_TMPL

        # o and e should be byte string because they are collected from stdout and stderr?
        if isinstance(o,str):
            # TODO: some problem with 'string_escape': it escape \n and mess up formating
            # uo = unicode(o.encode('string_escape'))
            uo=o
        else:
            uo = o

        ue = e

        script = self.REPORT_TEST_OUTPUT_TMPL % dict(
            id = tid,
            output = saxutils.escape(uo+ue),
        )

        row = tmpl % dict(
            tid = tid,
            Class = (n == 0 and 'hiddenRow' or 'none'),
            style = n == 2 and 'errorCase' or (n == 1 and 'failCase' or 'none'),
            desc = desc,
            script = script,
            status = self.STATUS[n],
        )
        rows.append(row)
        if not has_output:
            return

    def _generate_ending(self):
        return self.ENDING_TMPL


##############################################################################
# Facilities for running tests from the command line
##############################################################################

# Note: Reuse unittest.TestProgram to launch test. In the future we may
# build our own launcher to support more specific command line
# parameters like test title, CSS, etc.
class TestProgram(unittest.TestProgram):
    """
    A variation of the unittest.TestProgram. Please refer to the base
    class for command line parameters.
    """
    def runTests(self):
        # Pick HTMLTestRunner as the default test runner.
        # base class's testRunner parameter is not useful because it means
        # we have to instantiate HTMLTestRunner before we know self.verbosity.
        if self.testRunner is None:
            self.testRunner = HTMLTestRunner(verbosity=self.verbosity)
        unittest.TestProgram.runTests(self)

main = TestProgram

##############################################################################
# Executing this module from the command line
##############################################################################

if __name__ == "__main__":
    main(module=None)


打印HTML格式的测试报告,unittest_08_打印HTML格式的测试报告.py,代码:

import unittest
from HTMLTestRunner import HTMLTestRunner
import unittest_01_测试用例的编写 as unittest_01

suite = unittest.TestSuite()

test_data = (unittest.makeSuite(unittest_01.FuncTest),  unittest.makeSuite(unittest_01.FuncTest))
suite.addTests(test_data)

if __name__ == '__main__':
    # 生成html报告
    with open("test_report.html", "wb") as file:
        runner = HTMLTestRunner(
            stream=file,
            title="单元测试的HTML格式报告",
            description="python单元测试报告",
            tester="墨落"
        )
        runner.run(suite)


断言

断言(assertion)是一种在程序中的判断测试用例执行结果是否符合预期结果的方式,所以断言也被称之为“期望”。当程序执行到断言的位置时,对应的断言应该为真。若断言不为真时,程序会中止执行,并给出错误信息。

unittest中常用的断言方法(加粗为重要方法):

断言方法 断言描述
assertEqual(arg1, arg2, msg=None) 验证arg1=arg2,不等则fail
assertNotEqual(arg1, arg2, msg=None) 验证arg1 != arg2, 相等则fail
assertTrue(expr, msg=None) 验证expr是true,如果为false,则fail
assertFalse(expr,msg=None) 验证expr是false,如果为true,则fail
assertIs(arg1, arg2, msg=None) 验证arg1、arg2是同一个对象,不是则fail
assertIsNot(arg1, arg2, msg=None) 验证arg1、arg2不是同一个对象,是则fail
assertIsNone(expr, msg=None) 验证expr是None,不是则fail
assertIsNotNone(expr, msg=None) 验证expr不是None,是则fail
assertIn(arg1, arg2, msg=None) 验证arg1是arg2的子串,不是则fail
assertNotIn(arg1, arg2, msg=None) 验证arg1不是arg2的子串,是则fail
assertIsInstance(obj, cls, msg=None) 验证obj是cls的实例,不是则fail
assertNotIsInstance(obj, cls, msg=None) 验证obj不是cls的实例,是则fail

unittest_09_断言.py,代码:

import unittest

def add(x ,y):
    return x + y


class AddTest(unittest.TestCase):
    def test_01(self):
        res = add(1,2)
        # 断言结果是否与预期内容相同
        # self.assertEqual(res, 3, msg="断言失败!一般会错误的结果与原因")
        # self.assertEqual(res, 2, msg="断言失败!一般会错误的结果与原因")
        self.assertIn(res, [1, 2], msg="断言失败!一般会错误的结果与原因")

if __name__ == '__main__':
    unittest.main()



用例执行成功用.表示,执行失败是F表示。
unittest.main()会自动的找到当前模块的unittest.TestCase的子类,然后找该子类内部以test开头的用例名,完事去一一的执行它们。

跳过

针对开发中有时候针对不同环境或者不同的时间段,不同的代码版本,有时候部分测试用例不希望被执行,则可以使用跳过。

@ unittest.skip(reason)   # 跳过用例的描述
@ unittest.skipif(condition, reason)   # 跳过的条件,跳过的原因

unittest_10_跳过.py,代码:

import unittest


class MyCase(unittest.TestCase):


    def test_case_01(self):
        self.assertTrue(1)


    @unittest.skip(reason='无条件跳过')
    def test_case_02(self):
        self.assertTrue("")

    @unittest.skipIf(condition=3 < 2, reason='有条件跳过')
    def test_case_03(self):
        self.assertTrue(0)


if __name__ == '__main__':
    unittest.main(verbosity=2)

在输出的窗口中,跳过的用例用s表示;断言成功.表示;断言失败F表示。

参数化

当需要使用多组不同的测试数据测试同一个方法时,可以使用unittest参数化来解决。常用的参数化方法有ddt、parameterized

pip install parameterized


unittest_11_参数化.py,代码:

import unittest
from parameterized import parameterized

def add(x, y):
    return x + y


version = (2, 7, 0)

class AddTest(unittest.TestCase):
    def setUp(self):
        print("setUP执行....")

    @parameterized.expand([(10,20), ("a","B"), (50, 20)])
    def test_00(self, x, y):
        res = add(x, y)
        self.assertIn(res, [1, 30, "aB", 70], msg="断言失败!一般会错误的结果与原因")


    # def test_01(self):
    #     res = add(1, 2)
    #     self.assertIn(res, [1, 3], msg="断言失败!一般会错误的结果与原因")
    #
    # def test_02(self):
    #     res = add("a", "B")
    #     self.assertEqual(res, "aB", msg="断言失败!一般会错误的结果与原因")
    #
    # def test_03(self):
    #     print(add("a", 20))

if __name__ == '__main__':
    unittest.main()


数据驱动测试

Data-Driven Tests(DDT)即数据驱动测试,可以实现多个数据对同一个方法进行测试,达到数据和测试代码分离,目的是为了减少测试用例的数量。

基本安装

pip install ddt


直接传递单个数据

unittest_12_参数化_基于ddt直接传递数据.py,代码:

import unittest
from ddt import ddt, data

def add(a,b):
    return a+b

@ddt
class AddTest(unittest.TestCase):
    # # 单次传递一个数据到测试用例方法中
    # @data(100)
    # @data([1,2,3,4])
    # @data({"a":1,"b":2})
    # @data((1,2,3))

    # # 多次传递一个数据到测试用例方法中
    # @data(*["a","b","c"]) # 字符串
    # @data(*[{"a":1}, {"a":2}, {"a":3}]) # 字典
    # @data(*[[1, 1, 1], [1, 1, 2], [1, 1, 3]])
    @data([1, 1, 1], [1, 1, 2], [1, 1, 3])
    def test_01(self, a):
        print(a)


if __name__ == '__main__':
    unittest.main()


unittest_13_参数化-基于ddt解包传递多个数据.py,使用unpack装饰器解包数据

import unittest
from ddt import ddt, data, unpack

def add(a, b, c):
    return a + b + c

@ddt
class AddTest(unittest.TestCase):
    @data((1,2,3),(1,2,1),(1,3,1),(1,1,3))
    @unpack
    def test_01(self,a,b,c):
        add(a,b,c)

if __name__ == '__main__':
    unittest.main()


发送邮件

参考:https://www.cnblogs.com/Neeo/articles/11478853.html
参考:https://www.cnblogs.com/Neeo/articles/11199127.html#通过smtp发邮件

  1. 去QQ邮箱配置SMTP服务器和获取授权码
  2. 编写测试用例
  3. 生成测试报告
  4. 使用第三方邮件服务发送测试报告

单元测试框架-Pytest

Pytest是基于Python语言的单元测试框架,也是一个命令行的工具,比 unittest 测试框架更灵活。具有以下特点:

  1. 入门简单,易上手,官方文档丰富而且使用广泛,有大量的参数例子。
  2. unittest有的,它都有,而且支持更为复杂的功能测试
  3. 支持大量第三方插件,如:失败重试、控制用例执行顺序等。
  4. 基于配置文件可以很简单的集成CI(持续集成)工具中。

安装

pip install pytest

# 测试是否安装成功
pytest --version


快速入门

基本格式

pytest_01_基本格式.py,代码:

def add(x, y):
    return x + y

class TestAddFunc(object):  # 测试用例类名必须用Test开头,若执行收集不到用例则将文件名改为test_开头试试
    def test_01(self):   # 方法名与函数名必须要用test_开头
        print(add(10, 20))

    def test_02(self):
        print(add("a", "B"))

    def test_03(self):
        print(add("a", 20))


测试运行

pytest提供了三种方式给测试人员执行测试用例:

  • 命令行运行

    pytest -s 文件名
    pytest -s -v 文件名
    # -s 输出测试用例的print语句打印的信息
    # -v 输出执行的测试用用例的类名以及方法名
    # -x 一旦发现失败用例,立即停止继续运行
    # -maxfail=2 当测试遇到2条失败用例,立即停止继续运行
    
    
    
  • Pycharm运行

    file -> settings,打开配置窗口。

    image-20220515134914741

  • main函数运行(基本不用)

    pytest.main(["模块文件名::类名::方法名", "参数"])
    pytest.main(["pytest_01_基本格式.py::TEstAddFunc::test_01", "-sv"])
    
    
    
if __name__ == '__main__':
    # pytest.main() # 收集全部以test_开头的文件及其下面的类与方法
    # pytest.main(['test_BTDFSE.py::Test_BTDFSE', '-sv'])
    # os.system('pytest -s -v test_BTDFSE.py')

    # 先删除旧的测试报告
    try:
        shutil.rmtree("./allure_results")
        shutil.rmtree("./allure_reports")
    except:
        pass
    # 运行pytest执行测试测试用例,并生成json测试报告
    pytest.main(['test_BTDFSE.py::Test_BTDFSE', '-sv', '--alluredir', './allure_results'])

    # 基于os.system执行终端名,生成HTML格式文档的测试报告
    os.system("allure generate ./allure_results -o ./allure_reports --clean")

    # 基于http协议打开HTML测试报告
    os.system('allure open ./allure_reports')


测试脚手架

方法级别:setup与teardown

类级别:setup_class与teardown_class,注意:这是实例方法,不是类方法

模块级别:setup_module与teardown_module

pytest_02_测试脚手架.py,代码:

def add(x, y):
    return x + y


def setup_module():
    print("模块执行初始化操作")

def teardown_module():
    print("模块执行初始化putest")

class TestAddFunc(object):  # 测试用例类名必须用Test开头
    def setup(self):
        print('setup执行初始化操作')
    def teardown(self):
        print('teardown执销毁操作')

    def setup_class(self):    # 注意:此处方法类型是实例方法。
        print('类级别:setup_class执行初始化操作')

    def teardown_class(self):  # 注意:此处方法类型是实例方法。
        print('类级别:teardown_class执行初始化操作')



    def test_01(self):   # 方法名与函数名必须要用test_开头
        print(add(10, 20))

    def test_02(self):
        print(add("a", "B"))

    def test_03(self):
        print(add(20, 20))



基于配置文件运行pytest

在pytest提供的终端运行测试用例的方式的基础上,pytest还支持使用配置文件来简化运行参数。

可以通过pytest --help 查看pytest配置文件的名:pytest.ini、tox.ini、setup.cfg

配置文件一般保存在项目根目录下。

pytest.ini,配置文件格式:

; 命名空间,表示以下选项属于pytest配置
[pytest]
; 运行参数
addopts = -s -v
; 匹配搜索的测试文件的目录路径
testpaths = ./
; 匹配搜索的测试文件名格式
python_files = test_*.py
; 匹配搜索的测试类格式
python_classes = Test*
; 匹配搜索的测试方法名格式
python_functions = test_*
; markers
markers = "测试标记-一般就是本轮测试的项目名或者模块"


上面的注释,必须清除否则报错。有了配置文件以后,使用pytest命令即可运行测试用例。

pytest


断言

Pytest的断言比unittest提供的断言更加简单易用,仅仅只需要使用assert关键字,后续跟上python原生的表达式即可。

assert "m" in "moluo"
assert "m" not in "moluo"
assert 1 == 2
assert 1 != 1
assert 1 > 2
assert not True
assert type(1) is int
assert type(1) not is int


pytest_03_断言.py,代码:

def add(x, y):
    return x + y


class TestAddFunc(object):  # 测试用例类名必须用Test开头
    def test_01(self):   # 方法名与函数名必须要用test_开头
        res = add(10, 20)
        assert res == 30

    def test_02(self):
        res = add("a", "B")
        assert type(res) is int

    def test_03(self):
        res = add(20, 20)
        assert res != 20


跳过

根据特定的条件,不执行标识的测试函数。

@pytest.mark.skipif(判断条件, reason="跳过原因")


pytest_04_跳过.py,代码:

import pytest


def add(x, y):
    return x + y

version = (2, 7, 12)

class TestAddFunc(object):  # 测试用例类名必须用Test开头
    def test_01(self):   # 方法名与函数名必须要用test_开头
        res = add(10, 20)
        assert res == 30

    @pytest.mark.skipif(version <= (2, 7, 12), reason="高于2.7以下,不测试test_02")
    def test_02(self):
        res = add("a", "B")
        assert type(res) is int

    def test_03(self):
        res = add(20, 20)
        assert res != 20


参数化

pytest也支持参数化操作,而且不需要安装任何第三方模块即可使用,也不再需要ddt。

import pytest


def add(x, y):
    return x + y


class TestAddFunc(object):  # 测试用例类名必须用Test开头
    @pytest.mark.parametrize("x,y", [(10, 20), {"x":10, "y":20}, ("a", "b"), ("a", 20)])
    def test_01(self, x, y):   # 方法名与函数名必须要用test_开头
        res = add(x, y)
        assert res == 30


进阶使用

fixture-脚手架

在Unittest中我们经常需要针对不同的测试用例使用脚手架完成一些测试的前置与后置操作,但是很多测试用例的前置与后置操作基本一样,所以pytest提供的fixture脚手架相比Unittest提供的脚手架进行了显著改进:

  1. 有独立的命名,并通过声明它们从测试函数、模块、类或整个项目中的使用来激活脚手架。
  2. 也可以按模块化的方式实现,每个fixture脚手架都可以在各个测试用例之间互相调用。
  3. fixture的范围从简单的单元测试到复杂的功能测试,可以对fixture配置参数,或者跨函数function,类class,模块module或整个测试项目范围session。

pytest的fixture有个scope参数可以控制fixture的作用范围(从大到小):session>module>class>function。

session:是多个文件调用一次,可以跨.py文件调用,每个.py文件就是module
module:每一个.py文件调用一次,该文件内又有多个function和class
class:每一个类调用一次,一个类中可以有多个方法
function:每一个函数或方法都会调用


实现参数化效果
import pytest


# 类级别的脚手架
@pytest.fixture(scope="class")
def fixture_01_data():  # 建议脚手架的函数名以fixture开头.
    a = 10
    b = 20
    print("脚手架运行了!")
    return a, b  # 脚手架的函数可以有返回值,也可以没有返回值


# 被测试的代码单元
def add(x, y):
    return x + y


class TestAddFunc(object):
    def test_01(self, fixture_01_data):  # 此处的参数名,就是上面的脚手架名称,注意:参数名要与上面的脚手架函数保持一致
        print(f"fixture_01_data={fixture_01_data}")
        res = add(fixture_01_data[0], fixture_01_data[1])
        assert res == 30



使用装饰器修饰用例实现参数化效果
import pytest


@pytest.fixture(scope="class")
def fixture_01_data():
    print("fixture_01_data")
    a = 10
    b = 20
    return a, b

@pytest.fixture(scope="class")
def fixture_02_data():
    print("fixture_02_data")
    a = "10"
    b = "20"
    return a, b


def add(x, y):
    return x + y


@pytest.mark.usefixtures("fixture_01_data")  #  此处参数为脚手架函数名
@pytest.mark.usefixtures("fixture_02_data")
class TestAddFunc(object):
    def test_01(self, fixture_01_data):
        res = add(fixture_01_data[0], fixture_01_data[1])
        assert res == 30

    def test_02(self, fixture_02_data):
        res = add(fixture_02_data[0], fixture_02_data[1])
        assert res == 30



fixture自动执行

fixture提供了autouse=True的参数选项,让我们可以不需要装饰,就可以直接自动执行。

代码:

import pytest


@pytest.fixture(scope="class", autouse=True)
def fixture_01_data():
    print("fixture_01_data")
    a = 10
    b = 20
    return a, b


@pytest.fixture(scope="class", autouse=True)
def fixture_02_data():
    print("fixture_02_data")
    a = "10"
    b = "20"
    return a, b


def add(x, y):
    return x + y

@pytest.mark.usefixtures("fixture_01_data")  #  此处参数为脚手架函数名
@pytest.mark.usefixtures("fixture_02_data")
class TestAddFunc(object):
    def test_01(self, fixture_01_data):
        res = add(fixture_01_data[0], fixture_01_data[1])
        assert res == 30

    def test_02(self, fixture_02_data):
        res = add(fixture_02_data[0], fixture_02_data[1])
        assert res == 30


使用yield实现setup/teardown效果

pytest的fixture不仅可以在单元测试、集成测试中被广泛使用,甚至在UI测试或系统测试中也会经常用到,针对UI测试或系统测试中的功能测流或流程测试,总是经常需要打开相关环境,例如:web项目进行UI测试,每次都会需要打开浏览器或关闭浏览器等前置或后置的操作的。

代码:

import pytest


@pytest.fixture(scope="class", autouse=True)
def fixture_open_browser():
    print("打开浏览器")  # 相当于setup
    yield "xiaoming", "123456"
    # 生成器函数中的暂停关键字,作用是当代码运行到yield时,把yield右边的数据作为返回值提供给调用处,把代码执行权交出去。
    print("关闭浏览器")  # 相当于teardown


class TestUser(object): # object是一个基类,python中所有的类都是默认继承于object的。
    def test_01(self, fixture_open_browser):
        print(f"fixture_open_browser={fixture_open_browser}")
        print("注册流程,测试用户是否能注册成功")

    def test_02(self, fixture_open_browser):
        print(f"fixture_open_browser={fixture_open_browser}")
        print("登陆流程,测试用户是否能登陆成功")



单独存放fixture代码

我们还可以基于fixture脚手架把代码提前写好,放在在一个pytest能自动识别的conftest.py文件中,这样可以有效避免出现重复的fixture代码。注意:conftest.py的文件名必须固定,而且里面只存放fixture代码,并保证该文件与被测试代码文件在同一目录即可。

conftest.py,代码:

import pytest


@pytest.fixture(scope="class", autouse=True)
def fixture_open_browser():
    print("打开浏览器")  # 相当于setup
    yield "xiaoming", "123456"
    # 生成器函数中的暂停关键字,作用是当代码运行到yield时,把yield右边的数据作为返回值提供给调用处,把代码执行权交出去。
    print("关闭浏览器")  # 相当于teardown



5-单独存放fixture代码.py,代码:

class TestUser(object):
    def test_01(self, fixture_open_browser):
        print(f"fixture_open_browser={fixture_open_browser}")
        print("注册流程,测试用户是否能注册成功")

    def test_02(self, fixture_open_browser):
        print(f"fixture_open_browser={fixture_open_browser}")
        print("登陆流程,测试用户是否能登陆成功")


第三方常用组件

控制测试用例执行顺序

unittest执行测试用例的默认顺序是根据测试用例方法名的ASCII码排序[0-9A-Za-z]而定的,值越小,越靠前执行。

pytest执行测试用例的默认顺序是根据测试方法的源代码上下顺序来排序的。

而如果在完成接口测试或集成测试时,我们要控制测试用例的执行顺序,可以通过pytest的第三方模块pytest-ordering来实现。

安装

pip install pytest-ordering


使用

class TestAdd(object):
    @pytest.mark.run(order=n)  # n表示执行顺序,可以是正负整数。
    def test_测试方法名(self):
        pass
# 执行顺序为优先执行正数排序的方法,接着到没有排序的方法,最后是负数排序的方法。
# 如果多个方法都是正数,则先执行排序值小的,同理如果多个方法都是负数,也是一样先执行排序值小的。


pytest-ordering.py组件的使用,代码:

"""
前置步骤,安装插件:
pip install pytest-ordering
"""
import pytest


class TestAdd(object):

    @pytest.mark.run(order=-1)
    def test_01(self):
        print(f"test_01执行了,order=-1")

    @pytest.mark.run(order=-10)
    def test_02(self):
        print(f"test_02执行了,order=-10")

    @pytest.mark.run(order=10)
    def test_03(self):
        print(f"test_03执行了,order=10")

    @pytest.mark.run(order=3)
    def test_04(self):
        print(f"test_04执行了,order=3")

    def test_05(self):
        print(f"test_05执行了,没有指定排序值")

    def test_06(self):
        print(f"test_06执行了,没有指定排序值")

"""
多个方法排序值为正整数的情况:以小为先
test_04
test_03
没有排序值的情况下,源代码中先写的先执行,后写的后执行:先写为先
test_05
test_06
多个方法排序值为负整数的情况:以小为先
test_02
test_01
"""


pytest-ordering用于解决测试开发中,UI测试、系统测试、接口测试等多个测试用例有先后流程的场景。

例如,系统测试的购物车流程:

  1. 先访问商品详情页
  2. 添加商品购物车
  3. 点击购物车的结算
  4. 填写或选择发货地址
  5. 确定支付

注意:pytest-ordering组件如果和参数化fixtrue脚手架一起使用会导致参数解析错误,所以不能一起使用。因此pytest-ordering使用时,如果需要对测试用例实现参数化,则可以使用pytest.mark.parametrize,注意:不能使用ddt。

失败用例重试

针对网络场景或服务端性能不稳定的情况下,进行测试时经常遇到用例运行失败的情况,特别在性能测试方面,此时我们可以让失败用例重试指定次数,以达到测试的更准确的结果。

安装

pip install pytest-rerunfailures -i http://pypi.douban.com/simple/
    
# pip freeze | findstr rerunfailures # windows系统
# pip freeze | grep rerunfailures    # mac OS X 或 linux、Windows10以上


使用

安装 pytest-rerunfailures 插件到本地以后,在pytest运行参数中会新增选项:
--reruns n (n为正整数,表示重试n次)
--reruns-delay m (m为正整数,表示重试的间隔时间为m秒)


全局失败用例重试

全局失败用例重试.py,代码

import random

def add(x, y):
    return x + y


class TestAdd(object):
    def test_01(self):
        res = add(10, 20)
        assert res is 30

    def test_02(self):
        ret = random.randint(1, 3)
        assert ret % 2 == 0



配置文件pytest.ini,代码:

[pytest]
addopts =--reruns 3 --reruns-delay 2 -s -v
testpaths = ./
python_files = test_*.py
python_classes = Test*
python_functions = test_*



局部失败用例重试

局部失败用例重试.py,代码:

import random
import pytest

def add(x, y):
    return x + y


class TestAdd(object):
    def test_01(self):
        res = add(10, 20)
        assert res is 30

    # 只设置当前测试用例方法失败重试
    @pytest.mark.flaky(reruns=3, reruns_delay=2)
    def test_02(self):
        ret = random.randint(1, 3)
        assert ret % 2 == 0


注意:

  1. 局部重试参数会覆盖全局重试参数,也就是说,当使用了局部用法,全局用法就失效了。
  2. 与pytest.fixture脚手架也会存在参数解释冲突问题,所以使用了失败重试就不要使用pytest.fixture。
并发运行测试用例

当测试用例非常多的时候,一条条按顺序执行测试用例,是很浪费测试时间的。此时如果测试用例之间没有先后运行的依赖关系,可以完全独立运行的情况下,我们也可以并发运行测试用例,让自动化测试用例可以分布式执行,从而大大节省测试时间。pytest-xdist 就可以完成我们上面希望的并发执行测试用例的效果,它是属于进程级别的并发。

安装

pip install pytest-xdist


使用

pytest-xdist安装以后,pytest会新增一个参数-n,可以让我们指定本次运行测试所开启的进程数量。

参数设置如下:

pytest -s -v -n 4  # 使用4个进程运行,也可以改成auto
pytest -s -v -n auto  # 自动检测系统的CPU核数,并根据CPU核算创建对应数量的进程数量


pytest.ini,代码:

[pytest]
addopts =-s -v -n auto
testpaths = ./
python_files = test_*.py
python_classes = Test*
python_functions = test_*


代码:

def add(x, y):
    return x + y


class TestAdd(object):
    def test_01(self):
        res = add(10, 20)
        assert res is 30

    def test_02(self):
        res = add("10", "20")
        assert res == "1020"

    def test_03(self):
        res = add("10", "20")
        assert res == "1020"

    def test_04(self):
        res = add("10", "20")
        assert res == "1020"

    def test_05(self):
        res = add("10", "20")
        assert res == "1020"

    def test_06(self):
        res = add("10", "20")
        assert res == "1020"


自身生成HTML格式测试报告[了解下即可]

安装

pip install pytest-html


使用

安装插件到本地以后,在pytest运行参数中会新增选项:--html=report.html


生成HTML格式测试报告.py,代码

import random
import pytest

def add(x, y):
    return x + y


class TestAdd(object):
    def test_01(self):
        res = add(10, 20)
        assert res is 30

    # 只设置当前测试用例方法失败重试
    @pytest.mark.flaky(reruns=3, reruns_delay=2)
    def test_02(self):
        ret = random.randint(1, 3)
        assert ret % 2 == 0


pytest.ini,代码:

[pytest]
addopts =-s -v --html=report.html
testpaths = ./
python_files = test_*.py
python_classes = Test*
python_functions = test_*


Allure

Allure 是一款轻量级的开源自动化测试报告生成框架,Java语言开发出来的。它支持绝大部分测试框架,比如 pytest、unittest 等。比起上面那些丑陋的测试报告生成,Allure是最漂亮的,而且还可以配合pytest与Jenkins实现CI持续集成。pytest+Allure+git+pycharm+Jenkins+gitlab/gitee/github= CI持续集成

官方文档:https://docs.qameta.io/allure/#_get_started

allure依赖Java环境,你要安装Java 环境:https://www.cnblogs.com/Neeo/articles/11954283.html

allure插件下载:百度云盘链接:链接:https://pan.baidu.com/s/1Xj1A_xsRscOZHskTR4xjAg 提取码:6b33(已保存到本地百度网盘)

或者去https://github.com/allure-framework/allure2/releases下载

image-20200426155627685

allure插件安装:
解压压缩包到一个没有中文的目录中,然后将其中的bin目录添加到环境变量(PATH)中

image-20200426151936014

image-20200426151801922

测试,终端输入:

image-20200426152012302

安装

安装java环境

Allure的运行起来需要依赖于java环境,此处下载安装jdk1.8环境(Java 8)。

java的jdk1.8下载地址:https://www.java.com/zh-CN/download/

此处我们演示windows下的java环境安装。

鼠标右键点选下载到本地的java的jdk安装包,选择以"管理员身份运行",窗口如下,勾选窗口左下角的"
更改目标文件夹",点击"安装",进入下一步。

image-20220618201926856

image-20220618201948408

默认情况下,java的jdk会选择在C盘的"Program Files"目录下安装,如果不想要在此目录安装,则可以点击更改,如果无所谓,则点击"下一步"即可。建议修改“Program Files”之前的路径,后半段保持原样。

image-20220618202006796

image-20220618202031884

jre与jdk同样保存在一个父目录下即可。

image-20220618202048101

OK,接下来,喝杯茶,等待一会即可。

image-20220618202141055

效果如下,则没有问题。

image-20220618202151445

注意:javasdk环境是依赖于JAVA_HOME的,依次打开控制面板系统与安全系统高级系统设置环境变量系统变量新建。变量名中输入JAVA_HOME,变量值中填写刚才获取到的路径C:\tool\Java\jdk1.8.0_201(注意,此处根据自己的实际路径填写,别瞎复制)。

image-20220618135144208

保存了JAVA_HOME环境变量,点击下方的Path环境变量,里面把java sdk安装目录下bin目录加入到环境变量中。

image-20220618135206230

cmd终端输入java -version,出现如下内容表示安装完成。

image-20220618182351919

安装Allure命令行工具

# windows10系统打开powershell来安装,不能使用cmd
# 安装Allure可以借助第三方包管理器scoop,scoop用于提供给开发人员安装各种开发软件到windows系统的
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
y
iwr -useb https://gitee.com/RubyKids/scoop-cn/raw/master/install.ps1 | iex
# https://scoop.sh/#/apps?q=allure&s=0&d=1&o=true
scoop bucket add main
scoop install allure

# # Ubuntu系统使用apt-get包管理器
# sudo apt install -y openjdk-8-jdk
# sudo apt-add-repository ppa:qameta/allure
# sudo apt-get update 
# sudo apt-get install allure

# # Mac OS X系统使用brew包管理器
# brew install openjdk
# brew install allure


接下来,我们重新打开一个新的命令行窗口,输入"allure",显示如下即表示allure命令行工具安装正确。

image-20220522081917552

安装pytest-allure集成模块

接下来,只需要安装allure集成到pytest的pytest-allure集成模块即可。

pip install allure-pytest

基本使用

生成allure测试结果,结果以json文件格式保存在--alluredir选项指定的目录下。

pytest.ini,代码:

[pytest]
addopts =-s -v --alluredir=./allure_results
testpaths = ./
python_files = test_*.py
python_classes = Test*
python_functions = test_*


基于json报告结果使用浏览器展示allure测试结果报告

allure serve ./allure_results


生成HTML格式文档的测试报告

HTML格式文档的测试报告也是要基于上面的json文件格式的测试结果才能生成的。

# 必须先生成json格式的测试报告,才能生成HTML格式的测试报告
allure generate ./allure_results -o ./reports --clean



基于json格式的报告生成HTML报告,代码:

import os
import random
import pytest
import shutil

def add(x, y):
    return x + y


class TestAdd(object):
    def test_01(self):
        res = add(10, 20)
        assert res is 30

    # 只设置当前测试用例方法失败重试
    @pytest.mark.flaky(reruns=3, reruns_delay=2)
    def test_02(self):
        ret = random.randint(1, 3)
        assert ret % 2 == 0

    def test_03(self):
        ret = random.randint(1, 3)
        assert ret % 2 == 0

if __name__ == '__main__':
    # 先删除旧的测试报告
    try:
        shutil.rmtree("./allure_results")
        shutil.rmtree("./allure_reports")
    except:
        pass
    # 运行pytest执行测试测试用例,并生成json测试报告
    pytest.main()
    # 基于os.system执行终端名,生成HTML格式文档的测试报告
    os.system("allure generate ./allure_results -o ./allure_reports --clean")


注意:生成的报告,只能通过http协议访问,不能直接在硬盘双击点击打开(这种方式是基于本地文件协议file访问文件)。所以,目前来说,有两种打开方式:

  • 使用pycharm打开

image-20200426154008524

  • 第二种就是使用allure自带的open命令打开: allure open .\allure_report

image-20200426154112483

常用方法

方法 描述
@allure.testcase(url, name=None) 设置测试用例的站点访问地址
@allure.issue(url, name=None) 设置测试用例的站点的缺陷管理地址
@allure.feature(*features) 设置测试用例的模块名称
@allure.story("登录用例1") 行为驱动标记,简单理解为为用例分类。与@allure.feature("登录用例")结合使用
@allure.severity(severity_level) 设置测试用例的优先级,allure对测试用例的优先级划分成五个等级(allure.severity_level),值为:
BLOCKER(阻塞缺陷),CRITICAL(严重缺陷),NORMAL(普通缺陷),MINOR(较小缺陷),TRIVIAL(不重要缺陷)。 默认值为NORMAL。
@allure.title(test_title) 设置测试用例的标题名称
@allure.description(test_description) 设置测试用例的详细描述(纯文本)
@allure.description_html(test_description_html) 设置测试用例的详细描述(HTML文本)
@allure.link(url, link_type=LinkType.LINK, name=None) 设置指定测试用例的缺陷访问地址
allure.step(title) 设置测试用例执行过程中的步骤信息
allure.attach(body, name=None, attachment_type=None, extension=None) 设置测试用例的文本附件信息,allure.attachment_type的常见文本附件类型支持:TEXT,SVG,HTML,JSON,YAML,CSV。
添加文本格式:allure.attach("HTML代码", "文件标题", allure.attachment_type.HTML)
allure.attach.file(source, name=None, attachment_type=None, extension=None) 设置测试用例的文件附件信息,支持的类型参考上方使用。
allure.attachment_type的常见文本附件类型支持:PNG,GIF,JPG,PDF。添加二进制格式:allure.attach.file("文件路径", "文件名", attachment_type=allure.attachment_type.PNG)
allure.dynamic.title(item['title']) 在参数化的时候,我们无法使用allure的相关功能,因为装饰器中是写死的功能,在参数中,数据都是在用例中使用。所以,我们采用allure.dynamic参数动态的为用例添加相关的功能。
import requests
import pytest
import allure



url_list = [
    {'url': "https://www.baidu.com", "method": "get", "status": 200, "title": "百度"},
    {'url': "https://www.cnblogs.com/Neeo/articles/11832655.html", "method": "get", "status": 200, "title":"cnblogs"},
    {'url': "http://www.neeo.cc:6001/post", "method": "post", "status": 200, "title":"post接口"},
    {'url': "http://www.neeo.cc:6001/put", "method": "put", "status": 200, "title":"put接口"},
]


@pytest.mark.parametrize('item', url_list)
def test_case(item):

    allure.dynamic.title(item['title'])
    response = requests.request(method=item['method'], url=item['url'])
    # print(response.status_code,  item['status'])
    assert response.status_code == item['status']



代码:

import os
import random
import pytest
import shutil
import allure
from allure_commons.types import LinkType


def add(x, y):
    return x + y


@allure.testcase("http://www.luffycity.com", "测试地址站点的首页地址")
@allure.issue("http://www.luffycity.com", "软件缺陷的管理站点的首页地址")
class TestAdd(object):
    @allure.feature("测试用例模块的描述: 购物车模块,用户模块")
    @allure.story("测试用例的分类描述")
    @allure.title("测试用例test_01的标题描述")
    @allure.description("测试用例test_01的详细描述")
    @allure.severity(allure.severity_level.MINOR)  # 较小缺陷等级的用例,如果不设置这个,默认是NORMAL普通缺陷等级的用例
    def test_01(self):
        res = add(10, 20)
        assert res == 30

    @allure.feature("测试用例模块的描述: 购物车模块,用户模块")
    @allure.story("测试用例的分类描述")
    @allure.title("测试用例test_02的标题描述")
    # @allure.description("测试用例test_02的详细描述")    # 纯文本描述
    @allure.description_html("<b style='color: red;'>测试用例test_02的详细描述</b>")  # HTML文本描述
    @allure.severity(allure.severity_level.BLOCKER)  # 阻塞缺陷等级的用例
    @allure.link("http://test.luffycity.com/test_02", link_type=LinkType.LINK, name="测试用例:02")

    # 只设置当前测试用例方法失败重试
    @pytest.mark.flaky(reruns=3, reruns_delay=2)
    def test_02(self):
        # 注意是使用with上下文管理器语句
        with allure.step("步骤1:内容描述"):
            result = 1+1

        with allure.step("步骤2:内容描述"):
            result +=1

        print(f"测试结果是:{result}")
        assert result == 3

    @allure.feature("测试用例模块的描述: 购物车模块,用户模块")
    @allure.story("测试用例的分类2描述")
    @allure.title("测试用例test_03的标题描述")
    @allure.description("测试用例test_03的详细描述")
    @allure.severity(allure.severity_level.CRITICAL)  # 致命缺陷等级的用例
    def test_03(self):
        # 图片附件
        allure.attach.file("./images/demo.jpg", 'demo.jpg', allure.attachment_type.JPG)
        # 文本附件
        allure.attach("""<h1>Test with some complicated html description</h1>
<table style="width:100%">
  <tr>
    <th>Firstname</th>
    <th>Lastname</th>
    <th>Age</th>
  </tr>
  <tr align="center">
    <td>William</td>
    <td>Smith</td>
    <td>50</td>
  </tr>
  <tr align="center">
    <td>Vasya</td>
    <td>Jackson</td>
    <td>94</td>
  </tr>
</table>""", "Test with some complicated html attachment", allure.attachment_type.HTML)

        ret = random.randint(1, 3)
        assert ret % 2 == 0

# __name__ 是一个魔术变量,在当前文本被python解释器作为主程序运行是,值固定就是 "__main__",
# 如果当前文件作为模块被其他文件导包使用,则__name__的值,则为当前文件名或者其他的自定义名称,总之不是 "__main__"了。
if __name__ == '__main__':
    try:
        # 删除之前的测试结果与测试文件目录内容
        shutil.rmtree("reports")
        shutil.rmtree("results")
    except:
        pass
    pytest.main(["-sv", "main.py", "--alluredir", "./results"])
    # 生成报告html文件
    os.system('allure generate ./results -o ./reports --clean')
    # 基于http协议打开HTML测试报告
    os.system('allure open ./reports')


测试运行,使用终端

python main.py


补充:关于在python中可以执行命令行命令

方法一:

settings.py

# -------------------- allure 报告相关 ------------

ALLURE_COMMAND = 'allure generate {from_json_path} -o {save_to_path} --clean'.format(
    from_json_path=os.path.join(BASE_DIR, 'report', 'json_result'),
    save_to_path=os.path.join(BASE_DIR, 'report', "allure_result")
)

import subprocess


# 参考: https://docs.python.org/zh-cn/3/library/subprocess.html
subprocess.call(settings.ALLURE_COMMAND, shell=True)

方法二:

如何删除一个非空目录

用shuitil

import shutil
'''
shutil模块对文件和文件集合提供了许多高级操作。特别是,提供了支持文件复制和删除的功能
'''

# shutil.copy(src, dst)   # 拷贝文件
# shutil.move(src, dst)  # 移动目录或者文件

# shutil.rmtree(path)   # 递归删除目录,无法直接删除文件
# shutil.make_archive(base_name, format('zip'))   # 将目录或者文件以指定格式压缩
# shutil.unpack_archive(filename, extract_dir)  # 解压缩

# see also: https://docs.python.org/3/library/shutil.html


if __name__ == '__main__':
    # 先删除旧的测试报告
    try:
        shutil.rmtree("./allure_results")
        shutil.rmtree("./allure_reports")
    except:
        pass
    # 运行pytest执行测试测试用例,并生成json测试报告
    pytest.main()
    # 基于os.system执行终端名,生成HTML格式文档的测试报告
    os.system("allure generate ./allure_results -o ./allure_reports --clean")

方法三:

import pytest
import os
import sys

import config

if __name__ == '__main__':
    print('开始删除俩个文件')
    os.system(f"rd /s /q allure_results")
    os.system(f"rd /s /q allure_reports")

    # 让python解释器,追加3个项目中的核心目录为导包路径
    sys.path.insert(0, str(config.BASE_DIR / "api"))
    sys.path.insert(0, str(config.BASE_DIR / "tests"))
    sys.path.insert(0, str(config.BASE_DIR / "utils"))

    # 启动pytest框架
    pytest.main()

    # 生成报告html文件
    os.system('allure generate ./allure_results -o ./allure_reports')

    # 基于http协议打开HTML测试报告
    os.system(f'allure open ./reports -h {config.HOST} -p {config.PORT}')

接口自动化测试

基于pytest和allure构建接口自动化测试框架构与项目【luffytest项目】。

接口自动化的框架开发:

  • 用到的知识点:
    • pytest
    • allure
    • 参数化
    • Excel操作,不会,用xlrd
    • 日志操作,学过,不太会
    • 邮件,会
    • 文件操作,文件压缩, 没讲,但你要会的,zipfile
    • 执行终端命令,os.system, subprocess:cell, popen
      • 如何使用python查看当前目录下的所有文件或者目录?
  • 实现的个功能:
    • 将各个功能拆分为多个目录
    • 使用参数化读取Excel中的用例
      • 发请求
      • 获取请求结果
      • 校验/断言
    • 使用allure生成测试报告
    • 将allure测试报告所在的目录打包
    • 将打包的zip文件使用邮件发送到1206180814@qq.com
    • 在重点位置加日志

实现思路:

  1. 读取Excel,每一行数据都是一个用例,你在读出来之后,把这个一行用例封装成一个对象,字典,列表。
  2. 使用参数化每次讲一个用例对象传进去。
  3. 使用requests获取用例对象中的相关参数发请求。
  4. 然后将请求结果与预期值(用例对象)做断言
  5. 此时,allure所需的json数据已经有了。
  6. 使用allure命名读取josn数据生成测试报告
  7. 将报告压缩
  8. 使用发邮件功能将压缩文件发送
  9. 在重点位置,添加日志功能

框架目录结构

我们要构建一个自动化测试框架,就要以项目的概念来对项目中所有的代码文件进行划分目录和文件结构,不同的代码功能不一样,所以我们需要设计一个合理的目录结构,以方便与测试开发团队的其他人员进行测试功能的开发与测试,也方便将来的项目代码维护。

根目录/
 ├─ config.py    # 项目代码配置文件
 ├─ pytest.ini    # pytest模块配置文件
 ├─ main.py      # 主程序,执行入口
 ├─ api/         # 封装被测试项目的api接口存放目录[用于mock测试、冒烟测试]
 ├─ data/        # 测试数据/测试用例的存放目录
 ├─ allure_results/     # 测试报告结果生成目录
 ├─ allure_reports/     # HTML测试报告生成目录
 ├─ tests/       # 测试用例脚本存放目录
 ├─ libs/         # 第三方工具类的存放目录[开源模块,不是当前项目封装的模块]
 └─ utils/        # 自定义工具类的存放目录[当前项目自己封装的模块]


配置文件,config.py,代码:

import pathlib  # 路径操作模块,替代 os.path模块,os.path采用字符串来操作路径,pathlib采用面向对象来操作路径

# 项目目录的主目录路径[字符串路徑]
BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix()  # 基本操作系统转换路径的分隔符 as_posix
# 項目目录的主目录路径[路径对象]
BASE_DIR = pathlib.Path(BASE_DIR_STR)

# 项目名
WEB_NAME = "路飞自动化接口测试框架"

# 测试自动化项目的运行端口与IP地址
HOST = "127.0.0.1"
PORT = 8088

# print(pathlib.Path(__file__))  # D:\data\1045699\Desktop\luffy_code\testting\luffytest\config.py
# print(BASE_DIR_STR, type(BASE_DIR_STR))  # D:/data/1045699/Desktop/luffy_code/testting/luffytest   <class 'str'>
# print(BASE_DIR, type(BASE_DIR))  # D:\data\1045699\Desktop\luffy_code\testting\luffytest   <class 'pathlib.WindowsPath'>

入口文件,main.py,代码:

import pytest
import os
import sys

import config

if __name__ == '__main__':
    print('开始删除俩个文件')
    os.system(f"rd /s /q allure_results")
    os.system(f"rd /s /q allure_reports")

    # 让python解释器,追加3个项目中的核心目录为导包路径
    sys.path.insert(0, str(config.BASE_DIR / "api"))
    sys.path.insert(0, str(config.BASE_DIR / "tests"))
    sys.path.insert(0, str(config.BASE_DIR / "utils"))

    # 启动pytest框架
    # pytest.main()   # 此方法不可靠,执行时中间夹杂别的py文件导致allure生成不了json数据
    os.system('pytest -s -v --alluredir=./allure_results --clean-alluredir')

    # 生成报告html文件
    os.system('allure generate ./allure_results -o ./allure_reports')

    # 基于http协议打开HTML测试报告
    # os.system(f'allure open ./allure_reports -h {config.HOST} -p {config.PORT}')



在根目录下创建pytest配置文件pytest.ini,内容如下:

[pytest]
addopts =-s -v --alluredir=./results
testpaths = ./
python_files = test_*.py
python_classes = Test*
python_functions = test_*


OK,完成了上面操作以后,我们就可以写一个测试用例来测试下现在我们的基本框架是否能正常运行了。

tests/users/test_login.py,代码:

import allure
import config


@allure.epic(config.WEB_NAME)
@allure.feature("用户模块")
@allure.story("登录")
class TestLogin(object):
    @allure.severity(allure.severity_level.CRITICAL)
    def test_username_by_empty(self,):
        allure.dynamic.title("用户名为空,登陆失败")
        allure.dynamic.description("测试用户名为空的登陆结果")
        allure.attach('附件内容', '附件名字')

    def test_password_by_empty(self,):
        allure.dynamic.title("密码为空,登陆失败")
        allure.dynamic.description("测试密码为空的登陆结果")
        allure.attach('附件内容', '附件名字')


运行框架,效果:

image-20220611101450834

当然自动化接口框架的构建开发过程肯定不是一夜之间就能完成的,所以我们需要长期构建,不断完善的准备。所以需要使用git代码版本管理工具把代码推送到git仓库中进行代码版本的管理(在企业中一般我们会使用公司内部构建的gitlab平台来管理内部项目,但现在我们处于学习阶段,所以可以先把代码提交到gitee码云上)。

代码托管

注意:公司内部的代码不要私自自己往gitee(码云),github去推。

gitee官网地址:https://gitee.com/

准备工作

需要提前在当前开发机子上安装git代码版本管理工具。

windows下载地址:https://git-scm.com/

创建仓库

image-20220529061911628

我们是学习,所以我这创建项目库名luffytest。项目库名建议是英文的。

image-20220529062141957

选择git-flow自定义分支模型。

image-20220327031446181

所谓的分支,其实就是一个项目的代码的不同流程版本。

image-20220327035313097

git-flow分支命名规范:

分支前缀 描述
master 生产环境分支,将来可以部署到生产环境(公司的外网服务器)的代码
release 预发布环境分支,将来可以部署到预发布环境的代码,也可以是rel
develop 开发环境分支,也可以是dev
feature 新功能,新增业务分支,也可以是feat
hotfix 修复bug问题、漏洞分支,也可以是fix
test 测试相关分支

补充说明:

release/feature/xxx   # 表示待发布分支下的xxx业务功能相关代码
develop/feature/xxx   # 表示开发分支下的xxx业务的新功能代码
develop/test/xxx      # 表示开发分支下的正在测试的xxx业务功能的代码
develop/hotfix/xxx    # 表示开发分支下的修复xxx业务相关功能的bug

# 当然,分支名称并不是固定的,只要能让开发人员一目了然,名字都可以换,所以也可以这样:
release/xxx   # 表示待发布的xxx业务新功能代码
feature/xxx   # 表示正在开发的xxx业务功能代码
test/xxx      # 表示正在测试的xxx业务功能代码
hotfix/user   # 表示正在修复bug的xxx业务功能的代码


创建私有空仓库以后的界面:

image-20220529062535656

免密提交代码

接下来,我们可以使用ssh连接远程的git仓库,需要先在本地电脑下生成ssh秘钥对。

# 例如我的码云账号是 649641514@qq.com,那么该账号生成ssh秘钥对(基于rsa加密算法生成的秘钥对,公钥和私钥)
ssh-keygen -t rsa -C "649641514@qq.com"


image-20220611103038268

复制终端下出现的公钥信息,复制到码云上面。

# 查看生成的秘钥对中的公钥(私钥id_rsa保存在自己电脑不要乱动,公钥id_rsa.pub需要复制文件内容到码云平台)
cat C:\Users\Administrator/.ssh/id_rsa.pub
# 把公钥进行复制到码云平台上 https://gitee.com/profile/sshkeys


image-20220611103112560

在浏览器打开https://gitee.com/profile/sshkeys,保存公钥。

image-20220327040326605

切换项目的仓库地址,设置线上仓库

git config --global user.name "mooluo"
git config --global user.email "649641514@qq.com"

# 在项目根目录下初始化git仓库
cd luffytest/  # 具体的路径根据自己的设置而定
git init

# git remote remove origin # 删除仓库地址,origin可以理解是一个变量,因为当前时一个新仓库,所以不需要执行这段。
git remote add origin git@gitee.com:mooluo_admin/luffytest.git # 新仓库地址,等同于origin= git..../luffycity.git


分支管理,git提交代码版本并同步到远程服务器。

git branch    # 查看分支
# 刚执行git init 初始化时,会没有分支,因此我们需要进行第一次的代码提交。

git add .
git commit -m "feature: 项目初始化"
# 经过上面的命令操作,本地的git就会自动生成一个master分支

# git branch <分支名称>    # 新建分支
# git branch test         # 例如:创建一个test分支

# git checkout <分支名称>  # 切换分支
# git checkout test       # 例如:切换到test分支,检出分支代码

# git branch -d <分支名称>   # 删除分支
# git branch -d test

# git push <远程仓库别名> --delete <分支名称>    # 删除远程服务器分支
# git push origin --delete test  # 例如:删除远程仓库origin中的test

# 推送代码记录到远程服务器的代码仓库
git push origin master # 推送的过程中,如果本地有该分支,但是线上没有这个分支,则git会自动在远程中创建该分支,默认的空仓库是一个分支都没有的。


使用.gitignore可以在git上传或下载代码时,把一些不必要记录的垃圾文件/目录过滤掉。

注意:必须保证.git目录 和.gitignore在同一级目录下,才能生效。

image-20220611104441754

.gitignore编写参考地址:https://github.com/github/gitignore

.gitignore文件内容:

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/

# vue https://github.com/vuejs/vue/blob/dev/.gitignore
.DS_Store
node_modules
*.log
explorations
TODOs.md
dist/*.gz
dist/*.map
dist/vue.common.min.js
test/e2e/reports
test/e2e/screenshots
coverage
RELEASE_NOTE*.md
dist/*.js
packages/vue-server-renderer/basic.js
packages/vue-server-renderer/build.js
packages/vue-server-renderer/server-plugin.js
packages/vue-server-renderer/client-plugin.js
packages/vue-template-compiler/build.js
.vscode

# luffytest
reports/*
results/*
logs/*


记录并保存.gitignore到git中

git add .
git commit -m "feature: 新建.gitignore忽略文件"
# 推送代码记录到远程服务器的代码仓库
git push origin master


git commit 提交版本的描述信息,编写前缀规范:

描述前缀 描述
feature: 本次提交的代码用于开发新功能,新增业务
fix: 本次提交的代码用于修复bug问题、漏洞
docs: 本次提交的代码用于修改文档,注释等相关
style: 本次提交的代码用于修改代码格式,不影响代码逻辑,常见的代码规范:PEP8,PEP484
refactor: 本次提交的代码用于项目/代码重构,理论上不影响现有功能(针对代码的重写,改造原来的模块/类/函数/方法)
perf: 本次提交的代码用于提升性能,代码优化
test: 本次提交的代码用于增加或修改测试用例代码
deps: 本次提交的代码用于升级项目依赖(更新依赖模块)

最终,成功提交了代码版本到gitee平台。

新增日志功能

针对的项目开发,将来肯定需要把测试框架这个项目保存公司的服务器的,所以如果项目在公司服务器报错了,我们有可能不在场,或者其他同时去运作,那么我们针对当前这个项目在这个运行期间,有没有出现异常,那就需要记录整个项目的运行信息。

参考:https://www.cnblogs.com/Neeo/articles/10951734.html#示例

config.py,新增如下日志配置代码,代码:

import pathlib  # 路径操作模块,替代 os.path模块,os.path采用字符串来操作路径,pathlib采用面向对象来操作路径

# 项目目录的主目录路径[字符串路徑]
BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix()  # 基本操作系统转换路径的分隔符 as_posix
# 項目目录的主目录路径[路径对象]
BASE_DIR = pathlib.Path(BASE_DIR_STR)

# 项目名
WEB_NAME = "路飞自动化接口测试框架-master"

# 测试自动化项目的运行端口与IP地址
HOST = "127.0.0.1"
PORT = 8088

"""日志配置"""
LOGGING = {
    "name": "luffytest",  # 日志处理器的名称,一般使用当前项目名作为名称
    "filename": (BASE_DIR / "logs/luffytest.log").as_posix(),  # 日志文件存储路径,注意,一定要在项目根目录下手动创建logs目录
    "charset": "utf-8",  # 日志内容的编码格式
    "backup_count": 31,  # 日志文件的备份数量
    "when": "d",   # 日志文件的创建间隔事件,m 表示每分钟创建1个,h表示每小时创建1个,d表示每天创建1个,m0~m6表示每周星期日~星期六创建1个,midnight表示每日凌晨
}



在项目根目录下创建logs目录,并编写日志工具类,utils/logger.py,代码:

import logging
import logging.handlers
import config


class LogHandle(object):
    """日志处理工具类"""

    def __init__(self, name=None, filename=None):
        self.name = config.LOGGING.get("name", "pytest")
        if name:
            self.name = name

        self.filename = config.LOGGING.get("filename", None)
        if filename:
            self.filename = filename

        self.charset = config.LOGGING.get("charset", "utf-8")
        self.log_backup_count = config.LOGGING.get("backup_count", 31)
        self.when = config.LOGGING.get("when", "d")

    def get_logger(self):
        # 创建logger,如果参数name表示日志器对象名,name为空则返回root logger
        logger = logging.getLogger(self.name)
        # 务必设置一个初始化的日志等级
        logger.setLevel(logging.DEBUG)
        # 这里进行判断,如果logger.handlers列表为空则添加,否则多次调用log日志函数会重复添加
        if not logger.handlers:
            # 创建handler
            fh = logging.handlers.TimedRotatingFileHandler(
                filename=self.filename,
                when=self.when,
                backupCount=self.log_backup_count,
                encoding=self.charset
            )
            sh = logging.StreamHandler()

            # 单独设置logger日志等级
            fh.setLevel(logging.INFO)
            # 设置输出日志格式
            simple_formatter = logging.Formatter(
                fmt="{levelname} {name} {module}:{lineno} {message}",
                style="{"
            )
            verbose_formatter = logging.Formatter(
                fmt="{levelname} {asctime} {name} {pathname}:{lineno} {message}",
                datefmt="%Y/%m/%d %H:%M:%S",
                style="{"
            )

            # 为handler指定输出格式
            fh.setFormatter(verbose_formatter)
            sh.setFormatter(simple_formatter)
            # 为logger添加的日志处理器
            logger.addHandler(fh)
            logger.addHandler(sh)

        return logger  # 直接返回logger

if __name__ == '__main__':
    from datetime import datetime

    log = LogHandle()
    log.filename = "../logs/pytest.log"
    logger = log.get_logger()
    logger.debug("日期测试")
    logger.info("普通日志")
    logger.warning("警告日志")
    logger.error("错误日志")
    logger.fatal("致命错误信息")



在测试用例中简单使用,tests/users/test_login.py,代码:

import allure
from logger import LogHandle


@allure.epic(config.WEB_NAME)
@allure.feature("用户模块")
@allure.story("登录")
class TestLogin(object):
    def test_username_by_empty(self,):
        logger = LogHandle().get_logger()
        logger.debug("debug")
        logger.info("运行日志-info")
        logger.warning("warning")
        logger.error("error")
        logger.critical("critical")

        allure.attach('附件内容', '附件名字')

    def test_login(self,):
        allure.attach('附件内容', '附件名字')


提交代码版本,代码:

git add .
git commit -m "feature: 新增日志功能"
# 推送代码记录到远程服务器的代码仓库
git push origin master


实现接口自定义测试

封装请求工具requests

接口测试肯定离不开http网络请求,在工作中python开发常用的http网络请求模块有:urllib,requests、httpx与aiohttp。

其中我们作为测试开发,比较常用的就是requests模块了。

Requests 是一个⽤Python编写,基于urllib的开源HTTP网络请求工具第三方库。它⽐python内置的urllib模块使用更加简单⽅便,可以节约我们⼤量的⼯作,完全满⾜HTTP测试需求。

requests快速使用
安装
pip install requests


requests.request(method, url, **kwargs)类能够构造一个请求,支持不同的请求方式。

import requests

response = requests.request(method='get', url='https://www.baidu.com')
print(response.status_code)

request类中来看看几个参数:

  • method:请求方式。
  • url:请求URL。
  • kwargs:
    • params:字典或者字节序列,作为参数增加到url中,使用这个参数可以把一些键值对以k1=v1&k2=v2的模式增加到url中,get请求中用的较多。
    • data:字典、字节序列或者文件对象,重点作为向服务器提供或提交资源,作为请求的请求体,与params不同放在url上不同。它也可以接受一个字符串对象。
    • json:json格式的数据,可以向服务器提交json类型的数据。
    • headers:字典,定义请求的请求头,比如可以headers字典定义user agent。
    • cookies:字典或者CookieJar。
    • auth:元组,用来支持HTTP认证功能。
    • files:字典,用来向服务器传输文件。
    • timeout:指定超时时间。
    • proxies:字典,设置代理服务器。
    • allow_redirects:开关,是否允许对URL进行重定向,默认为True。
    • stream:开关,是否对获取内容进行立即下载,默认为False,也就是立即下载。这里需要说明的,stream一般应用于流式请求,比如说下载大文件,不可能一次请求就把整个文件都下载了,不现实,这种情况下,就要设置stream=True,requests无法将连接释放回连接池,除非下载完了所有数据,或者调用了response.close。
    • verify:开关,用于SSL证书认证,默认为True。
    • cert:用于设置保存本地SSL证书路径。

流式请求,指的不是请求是流,而是请求返回的数据流,返回一点取一点,而普通的请求是返回完毕你再取内容。

分享:http://www.neeo.cc:6001/ # httpbin,提供了常用的请求方式,用于接口练习

GET请求

发送无参数的GET请求

demo/demo_requests.py,代码:

import requests

response = requests.get('http://httpbin.org/get')
print(response.content)  # 获取原生内容
print(response.text)   # 获取文本内容


发送有参数的GET请求

import requests

params = {
    'name': 'moluo',
    'age': 18
}
response = requests.get("http://httpbin.org/get?sex=1", params=params)
print(response.text)



接收数据

接收文本格式数据

import requests
 
params = {
 'name': 'moluo',
 'age': 18
}
response = requests.get("http://httpbin.org/get", params=params)
print(response.text)


接受json格式数据

import requests
import json

response = requests.get("http://httpbin.org/get")
# 方式I:
print(json.loads(response.text))
# 方式2:
print(response.json())


接受二进制格式数据

import requests

response = requests.get("http://httpbin.org/image/png")
with open("1.png", "wb") as f:
    f.write(response.content)



POST请求

发送表单数据

import requests

body = {'name': 'moluo', 'age': '22'}
response = requests.post("http://httpbin.org/post", data=body)
print(response.text)



发送json数据

import requests

data = {'name': 'moluo', 'age': '22'}
response = requests.post("http://httpbin.org/post", json=data)
print(response.text)



文件上传

import requests

# 支持上传一张或多张图片
files = {'avatar': open('1.png', 'rb')}
response = requests.post("http://httpbin.org/post", files=files)
print(response.text)


发送请求头
import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36'
}

response = requests.get("https://www.zhihu.com/explore", headers=headers)
print(response.text)



代理IP
import requests
# https://www.beesproxy.com/free
proxies = {
    # "http": "http://112.6.117.135:8085",
    "http": "http://223.68.190.136:9091",
}
req = requests.get('http://icanhazip.com/', proxies=proxies)
print(req.text)



会话保持

提取Cookie

import requests
# 1. 登录,获取cookies值

response = requests.post(url="http://www.neeo.cc:6002/pinter/bank/api/login?userName=admin&password=1234")
print(response.json())
print(response.cookies)
print(response.cookies.get_dict())



发送Cookie

import requests

url = 'http://httpbin.org/cookies'
cookies = {'testCookies_1': 'Hello_Python3', 'testCookies_2': 'Hello_Requests'}
# 在Cookie Version 0中规定空格、方括号、圆括号、等于号、逗号、双引号、斜杠、问号、@,冒号,分号等特殊符号都不能作为Cookie的内容。
r = requests.get(url, cookies=cookies)
print(r.json())



其他方法

# 现在大部分的公司在开发项目都会会考虑多终端的问题,所以往往项目整体架构都是基于前后端分离的,那么实现前后端的分离的项目,往往服务端就是一个单独基于RestFul API接口的服务端。

import requests
# 只有args查询字符串参数,没有请求体
requests.get('http://httpbin.org/get')
requests.delete('http://httpbin.org/delete')
# 有请求体的
requests.post('http://httpbin.org/post')
requests.put('http://httpbin.org/put')
requests.patch('http://httpbin.org/patch')


requests/bs4爬虫练习

import os
import requests
from bs4 import BeautifulSoup   # 解析爬取回来的文本,去其中找标签/标签的属性/标签的内容
BASE_DIR = os.path.dirname(os.path.abspath(__file__))

url = "https://www.autohome.com.cn/news/"
response = requests.request('get', url=url, )
# print(response.encoding)
response.encoding = "GBK"   # 解决乱码问题
# print(response.text)
# 解析文本,拿到解析后的soup对象,所有解析后的数据都在soup对象中
soup_obj = BeautifulSoup(response.text, "html.parser")   # response.text: 要解析的文本    html.parser:指定什么解析器来解析文本
# 使用soup对象找div标签
div_obj = soup_obj.find(name='div', attrs={"id": "auto-channel-lazyload-article"})
# print(div_obj)
# 根据div对象,找其内的所有的img标签
img_list = div_obj.find_all(name="img")
# print(img_list[0])
for item in img_list:
    # print(item)
    # 获取标签的src属性
    img_url = "https:" + item.get("src")
    # 使用requests模块向img url发请求,获取bytes类型数据,并且保存到本地
    img_response = requests.get(img_url)
    file_name = os.path.join(BASE_DIR, 'images', img_url.rsplit("/", 1)[-1])
    with open(file_name, 'wb') as f:
        f.write(img_response.content)
    print(file_name, "download done .........")



beautifulsoup4:

  • soup_obj = BeautifulSoup(response.text, "html.parser") # response.text: 要解析的文本 html.parser:指定什么解析器来解析文本
  • soup_obj.find(name=标签名, attr={”属性名":"属性值"}) 因为找到的标签可能有多个,那使用attr来过滤指定的标签。
  • soup_obj.find_all(name=标签名, attr={”属性名":"属性值"})) 找到所有以attr过滤的标签。
  • img.get('src'),表示获取指定标签的属性
  • 获取标签的文本内容,使用标签名.text

自动化测试时,大家肯定都是希望自己写的代码越简洁越好,避免代码重复造成维护成本的提升,因此针对工作中常用的http网络请求模块requests,我们就需要在使用前对其进行简单的封装处理了。

utils/requestor.py,代码:

import requests
from logger import LogHandle

class Request(object):
    """http请求工具类"""
    def __init__(self):
        # 实例化session管理器,维持会话, 跨请求的时候保存参数
        self.session = requests.session()
        self.logger = LogHandle().get_logger()

    def send(self, method, url, params=None, data=None, json=None, headers=None, **kwargs):
        """
        :param method: http请求方式
        :param url: 请求地址
        :param params: 字典或bytes,作为参数增加到url中
        :param data: data类型传参,字典、字节序列或文件对象,作为Request的内容
        :param json: json传参,作为Request的内容
        :param headers: 请求头,字典
        :param kwargs: 若还有其他的参数,使用可变参数字典形式进行传递
        :return:
        """
        # 对异常进行捕获
        try:
            self.logger.info(f"请求方法:{method}")
            self.logger.info(f"请求地址:{url}")
            self.logger.info(f"请求头:{headers}")
            if params: self.logger.info(f"查询参数:params={params}")
            if data: self.logger.info(f"请求体:data={data}")
            if json: self.logger.info(f"请求体:json={json}")
            if kwargs: self.logger.info(f"额外参数:kwargs={kwargs}")
            response = self.session.request(method, url, params=params, data=data, json=json, headers=headers, **kwargs)
            self.logger.info(f"状态码:{response.status_code}")
            self.logger.info(f"响应头:{response.headers}")
            self.logger.info(f"响应体[纯文本]:{response.text}")
            self.logger.info(f"响应体[二进制]:{response.content}")
            self.logger.info(f"响应体[json]:{response.json()}")
            # 返回响应结果
            return response

        except Exception as e:
            # 异常处理 报错在日志中打印具体信息
            self.logger.error(f"请求失败:{e}")

    def __call__(self, method, url, params=None, data=None, json=None, headers=None, **kwargs):
        """当把一个对象,当成函数来使用,那么就指定执行当前对象的__call__"""
        return self.send(method, url, params=params, data=data, json=json, headers=headers, **kwargs)

if __name__ == '__main__':
    """"基本使用"""
    # 实例化
    request = Request()

    """发送get请求"""
    # # response = request("GET", 'http://httpbin.org/get')
    # response = request(method="GET", url='http://httpbin.org/get')
    # # 打印响应结果
    # print(response.text)


    """发送post请求"""
    # 请求地址
    url = 'http://httpbin.org/post'
    # 请求参数
    json = {"usernam": "moluo", "password": "123456"}
    # 请求头
    headers = {"company": "luffytest"}
    response = request(method="POST", url=url, json=json, headers=headers)
    # 打印响应结果
    print(response.text)



提交代码版本,代码:

git add .
git commit -m "feature: 新增requtsts http网络请求工具类"
# 推送代码记录到远程服务器的代码仓库
git push origin master


基于flask实现mockserver

在实际项目开发中,经常出现服务端与客户端分离的情况,那么我们在做测试开发时,有可能服务端的功能还没有实现,此时我们可以自己先模拟出服务器返回结果,直接对接口基本功能进行联调测试并编写自动化测试脚本,等服务器上线之后,切换server地址,直接可以简化调试时间,缩短项目测试周期。flask是一个轻量级的python web框架,非常适用于在测试开发中构建模拟api服务器。

安装模块
pip install flask
pip install pymysql
pip install flask_sqlalchemy


项目初始化

api/__init__.py,代码:

import config
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

# SQLAlchemy初始化
db = SQLAlchemy()

app = Flask(__name__)

def init_app():
    # 加载配置
    app.config.from_object(config)

    # 加载mysql数据库配置
    db.init_app(app)

    # db创建数据表
    with app.app_context():
        db.create_all()

    return app


config.py,添加配置信息,代码:

import pathlib  # 路径操作模块,替代 os.path模块,os.path采用字符串来操作路径,pathlib采用面向对象来操作路径

# 项目目录的主目录路径[字符串路徑]
BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix()  # 基本操作系统转换路径的分隔符 as_posix
# 項目目录的主目录路径[路径对象]
BASE_DIR = pathlib.Path(BASE_DIR_STR)

# 项目名
WEB_NAME = "路飞自动化接口测试框架-master"

# 测试自动化项目的运行端口与IP地址
HOST = "127.0.0.1"
PORT = 8088

"""日志配置"""
LOGGING = {
    "name": "luffytest",  # 日志处理器的名称,一般使用当前项目名作为名称
    "filename": (BASE_DIR / "logs/luffytest.log").as_posix(),  # 日志文件存储路径,注意,一定要在项目根目录下手动创建logs目录
    "charset": "utf-8",  # 日志内容的编码格式
    "backup_count": 31,  # 日志文件的备份数量
    "when": "d",   # 日志文件的创建间隔事件,m 表示每分钟创建1个,h表示每小时创建1个,d表示每天创建1个,m0~m6表示每周星期日~星期六创建1个,midnight表示每日凌晨
}


"""mock server 的服务端配置"""
# 数据库连接
SQLALCHEMY_DATABASE_URI: str = "mysql+pymysql://root:123@127.0.0.1:3306/pytest?charset=utf8mb4"
# 查询时会显示原始SQL语句
SQLALCHEMY_ECHO: bool = True

# 调试模式
DEBUG = True
# 监听端口
API_PORT = 8000
# 监听地址
API_HOST = "0.0.0.0"



mock接口实现

api/models.py,代码:

from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash

from . import db


class BaseModel(db.Model):
    """公共模型"""
    __abstract__ = True  # 抽象模型
    id = db.Column(db.Integer, primary_key=True, comment="主键ID")
    name = db.Column(db.String(255), default="", comment="名称/标题")
    is_deleted = db.Column(db.Boolean, default=False, comment="逻辑删除")
    orders = db.Column(db.Integer, default=0, comment="排序")
    status = db.Column(db.Boolean, default=True, comment="状态(是否显示,是否激活)")
    created_time = db.Column(db.DateTime, default=datetime.now, comment="创建时间")
    updated_time = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")

    def __repr__(self):
        return f"<{self.__class__.__name__}: {self.name}>"


class User(BaseModel):
    """用户基本信息表"""
    __tablename__ = "py_user"
    name = db.Column(db.String(255), index=True, comment="用户账户")
    nickname = db.Column(db.String(255), comment="用户昵称")
    _password = db.Column(db.String(255), comment="登录密码")
    intro = db.Column(db.String(500), default="", comment="个性签名")
    avatar = db.Column(db.String(255), default="", comment="头像url地址")
    sex = db.Column(db.SmallInteger, default=0, comment="性别")  # 0表示未设置,保密, 1表示男,2表示女
    email = db.Column(db.String(32), index=True, default="", nullable=False, comment="邮箱地址")
    mobile = db.Column(db.String(32), index=True, nullable=False, comment="手机号码")

    # 存取器
    @property
    def password(self):  # user.password
        return self._password

    @password.setter
    def password(self, rawpwd):  # user.password = '123456'
        """密码加密"""
        self._password = generate_password_hash(rawpwd)

    def check_password(self, rawpwd):
        """验证密码"""
        return check_password_hash(self.password, rawpwd)



api/views.py,代码:

from flask import request
from sqlalchemy import or_

from . import app
from .models import User,db


@app.route("/user/register", methods=["POST"])
def register():
    """
    用户信息注册
    :return:
    """
    try:
        data = request.json
        # 创建用户数据
        user = User(**data)
        db.session.add(user)
        db.session.commit()
        return {"msg": "注册成功!", "data": {"id":user.id, "name": user.name}}, 200
    except Exception as e:
        return {"msg": "注册失败!", "data": {}}, 400


@app.route("/user/login", methods=["POST"])
def login():
    """
    用户登录
    :return:
    """
    user = User.query.filter(
        or_(
            User.mobile == request.json.get("username"),
            User.name == request.json.get("username"),
            User.email == request.json.get("username")
        )
    ).first()  # 实例化模型

    if not user:
        return {"msg": "登录失败!用户不存在!", "data": {}}, 400

    if not user.check_password(request.json.get("password")):
        return {"msg": "登录失败!密码错误!", "data": {}}, 400

    return {"msg": "登录成功", "data":{"id": user.id, "name": user.name}}, 200



进入数据库终端,创建一个数据库.

create database pytest;


项目根目录下,单独创建run.py文件,启动mock server,代码:

import config
from api import init_app

# 注意,务必把模型models的内容以及 views 中的服务端接口引入当前文件,否则flask不识别。
from api import models
from api import views

app = init_app()

if __name__ == '__main__':
    app.run(host=config.API_HOST, port=config.API_PORT)



提交代码版本,代码:

git add .
git commit -m "feature: 基于flask实现mockserver"
# 推送代码记录到远程服务器的代码仓库
git push origin master


编写测试用例测试mockserver

tests/users/test_login.py,代码:

import allure
import config
from logger import LogHandle
from requestor import Request


logger = LogHandle().get_logger()
SERVER_URl = f"http://{config.API_HOST}:{config.API_PORT}"

@allure.epic(config.WEB_NAME)
@allure.feature("用户模块")
@allure.story("登录")
class TestLogin(object):
    @allure.severity(allure.severity_level.CRITICAL)
    def test_username_by_empty(self,):
        allure.dynamic.title("用户名为空,登陆失败")
        allure.dynamic.description("测试用户名为空的登陆结果")

        # 发送请求
        request = Request()
        request("POST", f"{SERVER_URl}/user/login", json={
            "username": "",
            "password": "123456"
        })

    def test_password_by_empty(self,):
        allure.dynamic.title("密码为空,登陆失败")
        allure.dynamic.description("测试密码为空的登陆结果")

        # 发送请求
        request = Request()
        request("POST", f"{SERVER_URl}/user/login", json={
            "username": "xiaoming",
            "password": ""
        })
 


提交代码版本,代码:

git add .
git commit -m "test: 编写测试用例测试mockserver"
# 推送代码记录到远程服务器的代码仓库
git push origin master


基于数据驱动生成用例代码

在实际测试开发中,我们一般使用参数化来自动生成测试用例,前面介绍过常用的有ddt与parametrize。那么在pytest中,因为本身提供了parametrize参数化,所以我们往往会在unittest中采用ddt来实现参数化,而在pytest中采用内置的parametrize即可。

而参数化所需要的测试用例,一般我们也是可以采用json,yaml或Excel文件来存储,如果用例数量太多,还可以改成数据库保存。

YAML

YAML(递归缩写:YAML Ain't a Markup Language,译作:YAML 不是一种标记语言)是一种可读性高,用来表达数据序列化的数据格式,使用场景与xml、json类似,2001年首次发布,在最初开发YAML语言时YAML的意思其实是:"Yet Another Markup Language"(仍是一种标记语言)。后面之所以改了名称,原因是为了强调YAML语言以数据为中心,而不是以标记语言为重点。

语法特点

1. 大小写敏感
2. 使用缩进表示嵌套层级关系,且缩进不允许使用tab,只允许使用空格缩进(缩进的空格数不重要,只要相同层级的元素左对齐即可)
3. '#' 表示注释
4. '~' 表示 空(None, null),也可以使用null表示None,但是尽量不要在属性中出现null或者~
5. yaml文件中,属性唯一不能重复,否则报错
6. 文件扩展名为yml或yaml,如:data.yaml 或者 data.yml


test.yaml,代码:

username: 'xiaoming'
age: 16


数据类型

类型名称 对应python的数据类型 描述
纯量(scalars) 整型、浮点型、布尔型、字符串、None 单个的、不可再分的值
数组(Array) 列表、元祖 一组按次序排列的值,又称为序列(sequence) / 列表(list)
对象(object) 字典 键值对的集合,又称为映射(mapping)/ 哈希(hashes) / 字典(dictionary)
纯量

纯量是最基本的,不可再分的值。类似python中的字符串、布尔值、整型、浮点型、None等

# 纯量
# 字符串
name: 小明
# 字符串内容太多可以换行,yaml中针对源码中的连续多个空格或换行,只会识别成一个空格
title: 如果内容太长,则可以换行
       换行后,两段字符串会被使用空格拼接
       但注意,缩进左对齐,否则报错
# yaml中的属性声明,建议采用python变量的命名规范
content_type: "*/*"  # 如果字符串内容中出现特殊符号,建议加上引号,否则有时候会报错

# 布尔值 False / True
sex: TRUE  # true,True 都可以

# 浮点型
score: 13.5
point: 6.8523015e+5   # 支持科学计数法,也可以6.8523015E+5

# 整型
age: 13
goods_id: 100_200_300  # 支持千分位

# 日期与时间
date: 2022-10-01
time: 12:30:00
datetime: 2018-02-17T15:02:31+08:00

# 空
data1: null
data2: ~
null: ~  # 避免这种情况,不能使用null作为属性名!


数组

一组按次序排列的值,又称为序列(sequence) / 列表(list),类似python的列表。

# 数组
# 数组的成员可以是任意的数据类型
# 方式1:使用中括号进行数组声明,中括号可以换行也可以不换行,无需左对齐
arr1: ["A", "B", True, 1, 1.3]  # 最终还是数组,属性名叫tuple,但并不代表他是元组。
arr2: [
  1,
  2,
  3
]

# 方式2:使用-标记成员进行数组声明,数组成员必须缩进左对齐,否则报错,并且-与成员值必须使用空格隔开 'arr3': [1, 2, 3]
arr3:
  - 1
  - 2
  - 3

# 多层数组嵌套[类似python的多维列表],这里只演示2层,还可以声明更多层嵌套 'arr4': [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]
arr4: [
  [1,2,3,4,5],
  [6,7,8,9,10]
]

# 'arr5': [['1.a', '1.b'], ['2.a', '2.b']]
arr5:
  -
    - 1.a
    - 1.b
  -
    - 2.a
    - 2.b



对象

键值对的集合,又称为映射(mapping)/ 哈希(hashes) / 字典(dictionary),类似python中的字典,但比字典灵活。

# 对象
# 对象的成员是键值对格式数据,属性与属性之间必须使用英文冒号(:)来映射,同时每一个之间使用英文逗号(,)隔开
# 方式1:使用花括号声明,花括号可以换行,无需左对齐
dict: {name: xiaoming, age: 17}
map: {
  name: xiaohong,
  age: 16,
  sex: false
}

# 方式2:使用缩进键值对成员进行对象声明,对象成员必须缩进左对齐,不对齐则报错
data:
  name: xiaoming
  age: 16

# 多层嵌套对象[类似python的多维字典]
# 'people': {'name': 'xiaoming', 'age': 26, 'love': ['code', 'watching TV', 'shopping'], 'son': {'name': 'xiaohui', 'age': 3}}
people:
  name: xiaoming
  age: 26
  love:
    - code
    - watching TV
    - shopping
  son:
    name: xiaohui
    age: 3

goods_list:
  -
    name: 立白洗衣粉
    price: 39.90
    num: 3
  -
    name: 黑猫神蚊香液
    price: 56.50
    num: 6


python操作YAML

安装yaml模块

pip install pyyaml


基本使用

import yaml
"""读取yaml文件的数据"""
# with open("./data.yaml", "r",encoding="utf-8") as f:
#     content = f.read()
#     data = yaml.load(content, Loader=yaml.FullLoader)
#     print(data)
#     print(data["name"])


"""把数据写入yaml文件"""
from datetime import datetime


with open("./data2.yaml", "w", encoding="utf-8") as f:
    # yaml.dump(data, f, Dumper=yaml.SafeDumper) # 没有多字节内容的情况下
    data = {
        "name": "xiaoming",
        "age": 17,
        "datetime": datetime.now(),
        "point": 1.245464E10,
        "goods_list": [
            {"name": "xiaoming","age": 17, "sex": True},
            {"name": "xiaoming","age": 17, "sex": True},
            {"name": "xiaoming","age": 17, "sex": True},
            {"name": "xiaoming","age": 17, "sex": True},
        ],
        "author_list": ["小明", "小白", "小红"],
        "user_info": {"username":"小明", "password": "123456"}
    }
    yaml.dump(data, f, Dumper=yaml.SafeDumper, allow_unicode=True)  # 有多字节内容的情况下,中文就是多字节内容



封装yaml工具类,utils/yamler.py,代码:

import yaml
from logger import LogHandle


class Yaml(object):
    """yaml操作工具类"""
    __instance = None

    def __new__(cls, *args, **kwargs):
        if not cls.__instance:
            print("创建Yaml的单例")
            cls.__instance = super(Yaml, cls).__new__(cls, *args, **kwargs)
        return cls.__instance

    def __init__(self):
        self.logger = LogHandle().get_logger()

    def read(self, path):
        """读取yaml文件"""
        with open(path, encoding="utf-8") as f:
            result = f.read()
            if result:
                result = yaml.load(result, Loader=yaml.FullLoader)
            return result

    def write(self, path, data):
        """写入yaml文件"""
        try:
            with open(path, "w", encoding="utf-8") as f:
                yaml.dump(data, f, Dumper=yaml.SafeDumper, allow_unicode=True)
            return True
        except Exception as e:
            self.logger(f"写入数据到yaml文件失败:{e}")
            return False


if __name__ == '__main__':
    ya = Yaml()
    data = ya.read("../demo/yaml_demo/data.yaml")
    print(data, type(data))



提交代码版本,代码:

git add .
git commit -m "feature: 封装yaml工具类"
# 推送代码记录到远程服务器的代码仓库
git push origin master


基于yaml数据驱动生成测试用例

data/user_login.yaml,代码:

-
  name: 用户登录-测试用户名与密码为空的情况
  request:
    method: post
    url: http://127.0.0.1:8000/user/login
    json:
      "username": ""
      "password": ""
  assert:
    - code == 400

-
  name: 用户登录-测试密码为空的情况
  request:
    method: post
    url: http://127.0.0.1:8000/user/login
    json:
      "username": "xiaoming"
      "password": ""
  assert:
    - code == 400


-
  name: 用户登录-测试有账号密码但是密码错误的情况
  request:
    method: post
    url: http://127.0.0.1:8000/user/login
    json:
      "username": "xiaoming"
      "password": "123"
  assert:
    - code == 400


-
  name: 用户登录-测试有账号密码而且正确的情况
  request:
    method: post
    url: http://127.0.0.1:8000/user/login
    json:
      "username": "xiaoming"
      "password": "1234"
  assert:
    - code == 200
    - "'data' in json"



utils/assertor.py,代码:

from logger import LogHandle

logger = LogHandle().get_logger()


def assertor(assert_list, response):
    """断言函数"""
    if type(assert_list) is not list:
        assert_list = [assert_list]

    for expr in assert_list:
        logger.info(f"开始断言:assert {expr}")
        if expr:
            exec(f"assert {expr}", {
                "code": response.status_code,
                "json": response.json(),
                "text": response.text,
                "content": response.content,
                "headers": response.headers,
            })

        logger.info(f"断言通过:assert {expr}")

if __name__ == '__main__':
    # Response就是模拟requests HTTP请求工具的返回结果对象
    class Response(object):
        status_code = 400
        text = "对不起,登陆失败!"
        content = "对不起,登陆失败!"
        headers = []

        @classmethod
        def json(cls):
            return {"id": 1},

    assert_list = [
        "code == 400",
        "'失败'in text",
    ]

    assertor(assert_list, Response())



tests/users/test_login.py,代码:

import allure
import config
import pytest
from logger import LogHandle
from requestor import Request
from yamler import  Yaml
from assertor import assertor

logger = LogHandle().get_logger()
SERVER_URl = f"http://{config.API_HOST}:{config.API_PORT}"
yaml = Yaml()


@allure.epic(config.WEB_NAME)
@allure.feature("用户模块")
@allure.story("登录")
class TestLogin(object):
    # @allure.severity(allure.severity_level.CRITICAL)
    # def test_username_by_empty(self,):
    #     allure.dynamic.title("用户名为空,登陆失败")
    #     allure.dynamic.description("测试用户名为空的登陆结果")
    #
    #     # 发送请求
    #     request = Request()
    #     request("POST", f"{SERVER_URl}/user/login", json={
    #         "username": "",
    #         "password": "123456"
    #     })
    #
    # def test_password_by_empty(self,):
    #     allure.dynamic.title("密码为空,登陆失败")
    #     allure.dynamic.description("测试密码为空的登陆结果")
    #
    #     # 发送请求
    #     request = Request()
    #     request("POST", f"{SERVER_URl}/user/login", json={
    #         "username": "xiaoming",
    #         "password": ""
    #     })

    @pytest.mark.parametrize("kwargs", yaml.read(config.BASE_DIR / "data/user_login.yaml"))
    def test_login(self, kwargs):
        request = Request()
        allure.dynamic.title(kwargs.get('name'))
        request.logger.info(f"开始请求测试接口:{kwargs.get('name')}")
        data = kwargs.get('request')
        response = request(data.get("method"), f'{SERVER_URl}{data.get("url")}', json=data.get("json"))
        assertor(kwargs.get("assert"), response)



提交代码版本,代码:

git add .
git commit -m "feature: 基于yaml数据驱动生成测试用例"
# 推送代码记录到远程服务器的代码仓库
git push origin master


Excel

在测试开发中,如果测试用例数量太多,使用yaml也存在很大的维护成本,此时可以考虑使用Excel或者数据库保存更多的测试用例,python中操作Exeel文件的模块有很多,常用的有:xlrd+xlwt,pyexcel+openpyxl等等。

安装模块

pip install xlrd
pip install xlwt


封装Excel工具类

utils/excel.py,代码:

import xlrd, json

class Excel(object):
    """Excel文件操作工具类"""
    def __init__(self, filename):
        self.workbook = xlrd.open_workbook(filename, formatting_info=True)

    def get_sheet_names(self):
        """
        获取当前excel文件所有的工作表的表名
        :return:
        """
        return self.workbook.sheet_names()

    def __get_sheet(self, sheet_index_or_name):
        """
        根据sheet的索引或名称,获取sheet对象
        :param sheet_index_or_name: sheet的索引或名称
        :return:sheet对象
        """
        if isinstance(sheet_index_or_name, int):
            if len(self.workbook.sheet_names()) > sheet_index_or_name:
                return self.workbook.sheet_by_index(sheet_index_or_name)
            else:
                raise Exception("Invalid Sheet Index!")
        elif isinstance(sheet_index_or_name, str):
            if sheet_index_or_name in self.workbook.sheet_names():
                return self.workbook.sheet_by_name(sheet_index_or_name)
            else:
                raise Exception("Invalid Sheet Name!")

    def get_rows_num(self,sheet_index_or_name):
        """
        获取指定工作表的数据总行数
        :param sheet_index_or_name: 工作表名或索引
        :return:
        """
        return self.__get_sheet(sheet_index_or_name).nrows

    def get_cols_num(self,sheet_index_or_name):
        """
        获取指定工作表的数据总列数
        :param sheet_index_or_name: 工作表名或索引
        :return:
        """
        return self.__get_sheet(sheet_index_or_name).ncols

    def get_cell_value(self, sheet_index_or_name, row_index, col_index):
        """
        获取指定工作表的指定位置的数据值
        :param sheet_index_or_name: 工作表名或索引
        :param row_index: 行下标,从0开始
        :param col_index: 列下标,从0开始
        :return:
        """
        sheet = self.__get_sheet(sheet_index_or_name)
        if sheet.nrows and sheet.ncols:
            return sheet.cell_value(row_index, col_index)
        else:
            raise Exception("Index out of range!")

    def get_data(self, sheet_index_or_name, fields, first_line_is_header=True):
        """
        获取工作表的所有数据
        :param sheet_index_or_name: 工作表名或索引
        :param fields: 返回数据的字段名
        :param first_line_is_header: 工作表是否是否表头,也就是非数据
        :return:
        """
        rows = self.get_rows_num(sheet_index_or_name)
        cols = self.get_cols_num(sheet_index_or_name)
        data = []
        for row in range(int(first_line_is_header), rows):
            row_data = {}
            for col in range(cols):
                cell_data = self.get_cell_value(sheet_index_or_name, row, col)
                if type(cell_data) is str and ("{" in cell_data and "}" in cell_data or "[" in cell_data and "]" in cell_data):
                    """判断如果表格中填写的数据是json格式键值对,则采用json模块转换成字典"""
                    cell_data = json.loads(cell_data)
                row_data[fields[col]] = cell_data
            data.append(row_data)

        return data

if __name__ == '__main__':
    xls = Excel("../data/case_user.xls")
    fields = [
        "case_id",
        "module_name",
        "case_name",
        "method",
        "url",
        "headers",
        "params_desc",
        "params",
        "assert_result",
        "real_result",
        "remark",
    ]

    print(xls.get_data(0, fields))

"""
[
    {'case_id': 1.0, 'module_name': '用户模块', 'case_name': '用户登录-测试用户名为空的情况', 'method': 'post', 'url': 'http://127.0.0.1:8000/user/login', 'headers': '', 'params_desc': 'username: 用户名\npassword: 密码', 'params': {'username': '', 'password': '123456'}, 'assert_result': 'code==400', 'real_result': '', 'remark': ''}, 
    {'case_id': 2.0, 'module_name': '用户模块', 'case_name': '用户登录-测试密码为空的情况', 'method': 'post', 'url': 'http://127.0.0.1:8000/user/login', 'headers': '', 'params_desc': 'username: 用户名\npassword: 密码', 'params': {'username': 'xiaoming', 'password': ''}, 'assert_result': 'code==400', 'real_result': '', 'remark': ''}, 
    {'case_id': 3.0, 'module_name': '用户模块', 'case_name': '用户登录-测试账号密码正确的情况', 'method': 'post', 'url': 'http://127.0.0.1:8000/user/login', 'headers': '', 'params_desc': 'username: 用户名\npassword: 密码', 'params': {'username': 'xiaoming', 'password': '123456'}, 'assert_result': 'code==200', 'real_result': '', 'remark': ''}, 
    {'case_id': 4.0, 'module_name': '用户模块', 'case_name': '用户登录-测试使用手机号码登录', 'method': 'post', 'url': 'http://127.0.0.1:8000/user/login', 'headers': '', 'params_desc': 'username: 手机号\npassword: 密码', 'params': {'username': '13312345678', 'password': '123456'}, 'assert_result': 'code==200', 'real_result': '', 'remark': ''}]

"""



config.py,代码:

import pathlib  # 路径操作模块,替代 os.path模块,os.path采用字符串来操作路径,pathlib采用面向对象来操作路径

# 项目目录的主目录路径[字符串路徑]
BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix()  # 基本操作系统转换路径的分隔符 as_posix
# 項目目录的主目录路径[路径对象]
BASE_DIR = pathlib.Path(BASE_DIR_STR)

# 项目名
WEB_NAME = "路飞自动化接口测试框架-master"

# 测试自动化项目的运行端口与IP地址
HOST = "127.0.0.1"
PORT = 8088

"""日志配置"""
LOGGING = {
    "name": "luffytest",  # 日志处理器的名称,一般使用当前项目名作为名称
    "filename": (BASE_DIR / "logs/luffytest.log").as_posix(),  # 日志文件存储路径,注意,一定要在项目根目录下手动创建logs目录
    "charset": "utf-8",  # 日志内容的编码格式
    "backup_count": 31,  # 日志文件的备份数量
    "when": "d",   # 日志文件的创建间隔事件,m 表示每分钟创建1个,h表示每小时创建1个,d表示每天创建1个,m0~m6表示每周星期日~星期六创建1个,midnight表示每日凌晨
}


# excel测试用例字段格式
FIELD_LIST = [
    "case_id",        # 用例编号
    "module_name",    # 模块名称
    "case_name",      # 用例名称
    "method",         # 请求方式
    "url",            # 接口地址
    "headers",        # 请求头
    "params_desc",    # 参数说明
    "params",         # 请求参数
    "assert_result",  # 预期结果
    "real_result",    # 实际结果
    "remark",         # 备注
]


"""mock server 的服务端配置"""
# 数据库连接
# SQLALCHEMY_DATABASE_URI: str = "数据库类型://账户:密码@IP地址:端口/数据库名称?charset=utf8mb4"
SQLALCHEMY_DATABASE_URI: str = "mysql+pymysql://root:123@127.0.0.1:3306/pytest?charset=utf8mb4"
# 查询时会显示原始SQL语句
SQLALCHEMY_ECHO: bool = True

# 调试模式
DEBUG = True
# 监听端口
API_PORT = 8000
# 监听地址
API_HOST = "127.0.0.1"



提交代码版本,代码:

git add .
git commit -m "feature: 封装Excel工具类"
# 推送代码记录到远程服务器的代码仓库
git push origin master


基于excel文件实现数据驱动生成测试用例

tests/users/user_login.py,代码:

import allure
import config
import pytest
from logger import LogHandle
from requestor import Request
from yamler import  Yaml
from assertor import assertor
from utils.excel import Excel

logger = LogHandle().get_logger()
SERVER_URl = f"http://{config.API_HOST}:{config.API_PORT}"
yaml = Yaml()


@allure.epic(config.WEB_NAME)
@allure.feature("用户模块")
@allure.story("登录")
class TestLogin(object):
    # @allure.severity(allure.severity_level.CRITICAL)
    # def test_username_by_empty(self,):
    #     allure.dynamic.title("用户名为空,登陆失败")
    #     allure.dynamic.description("测试用户名为空的登陆结果")
    #
    #     # 发送请求
    #     request = Request()
    #     request("POST", f"{SERVER_URl}/user/login", json={
    #         "username": "",
    #         "password": "123456"
    #     })
    #
    # def test_password_by_empty(self,):
    #     allure.dynamic.title("密码为空,登陆失败")
    #     allure.dynamic.description("测试密码为空的登陆结果")
    #
    #     # 发送请求
    #     request = Request()
    #     request("POST", f"{SERVER_URl}/user/login", json={
    #         "username": "xiaoming",
    #         "password": ""
    #     })

    # @pytest.mark.parametrize("kwargs", yaml.read(config.BASE_DIR / "data/user_login.yaml"))
    # def test_login(self, kwargs):
    #     """数据驱动自动化测试-基于yaml生成"""
    #     request = Request()
    #     allure.dynamic.title(kwargs.get('name'))
    #     request.logger.info(f"开始请求测试接口:{kwargs.get('name')}")
    #     data = kwargs.get('request')
    #     response = request(data.get("method"), f'{data.get("url")}', json=data.get("json"))
    #     assertor(kwargs.get("assert"), response)

    @pytest.mark.parametrize("kwargs", Excel(config.BASE_DIR / "data/case_user.xls").get_data(0, config.FIELD_LIST))
    def test_login(self, kwargs):
        """数据驱动自动化测试-基于excel生成"""
        request = Request()
        allure.dynamic.title(kwargs.get('case_name'))
        request.logger.info(f"开始请求测试接口:{kwargs.get('case_name')}")
        if kwargs.get("method").lower() in ["get", "delete"]:
            """发送get或delete"""
            response = request(kwargs.get("method"), f'{kwargs.get("url")}', params=kwargs.get("params"))
        else:
            """发送post,put,patch"""
            response = request(kwargs.get("method"), f'{kwargs.get("url")}', json=kwargs.get("params"))
        assertor(kwargs.get("assert_result"), response)



提交代码版本,代码:

git add .
git commit -m "feature: 基于excel数据驱动生成测试用例"
# 推送代码记录到远程服务器的代码仓库
git push origin master


认证测试

基于jwt实现登陆正确,并在pytest中基于conftest脚手架使用生成器保持登陆状态

在用户注册/登陆以后,往往项目会返回登陆状态(jwt,session, cookie)提供给客户端,所以上面我们所实现的mockserver实际上是有问题的。因此接下来我们继续来模拟存在jwt认证鉴权的服务端,并在测试框架中基于conftest.py来实现认证测试的这个流程。

1670397775010

image-20220611183354232

安装jwt

pip install flask-jwt-extended


config.py,代码:

# 秘钥,不管是使用session还是jwt认证,都需要对认证的信息鉴权加密
SECRET_KEY = "ac361a52518d99f4525c1cfe5ba635572190aa6ac52bc8f27ae1b07529feafd0"


api/__init__.py,代码:

import config
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_jwt_extended import JWTManager

# SQLAlchemy初始化
db = SQLAlchemy()

app = Flask(__name__)

jwt = JWTManager()

def init_app():
    """服务端初始化"""
    # 加载配置
    app.config.from_object(config)
    # 加载mysql数据库配置
    db.init_app(app)

    # jwt初始化
    jwt.init_app(app)

    # 自动创建数据表
    with app.app_context():
        db.create_all()

    return app


api/views.py,代码:

from flask import request
from sqlalchemy import or_
from flask_jwt_extended import jwt_required, create_access_token

from . import app
from .models import User,db


@app.route("/user/register", methods=["POST"])
def register():
    """
    用户信息注册
    :return:
    """
    try:
        data = request.json
        # 创建用户数据
        user = User(**data)
        db.session.add(user)
        db.session.commit()
        return {"msg": "注册成功!", "data": {"id":user.id, "name": user.name}}, 200
    except Exception as e:
        return {"msg": "注册失败!", "data": {}}, 400


@app.route("/user/login", methods=["POST"])
def login():
    """
    用户登录
    :return:
    """
    user = User.query.filter(
        or_(
            User.mobile == request.json.get("username"),
            User.name == request.json.get("username"),
            User.email == request.json.get("username")
        )
    ).first()  # 实例化模型

    if not user:
        return {"msg": "登录失败!用户不存在!", "data": {}}, 400

    if not user.check_password(request.json.get("password")):
        return {"msg": "登录失败!密码错误!", "data": {}}, 400

    # 生成token,并返回给客户端
    access_token = create_access_token(identity={"id": user.id, "username": user.name})
    return {"msg": "登录成功", "data": {"token": access_token}}, 200


@app.route("/user", methods=["GET"])
@jwt_required()   # 当前这个装饰器的作用就是 校验用户是否登录
def user_center():
    return {"errmsg": "访问成功"}



项目根目录下创建conftest.py,代码:

import pytest
import config
from utils.requestor import Request
from utils.yamler import Yaml

yaml = Yaml()


@pytest.fixture(scope="class", autouse=False)
def jwt_token():
    request = Request()
    request.logger.info("获取token")
    data = yaml.read(config.BASE_DIR / "data/test_user.yaml")
    response = request(data.get("method"), data.get("url"), json=data.get("json"))
    token = response.json().get("data", {}).get("token")
    yield token
    # 生成器函数中的暂停关键字,作用是当代码运行到yield时,把yield右边的数据作为返回值提供给调用处,把代码执行权交出去。
    request.logger.info("移除token")


data/test_user.yaml,填写一个保存正确用户信息的用例,方便在conftest中发送正确的账户信息获取token,代码:

method: post
url: http://127.0.0.1:8000/user/login
json:
  "username": "xiaoming"
  "password": "123456"


新建一个测试用例,tests/test_user.py,代码:

import allure
import pytest
import config
from utils.requestor import Request
from utils.yamler import Yaml
from utils.assertor import assertor


yaml = Yaml()


@allure.epic(config.WEB_NAME)
@allure.feature("用户模块")
@allure.story("用户中心")
class TestUser(object):
    @pytest.mark.usefixtures("jwt_token")
    @pytest.mark.parametrize("kwargs", yaml.read(config.BASE_DIR / "data/user_info.yaml"))
    def test_user(self, jwt_token, kwargs):
        allure.dynamic.title(kwargs.get('name'))
        request = Request()
        request.logger.info(f"开始请求测试接口:{kwargs.get('name')}")
        data = kwargs.get('request')
        data['headers']["Authorization"] = data['headers']["Authorization"].format(token=jwt_token)
        response = request(data.get("method"), data.get("url"), headers=data.get("headers"))
        assertor(kwargs.get("assert"), response)



data/user_info.yaml,代码:

-
  name: 用户中心-测试jwt认证鉴权
  request:
    method: get
    url: http://127.0.0.1:8000/user
    headers:
      Authorization: "Bearer {token}"
  assert:
    - code == 200



提交代码版本,代码:

git add .
git commit -m "feature: 基于conftest实现jwt认证鉴权的测试用例"
# 推送代码记录到远程服务器的代码仓库
git push origin master


关于生成测试报告的命令

参考:https://docs.python.org/zh-cn/3/library/subprocess.html

关于打包

参考:https://www.cnblogs.com/Neeo/articles/11934072.html

处理cookie

思路:

  1. 在每个用例发请求的时候,查看响应结果是否有cookies返回
  2. 如果有cookie返回,就把保存起来
    1. 保存到本地指定目录,以域名命名,当有接口需要cookies的时候,去指定目录里根据域名匹配查找cookies,然后携带。(相似Postman)
    2. 将cookies保存到当前的用例对象中,可以为用例对象新建一个临时字段,来保存cookies,谁要用,就来找就完了。

问题:如何获取域名?

从url上切

# import requests
# # response = requests.request(method='post', url='http://www.neeo.cc:6002/pinter/bank/api/login2', data={"userName":"admin", "password":1234})
# # data = response.json()
#
# response = requests.request(method='post', url='http://www.neeo.cc:6002/pinter/bank/api/login2', data={"userName":"admin", "password":1234})
#
# data = response.json()
# print(data)
# response = requests.request(method='get', url='http://www.neeo.cc:6002/pinter/bank/api/query2', params={"userName": "admin"},
#                             headers={"testfan-token": data['data']}
#                             )
# print(response.json())

data = {
    'code': '0',
    'message': 'success',
    'data': '95e947dd5a684105b97cf74d3a4514a8',
    "info": {
        "address": "北京",
        "phone": "1212313123"
    },
    "item": [
        {"user": "zhangkai"},
        {"user": "wuaihua"},
    ],
    "userName": "zhangkai",
    "password": 1234,
}

# print(data['info']['phone'])
# a = 'info.address'
a = 'item.[1].user'
# a = '$..user'


from jsonpath_rw import parse
#
# js_exe = parse(a)
# match = js_exe.find(data)
# print([i.value for i in match])

import re
import json
data2 = {
    "userName": "${neeo_002>params>userName}$",
    "password": "${neeo_003>data>password}$",
    "user": "${neeo_003>data>item.[0].user}$"
}

pattern = re.compile('\${(.*?)}\$')
match_list = pattern.findall(json.dumps(data2))
print(match_list)
for i in match_list:
    case, params, json_path = i.split(">")
    print(case, params, json_path)
    match = parse(json_path).find(data)
    temp = [v.value for v in match][0]
    # re.match(json_path, json.dumps(data2), temp)
    data2= re.sub(pattern, temp, json.dumps(data2), 1)
    print(data2)
    # break

"""
JsonPath

pip install jsonpath-rw



正则匹配: ${neeo_001>data>data.info.address.phone}$   --> neeo_001>data>data.info.address.phone  --> abc

正则替换:${neeo_001>data>data.info.address.phone}$ --> abc

abc
${}$
${neeo_001>data>data.info.address.phone}$
abc
${neeo_001>headers>data}$
${neeo_001>params>data}$

neeo_001 data data.info.address.phone
    - neeo_001:依赖数据来自于哪个用例
    - data: 来自于哪个用例中的什么参数:
        - response.json
        - headers
        - cookies
        - params
    - data.info.address.phone: 数据所在的路径

neeo_001 data data
case_num 

"""


踩过的坑

代码执行pytest.main或者终端执行pytest均生成不了json数据

 # 启动pytest框架
    # pytest.main()   # 此方法不可靠,执行时中间夹杂别的py文件导致allure生成不了json数据
    os.system('pytest -s -v --alluredir=./allure_results --clean-alluredir')

    # 生成报告html文件
    os.system('allure generate ./allure_results -o ./allure_reports')

    # 基于http协议打开HTML测试报告
    # os.system(f'allure open ./allure_reports -h {config.HOST} -p {config.PORT}')

持续集成-jenkins

docker篇:

Jenkins篇:

什么是持续集成

持续集成

持续集成是一种软件开发实践经验,采用持续集成时,开发人员会定期将他们的代码变更合并到一个中央存储库中,之后系统会自动运行构建和测试操作。持续集成的主要目标是更快发现并解决错误,提高软件质量,并缩短验证和发布新软件更新所需的时间。

持续交付

持续交付是一种软件开发实践。通过持续交付,系统可以自动构建和测试代码更改,并为将其发布到生产环境做好准备。持续交付可以在构建阶段后将所有代码变更都部署到测试环境和/或生产环境中,从而实现对持续集成的扩展。当持续交付得以正确实施时,开发人员将始终能够获得一个已通过标准化测试流程的部署就绪型构建工件。

参考:

关于jenkins

Jenkins是一个开源项目,是基于Java开发的集成工具。Jenkins是一款开源CI&CD软件,用于自动化各种任务,包括构建、测试和部署软件.
Jenkins支持各种运行方式,可通过系统包,Docker或者通过一个独立的Java程序。

文档:https://www.jenkins.io/zh/doc/

jenkins安装

前提:有java 环境

支持各种的平台:

  • Windows,有msi
  • Tomcat环境,可以部署到Windows、Mac OS、linux
  • docker,Windows、Mac OS、linux

最低推荐配置:

  • 256MB可用内存
  • 1GB可用磁盘空间(作为一个Docker容器运行jenkins的话推荐10GB)

为小团队推荐的硬件配置:

  • 1GB+可用内存
  • 50 GB+ 可用磁盘空间

软件配置:

  • Java 8—无论是Java运行时环境(JRE)还是Java开发工具包(JDK)都可以。

jenkins for docker

本次安装环境:阿里云服务器(centos7.4) + docker 19.03.8

常用的镜像有两个:

docker pull jenkins:latest
docker pull jenkinsci/blueocean:latest

  1. 下载jenkins镜像
[root@r ~]# docker pull jenkinsci/blueocean:latest
[root@r ~]# docker images |grep jenkins
jenkinsci/blueocean   latest              789f2766377f        15 hours ago        567MB

  1. 启动docker容器
docker run \
  -u root \
  --name myjenkins \
  --restart=always \
  -d \
  -p 6010:8080 \
  -p 50000:50000 \
  --env JAVA_OPTS="-Xmx512m" \
  -e JAVA_OPTS=-Duser.timezone=Asia/Shanghai \
  -v /etc/localtime:/etc/localtime \
  -v /tmp/jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  jenkinsci/blueocean:latest

1669796682025

  1. 启动后,会默认的创建一个admin用户(只有基于jenkinsci/blueocean:latest镜像的jenkins容器会创建一个admin用户),并且生成一个初始密码:
# 查看密码,仅除此使用
[root@r ~]# docker logs -t -f --tail 40 myjenkins
# 查看密码,推荐
[root@r ~]# docker exec -it -u root myjenkins bash
bash-5.1# cat /var/jenkins_home/secrets/initialAdminPassword 
e2be0a4bd7db42dca7c47c60fcdc636a

  1. 使用初始密码解锁jenkins。

image-20200527095044330

  1. 新手入门之,安装推荐的插件:

image-20200527095420337

image-20200527095652321

  1. 新手入门之创建初始管理员用户,如果在这一步手动创建了管理员用户,默认创建的admin用户将会被注销。

image-20200527095849183

  1. 新手入门之实例配置,配置jenkins url

image-20200527095930600

  1. 新手入门结束

image-20200527100020756

  1. 欢迎来到jenkins

image-20200527100115901

常用的操作

手动重启jenkins

  1. 前台访问:
http://47.52.72.214:6010/restart

  1. 安装完插件之后,可以选择重启jenkins,使插件安装生效。
  2. docker命令来重启jenkins
docker restart myjenkins

插件管理

安装插件

方式1:

管理jenkins ---> manges plugins ---> 可选插件,搜索要安装的插件,可选择,安装并且重启jenkins

image-20200527103254015

由于下载地址是插件官网,可能会导致下载失败,然后安装失败.....

如果安装失败,就采用第二种方式。

方式2:

  1. 手动下载插件,参考网址:https://updates.jenkins.io/download/plugins/,这里以allure插件为例:

image-20200527103548850

image-20200527103632896

然后手动将下载到本地的hpi插件,上传到jenkins。

管理jenkins ---> manges plugins ---> 高级选项,下拉选择上传插件。点击本地文件上传

image-20200527104023259

完事之后,重启jenkins,插件生效。

卸载插件

管理jenkins ---> manges plugins ---> 已安装,搜索要卸载的插件,并且勾选然后点击卸载。

image-20200527103731703

image-20200527103832245

解决:插件下载慢的问题

修改/var/jenkins_home/hudson.model.UpdateCenter.xml 文件,换国内源:

bash-4.4# cat /var/jenkins_home/hudson.model.UpdateCenter.xml 
<?xml version='1.1' encoding='UTF-8'?>
<sites>
  <site>
    <id>default</id>
    <url>https://updates.jenkins.io/update-center.json</url>
  </site>
</sites>

bash-4.4# vi /var/jenkins_home/hudson.model.UpdateCenter.xml 
bash-4.4# cat /var/jenkins_home/hudson.model.UpdateCenter.xml 
<?xml version='1.1' encoding='UTF-8'?>
<sites>
  <site>
    <id>default</id>
    <url>https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json</url>
  </site>

常用的国内源地址:函数的返回值

https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json
http://mirror.esuni.jp/jenkins/updates/update-center.json
http://mirror.xmission.com/jenkins/updates/update-.json

用户管理

常用的操作:

  • 创建用户
  • 修改密码

必要的配置

管理jenkins ---> 全局安全配置,勾选允许用户注册,完事点击保存。

image-20200527111143285

修改密码

  1. manage jenkins ---> mange user ,用户列表,选择用户id。

image-20200527105935419

  1. 选择设置,下拉重新输入新的密码,然后下拉点击确认按钮。

image-20200527110334393

创建用户

管理jenkins ---> 管理用户

image-20200527111449724

点击新建用户

image-20200527111515981

image-20200527111554650

创建成功后的用户列表:

image-20200527111711483

删除用户

这里只能删除普通的用户。

管理jenkins ---> 管理用户,点击红色按钮进行删除。

image-20200527111737296

确认删除:

image-20200527111812777

凭据管理

由于jenkins要和别的软件或者平台打交道,那么就要拿着先关凭据去做认证。

  • 添加凭据
  • 修改凭据
  • 删除凭据

添加凭据

jenkins主页 ---> 凭据 ---> 全局凭据

image-20200527114737389

image-20200527114757289

此时进入到了全局的凭据列表,列出了所有的凭据。

image-20200527114822093

如何添加凭据呢?

点击左侧的添加凭据按钮。

image-20200527114955263

创建成功,会在凭据列表展示出来,可以点击右侧按钮编辑该凭据。

image-20200527115046561

修改凭据

在凭据列表中,点击指定凭据后的小三角或者右侧的更新按钮,来修改凭据。

image-20200527115151066

来修改相关内容。

image-20200527115237732

删除凭据

凭据列表,选择指定凭据后的小三角,选择删除选项。

image-20200527115327621

确认删除。

image-20200527115356054

问题

在上述的配置GitHub账号密码的凭据中,有的时候会遇到如下问题:

img

如何解决:

可以使用ssh形式来解决:

配置github公钥私钥凭据

生成公钥私钥

github对SSH密钥做了升级,原来的SHA-1,rsa等一些已经不支持了,由于我使用的是rsa,可能和大部分用户一样,所以今天在push代码时候遇到了这个问题,记录以下。

在本机使用git来生成公钥私钥:

ssh-keygen -t ed25519 -C "your-email"

# 示例,一路回车
$ ssh-keygen -t ed25519 -C "your-email"
Generating public/private rsa key pair.
Enter file in which to save the key (/c/Users/Anthony/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /c/Users/Anthony/.ssh/id_rsa.
Your public key has been saved in /c/Users/Anthony/.ssh/id_rsa.pub.
The key fingerprint is:
SHA256:IpMirm5XMasMF74a2ti1RSXZyiu35hORaYTYfkoWIz0 tingyuweilou@163.com
The key's randomart image is:
+---[RSA 2048]----+
|   + .           |
|  o E .o         |
|   o =ooo        |
|   .+=*+         |
|. oo=+O.S        |
|.o +.*.o         |
| o+ * +.         |
|o=.* =o.         |
|*o= .oo.         |
+----[SHA256]-----+



1670064916202

在本机(windows)的用户,你的用户下面有个.ssh目录,生成了公钥私钥两个文件。

image-20200527120353486

配置公钥

  1. 在GitHub的settings中,添加ssh key

image-20200527120549233

  1. 将本地的公钥添加进去

image-20200527120652870

image-20200527120703687

添加成功。

image-20200527120720621

配置私钥

在jenkins中,凭据管理下的凭据列表,添加一个凭据。

image-20200527121018344

添加成功后的凭据列表:

image-20200527121048504

1670040780355

使用凭据

如在job中:

image-20200527121156350

错误参考:https://www.cnblogs.com/my_captain/p/11020381.html

配置python环境

不同的镜像依赖的基础镜像不同,导致容器内容的包管理工具也不同,如何查看以来的基础镜像:

[root@r docker_data]# docker exec -it -u root myjenkins bash
bash-4.4# cat /etc/issue 
Welcome to Alpine Linux 3.9
Kernel \r on an \m (\l)



常见的基础镜像的包管理工具有:

  • Alpine Linux 3.9:apk
  • Debian:apt-get
  • centos:yum

常用的apk的操作:

# 更新源列表
apk update  # 更新最新镜像源列表

apk upgrade            # 升级所有软件
apk upgrade openssh    # 升级指定软件
apk upgrade openssh  vim  bash nginx # 升级多个软件
apk add --upgrade busybox  # 指定升级部分软件包

# 搜索包
apk search                 # 查找所有可用软件包
apk search -v              # 查找所用可用软件包及其描述内容
apk search -v ‘包名’        # 通过软件包名称查找软件包
apk search -v -d ‘docker’  # 通过描述文件查找特定的软件包


# 安装包
apk add 包名

apk add python3
apk add openssh                       # 安装一个软件
apk add openssh  vim  bash nginx      # 安装多个软件
apk add --no-cache mysql-client       # 不使用本地镜像源缓存,相当于先执行update,再执行add

apk add python3=3.6.9-r2
apk add python2=2.7.18-r0


# 查看包信息
apk info           # 列出所有已安装的软件包
apk info -a zlib   # 显示完整的软件包信息
apk info --who-owns /usr/sbin/nginx # 显示指定文件属于的包
# 删除包
apk del 包名

apk del openssh      # 删除一个软件
apk del nginx mysql  # 删除多个软件

# 配置源的方法
localhost:/etc/apk# vim /etc/apk/repositories 

//阿里云源
http://mirrors.aliyun.com/alpine/v3.15/main
http://mirrors.aliyun.com/alpine/v3.15/community


#  Alpine Linux服务管理
rc-update    # 主要用于不同运行级增加或者删除服务。
rc-status    # 主要用于运行级的状态管理。
rc-service   # 主用于管理服务的状态
openrc       # 主要用于管理不同的运行级。

# 重启网络服务: rc-service networking restart
# 列出所有服务: rc-status -a


更多参考:https://zhuanlan.zhihu.com/p/466105234

安装python3.6

  1. 更新源列表和安装依赖
bash-4.4# apk update -y
bash-4.4# apk add gcc
bash-4.4# apk add build-base
bash-4.4# apk add zlib-dev



  1. 安装python3
apk search python3
apk add python3=3.6.9-r2



  1. 测试安装成功:
bash-4.4# python3 -V
Python 3.6.9
bash-4.4# pip3 -V
pip 18.1 from /usr/lib/python3.6/site-packages/pip (python 3.6)



  1. 升级pip
pip3 install --upgrade pip



参考:

配置邮箱

能配置基础的邮箱和扩展邮箱

基础邮箱配置

在系统配置选项,下拉选择邮件通知选项:

image-20200527161130777

如何配置?

  1. 在系统配置选项,配置系统管理员邮箱

image-20200527161228038

  1. 配置邮件通知,按下图配置。

image-20200527161321342

  1. 点击高级后,按下图配置

image-20200527161742405

  1. 在项目中的构建后操作,选择

image-20200527162111706

  1. 填写收件人列表,然后点击保存。

image-20200527162216181

后续的构建中,在构建后的操作中,就会自动发邮件。

配置邮箱升级版

参考博客

邮箱HTML模板,后续会用到:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>${ENV, var="JOB_NAME"}-第${BUILD_NUMBER}次构建日志</title>
</head>

<body leftmargin="8" marginwidth="0" topmargin="8" marginheight="4"
      offset="0">
<table width="95%" cellpadding="0" cellspacing="0"
       style="font-size: 11pt; font-family: Tahoma, Arial, Helvetica, sans-serif">
    <tr>
        <td>
            <h2>
                <b>来自Jenkins的邮件通知</b>
            </h2>
        </td>
    </tr>
    <tr>
        <td>
            <br/>
            <b style="color:#0B610B;">构建信息:</b>
            <hr size="2" width="100%" align="center"/>
        </td>
    </tr>
    <tr>
        <td>
            <ul>
                <li>项目名称&nbsp;:&nbsp;${PROJECT_NAME}</li>
                <li>触发原因&nbsp;:${CAUSE}</li>
                <li>构建日志&nbsp;:&nbsp;<a href="${BUILD_URL}console">${BUILD_URL}console</a></li>
                <li>单元测试报告&nbsp;:<a href="${BUILD_URL}allure/">${BUILD_URL}allureReport/</a></li>
                <li>工作目录&nbsp;:&nbsp;<a href="${PROJECT_URL}">${PROJECT_URL}</a></li>
                <li>测试报告下载&nbsp;:&nbsp;<a href="${PROJECT_URL}">${PROJECT_URL}lastSuccessfulBuild/artifact/allure-report.zip</a></li>
            </ul>
        </td>
    </tr>
    <tr>
        <td><b style="color:#0B610B;">构建日志:</b>
            <hr size="2" width="100%" align="center"/>
        </td>
    </tr>
    <tr>
        <td><textarea cols="80" rows="30" readonly="readonly"
                      style="font-family: Courier New;width: 500px;max-width: 1000px;">${BUILD_LOG}</textarea>
        </td>
    </tr>
</table>
</body>
</html>


上面模板的含义参考可用变量列表:

image-20200527170242253

  1. 首先要保证Email Extension Plugin已下载。

image-20200527162543435

  1. 确认管理员邮件地址

image-20200527162759419

  1. 填写smtp等信息,点击高级

image-20200527162729182

  1. 按照下图配置

image-20200527163307063

image-20200527163459823

image-20200527163555765

  1. 点击保存。

  2. 在项目构建后操作,选择高级邮箱配置

image-20200527163817819

  1. 按照下图,点击高级设置

image-20200527164237197

  1. 如下图,点击高级

image-20200527164404790

  1. 如下图

image-20200527164633701

  1. 点击保存即可。

注意,上述6~9都是针对于该项目的特殊配置,如果没有特殊的配置,就是用系统配置中的相关参数。

配置java jdk/git/Allure Commandline

配置java jdk

  1. 找到容器内容的JAVA_HOME

bash-4.4# echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/jvm/java-1.8-openjdk/jre/bin:/usr/lib/jvm/java-1.8-openjdk/bin
bash-4.4# 




image-20200527152203909

  1. /usr/lib/jvm/java-1.8-openjdk添加到管理 jenkins---> 全局工具配置中的JDK选项中。

image-20200527152318340

  1. 完事之后,下拉点击保存即可。

配置git

  1. 容器环境从软件中过滤出Git
bash-4.4# ls /usr/bin/git*
/usr/bin/git  /usr/bin/git-lfs	/usr/bin/git-receive-pack  /usr/bin/git-shell  /usr/bin/git-upload-archive  /usr/bin/git-upload-pack


image-20200527152456202

  1. /usr/bin/git软连添加到管理 jenkins---> 全局工具配置中的git选项中。

image-20200527152621311

  1. 下拉保存即可。

配置Allure Commandline

  1. 保证已经在插件中心下载了allure插件。

image-20200527152855284

  1. 管理 jenkins---> 全局工具配置中的Allure Commandline选项,点击添加 allure commandline。

image-20200527153027452

  1. 按照下图配置即可。

image-20200527153157528

部署一个自由风格的job

常用的操作:

  1. general,项目的描述信息,和基本的一些参数
  2. 源码管理,如何管理你的代码,从哪拉取,如何配置凭据。
  3. 构建触发器,你的项目如何运行?
    1. 执行一次
    2. 轮循执行
    3. 每一周,每一天,每一个月
  4. 构建环境,选择你的项目构建环境是,如ant
  5. 构建,如何执行你的项目,如何运行你的代码
    1. 执行shell
    2. Windows 终端
    3. .....
  6. 构建后的操作,当项目构建完毕后,要做什么事情
    1. 生成相关报告
    2. 发送相关的邮件

general

配置构建环境中的参数,后续再构建中,能直接是用的参数。

image-20200527170451419

构建触发器

image-20200527143721345

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name  command to be executed
 分 时  日 月  周



示例:

# 每天8:30分执行一次
30 08 * * *

# #每小时的3,15分组执行
3,15 * * * *

# 在上午8-11点的第3和第15分钟执行
3,15 8-11 * * *



更多参考

构建

点击添加构建步骤:

image-20200527144123687

常用的有:

  • linux:执行shell
  • Windows:执行Windows批处理命令

image-20200527170348674

构建后的操作

配置allure报告

allure commandline会自动从$ALLURE_HOME目录读取json数据,生成allure报告。

image-20200527170531643

注意,json数据目录在项目根目录下的allure-results目录;生成的报告在项目根目录下的allure-report目录中。我们可以通过在项目目录下看到。

image-20200527170914742

邮件配置

如果在系统管理中,配置了邮件进阶版的相关参数,这里都选择默认即可。

image-20200527171036519

image-20200527171100731

image-20200527171115171

image-20200527171208386

项目的配置一览图

image-20200527171308309

就差左下角的保存按钮了。

关于jenkins容器迁移

我已经将制作好的镜像上传到了docker hub上,咱们直接拉取即可:

docker pull wangzhangkai/jenkins:1.0

然后启动:

docker run \
  -u root \
  --name myjenkins \
  --restart=always \
  -d \
  -p 6010:8080 \
  -p 50000:50000 \
  --env JAVA_OPTS="-Xmx1024m" \
  -e JAVA_OPTS=-Duser.timezone=Asia/Shanghai \
  -v /etc/localtime:/etc/localtime \
  -v /tmp/jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wangzhangkai/jenkins:1.0


参考上述命令启动即可。

问题

HTTP ERROR 403 No valid crumb was included in the request

一般在提交的时候,遇到该问题。

image-20200527110454276

解决,管理jenkins ----> 全局安全配置,下拉选择扩展请求保护,勾选启用代理兼容,下拉保存。

image-20200527110632483

这么解决之后,可能会引发一个新的问题,匿名用户可以登录。

docker run \
  -u root \
  --name myjenkins \
  --restart=always \
  -d \
  -p 6010:8080 \
  -p 50000:50000 \
  --env JAVA_OPTS="-Xmx512m" \
  -e JAVA_OPTS=-Duser.timezone=Asia/Shanghai \
  -v /etc/localtime:/etc/localtime \
  -v /docker_data/jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wangzhangkai/jenkins:1.0

java.io.IOException: Failed to load: Lockable Resources plugin (lockable-resources 1069.v726298f53f8c)...

解决办法:更新最新版本即可

接口自动化平台

管理所有的接口:

  • 接口的增删改查
  • 一键执行接口,并生成测试报告
    • 批量执行
    • 下载报告
  • 批量导入
    • 从Excel表格中
  • 定时任务
    • 每天定时(凌晨1点)检查是否有今天要结束的测试活动,如果有,就自动的执行一遍。
  • 可视化
    • echarts

实现:

  • django + unittest

image-20200504084921049

项目分析

项目列表:

image-20200504085957020

用例列表:

image-20200504090451514

用例执行日志:

1670695460588

数据可视化:

1670695402828

常见问题

遇到数据库问题,pycharm中无法连接或者无法识别mysql数据库。可能的原因:

  • pycharm版本太高,导致mysql驱动版本较高,8.x
  • 禅道的mysql与本地的mysql冲突
    • 可以把禅道卸载,你可以装在docker上

image-20200505084212632

  • mysql时区问题

image-20200505084503573

用例批量执行逻辑

  1. 页面中,勾选了一个或者多个用例,CheckBox

  2. 点击执行按钮后,后端接收一个或者多个CheckBox的值(用例id):

    1. 前端如何往后端发送?

      1. ajax发送,循环CheckBox的外部盒子,获取每一个CheckBox状态为选中的input框,获取input的value值(用例id),然后push到数组中,再将该数组发送到后端。
      2. form表单提交
    2. 后端如何接收form表单提交的值

      1. request.POST.get_list

        def index(request):
            if request.method == "POST":
                request.POST.get("username")  # username对应的是单个值
                request.POST.get_list('checkbox_list')  # 以列表的形式接收多个值
        
        
  3. 后端接收到了前端传过来的值:[1, 2, 3, 4]

    1. 根据获取到的用例id列表,去数据库中提取出对应记录(用例对象)
    2. 如果是多个用例对象,循环使用requests提取用例对象中的字段发请求。
    3. 结果断言
    4. unittest生成测试报告
    5. 将测试报告保存到用例的相应字段中
    6. 修改用例的执行状态和通过状态
    7. 考虑如何获取批量执行的测试结果报告
  4. 用例批量执行完毕,将批量执行结果保存到log表中

    1. 用例执行的时间
    2. 用例报告
  5. 将执行结果给前端返回。

前端模板

adminLTE:

我们copy的是static/AdminLTE-master/starter.index
修改静态文件的引用方式

modelform

it表

法1

# 来自张子俊

from django.forms import ModelForm
from django import forms
from django.forms import widgets as wid
from app01 import models

class ItModelForm(ModelForm):
    class Meta:
        model = models.It
        fields = "__all__"
    bootstrapClass_filter = ['it_start_time', 'it_end_time']
    it_start_time = forms.DateField(label="开始时间", widget=wid.DateInput(attrs={"class": "form-control", 'type': "date"}))
    it_end_time = forms.DateField(label="结束时间", widget=wid.DateInput(attrs={"class": "form-control", 'type': "date"}))
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for name, field in self.fields.items():
            if name in self.bootstrapClass_filter:
                continue
            old_class = field.widget.attrs.get('class', "")
            field.widget.attrs['class'] = '{} form-control'.format(old_class)
            field.widget.attrs['placeholder'] = '请输入%s' % (field.label,)

法2

from django.forms import ModelForm
from django import forms
from django.forms import widgets as wid
from app01 import models

class ItModelForm(ModelForm):
    class Meta:
        model = models.It
        fields = "__all__"
        # 法2
        labels = {
            "it_name": "项目名称",
            "it_desc": "项目描述",
            "it_start_tile": "项目开始时间",
            "it_end_tile": "项目结束时间",
        }
        error_messages = {
            "it_name": {"required": "不能为空"},
            "it_desc": {"required": "不能为空"},
            "it_start_tile": {"required": "不能为空"},
            "it_end_tile": {"required": "不能为空"},
        }
        widgets = {
            "it_name": wid.Input(attrs={"class": "form-control", "placeholder": "输入项目名称"}),
            "it_desc": wid.Textarea(attrs={"class": "form-control", "placeholder": "输入项目名称"}),
            "it_start_time": wid.DateInput(attrs={"class": "form-control", 'type': "date"}),
            "it_end_time": wid.DateInput(attrs={"class": "form-control", 'type': "date"}),
        }

模板语言

<!-- 切片, 参数必须是 str -->
<td>{{ foo.api_url | slice:"10"}}</td>  
<!-- 截取指定长度字符,后续以点代替, 参数必须是int -->
<td title="{{ foo.api_url }}">{{ foo.api_url | truncatechars:10}}</td>

unitest生成测试报告的坑

直到后来,我发现了pypi上有了这个包,所以你可以直接使用pip下载:

pip install HTMLTestRunner-Python3
pip install HTMLTestRunner-Python3==0.8.0


但经过测试,发现源码有点问题,如果你在使用中遇到报错:

TypeError: a bytes-like object is required, not 'str'


就去源码的691行,修改:

# 修改前
self.stream.write(output)

# 修改后
self.stream.write(output.encode('utf8'))


其实源码中也提到了这点!

代码:

    def get_report(self, suite):
        """生成测试报告"""
        file_path = os.path.join(settings.BASE_DIR, 'report.html')

        # 报错TypeError: a bytes-like object is required, not 'str'
        # 就去源码的691行,修改为self.stream.write(output.encode('utf8'))
        with open(file_path, 'wb') as f:
            result = HTMLTestRunner.HTMLTestRunner(
                stream=f,
                verbosity=2,
                title=self.api_obj.api_name,
                description=self.api_obj.api_desc,
            ).run(suite)


django的下载逻辑

from django.http import FileResponse
from django.http import StreamingHttpResponse



参考:https://www.cnblogs.com/Neeo/articles/11021972.html

关于复选框的操作

获取所有的选中状态的复选框:

$("#chk1").find('input:checkbox').each(function() { //遍历所有复选框
 
    if ($(this).prop('checked') == true) {
 
        console.log($(this).val()); //打印当前选中的复选框的值
 
    }
 
});
 
function getCheckBoxVal(){ //jquery获取所有选中的复选框的值 
 
    var chk_value =[]; 
 
    $("#chk1").find('input[name="test"]:checked').each(function(){ //遍历,将所有选中的值放到数组中
 
        chk_value.push($(this).val()); 
 
    }); 
 
    alert(chk_value.length==0 ?'你还没有选择任何内容!':chk_value); 
 
} 


或者:

$("#sure").click(function () {
    var arr = new Array();
    $.each($(".p1"), function (index, item) {
        // console.log(index, item)
        if ($(item).get(0).checked) {
            arr.push($(item).val())
        }
    });
    if (arr.length == 0) {
        // 说明用户未选中用例,需要给提示
        // console.log(2222222, "未选中", arr);
        $("#errorMsg").html("请勾选至少一个用例!");

    } else {
        // 编写后续的操作
    }
});


ajax如何处理跨域问题?

有以下几种办法:

  • 装饰器
# views中
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def run_case(request, pk=0):
    pass


其他形式参考:https://www.cnblogs.com/Neeo/articles/11455271.html

前端序列化与反序列化

// 序列化    字典 -> 字符串
JSON.stringify(['A', 'B'])
// 反序列化    字符串 -> 字典
JSON.parse()



后端序列化与反序列化

#  序列化    字典 -> 字符串
json.dumps(['A', 'B'])
#  反序列化    字符串 -> 字典
json.loads()



io

在内存中创建一个文件句柄

from io import BytesIO
from io import StringIO

f = open('a.html', 'wb')  # BytesIO
f1 = open('a.html', 'w', encoding='utf-8')  # StringIO


可视化

  • hightcharts
  • echarts

前端从后端获取必要的数据,进行渲染展示

  • pyecharts: 百度开源的,后台自己处理数据,自己生成html页面

pyecharts的使用

pip install wxpy  # 备用下载地址:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple wxpy
pip install pyecharts==0.5.11       # 备用下载地址:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pyecharts==0.5.11
pip install pyecharts_snapshot  # 备用下载地址:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pyecharts_snapshot



示例:

import wxpy
import webbrowser
from pyecharts import Pie
# 1. 登录
bot = wxpy.Bot()         # cache_path=True  可以不填,但每次都要重新扫描
# 2. 获取所有的朋友对象,放到列表中
friends = bot.friends()
attr = ['男朋友', '女朋友', '性别不详']
value = [0, 0, 0]
for friend in friends:
    print(friend.name, friend.sex)
    if friend.sex == 1:     # 1代表男性
        value[0] += 1
    elif friend.sex == 2:   # 2代表女性
        value[1] += 1
    else:   # 未指定性别的
        value[2] += 1
# 3. 处理为图像
pie = Pie('%s 朋友圈性别比例图' % bot.self.name)
pie.add('', attr, value, is_label_show=True)  # 图表名称str,属性名称list,属性所对应的值list,is_label_show是否显示标签
pie.render('sex.html')  # 生成html页面
# 4. 打开浏览器展示
webbrowser.open('sex.html')



示例参考:
https://www.cnblogs.com/Neeo/articles/10454764.html
https://www.cnblogs.com/Neeo/articles/10454677.html

echarts的使用
参考: https://echarts.apache.org/zh/index.html

使用:

  1. 绑定一个标签,生成一个echarts对象
  2. 配置数据和参数
  3. setoption生成图表

项目集成可视化

前端

{% extends 'layout.html' %}
{% load static %}

{% block nav %}
    <ol class="breadcrumb float-sm-right">
        <li class="breadcrumb-item"><a href="{% url 'index' %}">首页</a></li>
        <li class="breadcrumb-item active">数据可视化</li>
    </ol>
{% endblock %}

{% block content %}
    <div class="card card-info">
        <div class="card-header">
            <h3 class="card-title">数据用例近一年总用例数走势图</h3>

            <div class="card-tools">
                <button type="button" class="btn btn-tool" data-card-widget="collapse"><i
                        class="fas fa-minus"></i>
                </button>
                <button type="button" class="btn btn-tool" data-card-widget="remove"><i
                        class="fas fa-times"></i>
                </button>
            </div>
        </div>
        <div class="card-body">
            <div class="chart">
                <div id="LineRace"
                     style="min-height: 250px; height: 400px; max-height: 450px; max-width: 100%; "></div>
            </div>
        </div>
        <!-- /.card-body -->
    </div>

    <div class="row">
        <!-- 数据用例通过情况 -->
        <div class="col-md-6">
            <div class="card card-info">
                <div class="card-header">
                    <h3 class="card-title">数据用例通过情况</h3>

                    <div class="card-tools">
                        <button type="button" class="btn btn-tool" data-card-widget="collapse"><i
                                class="fas fa-minus"></i>
                        </button>
                        <button type="button" class="btn btn-tool" data-card-widget="remove"><i
                                class="fas fa-times"></i>
                        </button>
                    </div>
                </div>
                <div class="card-body">
                    <div class="chart">
                        <div id="PassPie"
                             style="min-height: 250px; height: 400px; max-height: 450px; max-width: 100%; "></div>
                    </div>
                </div>
                <!-- /.card-body -->
            </div>

        </div>

        <!-- 数据用例执行情况 -->
        <div class="col-md-6">
            <div class="card card-info">
                <div class="card-header">
                    <h3 class="card-title">数据用例执行情况</h3>

                    <div class="card-tools">
                        <button type="button" class="btn btn-tool" data-card-widget="collapse"><i
                                class="fas fa-minus"></i>
                        </button>
                        <button type="button" class="btn btn-tool" data-card-widget="remove"><i
                                class="fas fa-times"></i>
                        </button>
                    </div>
                </div>
                <div class="card-body">
                    <div class="chart">
                        <div id="ExecutePie"
                             style="min-height: 250px; height: 400px; max-height: 450px; max-width: 100%; "></div>
                    </div>
                </div>
                <!-- /.card-body -->
            </div>

        </div>
    </div>
{% endblock %}


{% block js %}
    <script src="{% static 'echarts.min.js' %}"></script>
    <script>

        // var ROOT_PATH = 'https://127.0.0.1/examples';
        var ROOT_PATH = 'https://echarts.apache.org/examples';

        // 用例通过/未通过情况   饼图
        function PassPie(title, data) {
            // 1. 实例化 echarts 对象
            var chartDom = document.getElementById('PassPie');
            var myChart = echarts.init(chartDom);
            var option;

            // 2, 配置参数
            option = {
                title: {
                    text: '用例执行通过情况',
                    subtext: 'Fake Data',
                    left: 'center'
                },
                tooltip: {
                    trigger: 'item',
                    formatter: '{a} <br/>{b} : {c} ({d}%)'
                },
                legend: {
                    bottom: 10,
                    left: 'center',
                    data: title
                },
                series: [
                    {
                        type: 'pie',
                        radius: '65%',
                        center: ['50%', '50%'],
                        selectedMode: 'single',
                        data: data,
                        emphasis: {
                            itemStyle: {
                                shadowBlur: 10,
                                shadowOffsetX: 0,
                                shadowColor: 'rgba(0, 0, 0, 0.5)'
                            }
                        }
                    }
                ]
            };

            // 3. setoption
            option && myChart.setOption(option);
        }

        // 用例执行/未执行情况   饼图
        function ExecutePie(title, data) {
            // 1. 实例化 echarts 对象
            var chartDom = document.getElementById('ExecutePie');
            var myChart = echarts.init(chartDom);
            var option;

            // 2, 配置参数
            option = {
                title: {
                    text: '用例执行情况',
                    subtext: 'Fake Data',
                    left: 'center'
                },
                tooltip: {
                    trigger: 'item',
                    formatter: '{a} <br/>{b} : {c} ({d}%)'
                },
                legend: {
                    bottom: 10,
                    left: 'center',
                    data: title
                },
                series: [
                    {
                        type: 'pie',
                        radius: '65%',
                        center: ['50%', '50%'],
                        selectedMode: 'single',
                        data: data,
                        emphasis: {
                            itemStyle: {
                                shadowBlur: 10,
                                shadowOffsetX: 0,
                                shadowColor: 'rgba(0, 0, 0, 0.5)'
                            }
                        }
                    }
                ]
            };

            // 3. setoption
            option && myChart.setOption(option);
        }

        // 用例近一年数据情况   折线图
        function LineRace(title, data) {
            var chartDom = document.getElementById('LineRace');
            var myChart = echarts.init(chartDom);
            var option;

            option = {
                xAxis: {
                    type: 'category',
                    boundaryGap: false,
                    data: title
                },
                yAxis: {
                    type: 'value'
                },
                series: [
                    {
                        data: data,
                        type: 'line',
                        areaStyle: {}
                    }
                ]
            };

            option && myChart.setOption(option);

        }
        

        function init() {
            $.ajax({
                'url': '{% url "echarts_show" %}',
                'type': 'POST',
                'data': '',
                success: function (res) {
                    // console.log(res);
                    // 用例通过/未通过情况   饼图
                    PassPie(res['data_dict']['pass_pie']['title'], res['data_dict']['pass_pie']['data']);
                    // 用例执行/未执行情况   饼图
                    ExecutePie(res['data_dict']['execute_pie']['title'], res['data_dict']['execute_pie']['data']);
                    // 用例近一年数据情况   折线图
                    LineRace(res['data_dict']['line_race']['title'], res['data_dict']['line_race']['data']);

                }
            })
        }

        init();
    </script>
{% endblock %}

后端

utils/EchartsHand.py

"""
处理数据可视化从后端获取动态数据

"""
import os
import django
import datetime


# 准备工作
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auto_port.settings")
django.setup()


from app01 import models


class EchartsHandler(object):

    def pie(self):
        """
        饼图
        :return:
        """
        data_dict = {
            "pass_pie": {
                'title': ['通过', '失败'],
                'data': [
                    {'value': 0, 'name': '通过'},
                    {'value': 0, 'name': '失败'},
                ]
            },
            "execute_pie": {
                'title': ['已执行', '未执行'],
                'data': [
                    {'value': 0, 'name': '已执行'},
                    {'value': 0, 'name': '未执行'},
                ]
            },
        }

        it_obj_list = models.It.objects.all()
        for it_obj in it_obj_list:
            data_dict["pass_pie"]["data"][0]["value"] += it_obj.api_set.filter(api_pass_status=1).count()
            data_dict["pass_pie"]["data"][1]["value"] += it_obj.api_set.filter(api_pass_status=0).count()
            data_dict["execute_pie"]["data"][0]["value"] += it_obj.api_set.filter(api_run_status=1).count()
            data_dict["execute_pie"]["data"][1]["value"] += it_obj.api_set.filter(api_run_status=0).count()
        # print(data_dict)
        return data_dict

    def line_race(self):
        """
        折线图
        近一年,统计每个月的用例数据走势图(21年5月11号~22年5月11号) 根据 it_start_time 进行过滤
        :return:
        """
        data_dict = {
            "line_race": {
                'title': [],
                'data': []
            },
        }
        end_time = datetime.date.today()    # 2022-11-29
        start_time = end_time - datetime.timedelta(days=365)    # 2021-11-29
        it_obj = models.It.objects.filter(start_time__range=(start_time, end_time))

        X = {}  # x轴表时间段
        for item in it_obj:
            time = item.start_time.strftime('%Y-%m')
            if X.get(time):
                # 若该月份存在则用例数相加
                X[time] += item.api_set.count()
            else:
                # 若该月份不存在则直接
                X[time] = item.api_set.count()
        # print(X.items())    # dict_items([('2021-12', 2), ('2022-11', 3)])

        # 给时间排序对应其用例数
        new_data = sorted(X.items(), key=lambda x: x[0])   # [('2021-12', 2), ('2022-11', 3)]
        for data in new_data:
            data_dict["line_race"]['title'].append(data[0])
            data_dict["line_race"]['data'].append(data[1])
        # print(data_dict)    # {'line_race': {'title': ['2021-12', '2022-11'], 'data': [2, 3]}}
        return data_dict


if __name__ == '__main__':
    EchartsHandler().line_race()


views.py

@csrf_exempt
def echarts_show(request):
    """可视化"""
    if request.is_ajax():
        data_dict = {}
        data_dict.update(EchartsHandler().pie())
        data_dict.update(EchartsHandler().line_race())
        return JsonResponse({'status': True, 'data_dict': data_dict})
    return render(request, 'echarts_show.html')


定时任务

常见用于定时任务的:

APScheduler

pip install -i https://pypi.doubanio.com/simple/ apscheduler



使用

指定时间执行一次

import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
def job2(text):
    print('job2', datetime.datetime.now(), text)
scheduler = BlockingScheduler()
scheduler.add_job(job2, 'date', run_date=datetime.datetime(2019, 2, 25, 19, 5, 6), args=['text'], id='job2')
scheduler.start()



每天指定时间执行一次

# ----------------- 每天指定时间 执行一次 -----------------

from apscheduler.schedulers.blocking import BlockingScheduler  # 后台运行

sc = BlockingScheduler()
f = open('t1.txt', 'a', encoding='utf8')

@sc.scheduled_job('cron', day_of_week='*', hour=11, minute='56', second='2')  # 每天11点56分02秒执行一次
def check_db():
    print(111111111111)


if __name__ == '__main__':
    try:
        sc.start()
        f.write('定时任务成功执行')
    except Exception as e:
        sc.shutdown()
        f.write('定时任务执行失败')
    finally:
        f.close()



项目集成定时任务

manage.py

#!/usr/bin/env python
import os
import sys
import threading
from utils.CrontabHand import run

if __name__ == "__main__":
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auto_port.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError:
        # The above import may fail for some other reason. Ensure that the
        # issue is really that Django is missing to avoid masking other
        # exceptions on Python 2.
        try:
            import django
        except ImportError:
            raise ImportError(
                "Couldn't import Django. Are you sure it's installed and "
                "available on your PYTHONPATH environment variable? Did you "
                "forget to activate a virtual environment?"
            )
        raise
    # 起一个线程执行定时任务
    # threading.Thread(target=run).start()

    execute_from_command_line(sys.argv)


utils/CrontabHand.py

import os
import django
import datetime
from apscheduler.schedulers.blocking import BlockingScheduler

# 准备工作
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auto_port.settings")
django.setup()

# 一定得在准备工作之后才能导入相关模块
from app01 import models  
from utils.RequestHand import run_case


def job1():
    # print('job1', datetime.datetime.now())
    api_obj_list = models.It.objects.all()
    # print(api_obj_list)  # <QuerySet [<It: x1>, <It: x2>]>
    for api_obj in api_obj_list:
        if datetime.date.today() == api_obj.end_time:
            run_case(api_obj.api_set.all())


def run():
    scheduler = BlockingScheduler()
    scheduler.add_job(job1, 'interval', seconds=10, id='job1')  # 每隔10秒执行一次
    scheduler.start()


if __name__ == '__main__':
    job1()



日志的预览和下载

<div class="card-body">
            <div class="chart">
                <div id="logs"
                         style="min-height: 250px; height: 100%; max-width: 100%; ">

                        {{ log_obj.log_report| safe }}
                    </div>


                <form action="" method="post">
                    <div style="float: right">
                        <a href="{% url 'logs_list' %}"><input type="button" value="返回" class="btn btn-info"></a>
                       <input type="submit" value="下载" class="btn btn-success">
                    </div>
                </form>
            </div>
        </div>


def logs_preview(request, logs_id):
    """预览日志"""
    log_obj = models.Logs.objects.filter(id=logs_id).first()

    if request.method == 'POST':
        """日志下载"""
        response = FileResponse(log_obj.log_report)
        response['Content-Type'] = 'application/octet-stream'
        response['Content-Disposition'] = 'attachment;filename="{}.{}"'.format(escape_uri_path(log_obj.log_sub_it.name),
                                                                               'html')
        return response
    return render(request, 'logs_preview.html', {'log_obj': log_obj})


django发邮件

参考:https://www.cnblogs.com/Neeo/articles/11199085.html

简单配置发邮件

settings.py配置:

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = True  # 是否使用TLS安全传输协议(用于在两个通信应用程序之间提供保密性和数据完整性。)
EMAIL_USE_SSL = False  # 是否使用SSL加密,qq企业邮箱要求使用
EMAIL_HOST = 'smtp.163.com'  # 发送邮件的邮箱 的 SMTP服务器,这里用了163邮箱
EMAIL_PORT = 25  # 发件箱的SMTP服务器端口

# 上面配置可以不动,下面配置可以修改
EMAIL_HOST_USER = '邮箱@163.com'  # 发送邮件的邮箱地址
EMAIL_HOST_PASSWORD = '你的授权码'  # 发送邮件的邮箱密码(这里使用的是授权码)
EMAIL_TO_USER_LIST = ['接收人@qq.com']   # 此字段是可选的,用来配置收件人列表



views.py配置:

from django.http import HttpResponse
from dengxin import settings
from django.core.mail import send_mail

def send_email(request):
    send_mail(
        subject='这里是邮件标题',
        message='这里是邮件内容',
        from_email='tingyuweilou@163.com',
        recipient_list=settings.EMAIL_TO_USER_LIST,
        fail_silently=False
    )
    return HttpResponse('OK')



发送待附件的示例

views.py中:

from django.http import HttpResponse
from dengxin import settings
from django.core.mail import send_mail, EmailMessage

def send_email(request):

    # 发送带附件的邮件
    msg = EmailMessage(
        subject='这是带附件的邮件标题',
        body='这是带附件的邮件内容',
        from_email=settings.EMAIL_HOST_USER,  # 也可以从settings中获取
        to=settings.EMAIL_TO_USER_LIST
    )
    msg.attach_file(r'D:\video\s28-testing-day16-接口自动化平台-实现-3\note\dengxin\sex.html')
    msg.send(fail_silently=False)
    return HttpResponse('OK')




可以把发邮件功能和定时任务结合起来。

文件上传

有两种常用的文件上传方式:

  • ajax上传
  • form表单上传

ajax上传

upload.html

{% extends 'layout.html' %}


{% block nav %}
    <ol class="breadcrumb float-sm-right">
        <li class="breadcrumb-item"><a href="{% url 'index' %}">首页</a></li>
        <li class="breadcrumb-item active">批量导入用例</li>
    </ol>
{% endblock %}

{% block content %}
    <div class="card-body">
        <div class="alert alert-warning alert-dismissible" id="p1">
            <button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
            <h5><i class="icon fas fa-ban"></i> 重要提示!</h5>
            <span id="error">
            这里只能上传Excel文件,
        Excel表格设计字段的时候,参考数据的字段设计,方便上传文件。
        </span>
        </div>


        <div class="form-group">
            <label for="exampleInputFile">文件上传</label>
            <div class="input-group">
                <div class="custom-file">
                    <input type="file" class="custom-file-input" id="ajaxFile">
                    <label class="custom-file-label" for="">请选择文件</label>
                </div>
                <div class="input-group-append">
                    <span class="input-group-text" id="ajaxBtn">Upload</span>
                </div>
            </div>
        </div>
        <span>{{ errors }}</span>
    </div>
{% endblock %}


{% block js %}

    <script>
        $("#ajaxBtn").click(function () {
            // 首先,实例化一个formdata对象
            var formData = new FormData();
            // 然后使用formdata的append来添加数据,即获取文件对象
            // var file_obj = $("#ajaxFile")[0].files[0];    // 使用jQuery获取文件对象
            var file_obj = document.getElementById('ajaxFile').files[0];   // 使用dom也行
            formData.append('f1', file_obj);

            $.ajax({
                url: "{% url 'upload' it_obj_pk%}",
                type: "POST",
                data: formData,
                processData: false,
                contentType: false,   // 避免 JQuery 对请求头content_type进行操作
                success: function (res) {
                    console.log(res);
                    if (res['status'] == 500) {
                        $("#p1").attr("class", 'alert alert-danger alert-dismissible');
                        $("#error").text(res['errors'])
                    } else {
                        window.location.href = res['path']
                    }
                }
            })
        })
    </script>
{% endblock %}


views.py

from django.db import transaction


def upload(request, pk):
    """上传文件"""
    if request.is_ajax():
        try:
            with transaction.atomic():
                file_obj = request.FILES.get("f1")
                it_obj_pk = request.POST.get("it_obj_pk")
                # print(it_obj_pk, file_obj)  # 6 接口测试示例-2.xlsx
                book_obj = xlrd.open_workbook(filename=None, file_contents=file_obj.read())
                sheet = book_obj.sheet_by_index(0)
                title = sheet.row_values(0)
                data_list = [dict(zip(title, sheet.row_values(item))) for item in range(1, sheet.nrows)]
                """
                [
                    {
                        'case_num': 'neeo_001', 
                        'title': '下单接口', 
                        'desc': 'neeo项目的下单接口', 
                        'url': 'http://www.neeo.cc:6002/pinter/com/buy', 
                        'method': 'post', 
                        'params': '', 
                        'data': '{"param":{"skuId":123,"num":10}}', 
                        'json': '', 
                        'cookies': '', 
                        'headers': '', 
                        'except': '{"code": "0", "message": "success"}'
                    }
                ]
                """
                for item in data_list:
                    models.Api.objects.create(
                        api_sub_it_id=it_obj_pk,
                        api_name=item['title'],
                        api_desc=item['desc'],
                        api_url=item['url'],
                        api_method=item['method'],
                        api_params=item['params'],
                        api_data=item['data']
                    )
            return JsonResponse({"status": 200, 'path': '/list_api/{}'.format(pk)})
        except Exception as e:
            return JsonResponse({
                "status": 500,
                'path': '/list_api/{}'.format(pk),
                "it_obj_pk": pk,
                "errors": "这里只能上传 [xls] or [xlsx] 类型的表格,并且表格的字段要符合要求, 错误详情:{}".format(e)
            })
    else:
        return render(request, 'upload.html', {'it_obj_pk': pk})

参考:https://www.cnblogs.com/Neeo/articles/11021972.html

周末作业

日志表相关的作业

把预览完成

  • 点击预览,跳转到一个新的页面中,在新的页面中,有返回上一页和下载按钮
    • 点击返回上一页,跳转到logs_list页面
    • 点击下载,就把当前的报告下载到本地

image-20200508112902925

可视化

使用echarts展示三个图:

  • 用例通过/失败(饼图)
  • 用例执行/未执行(饼图)
  • 展示最近一年的每个月的项目创建数量(折线图),注意,提前准备数据

image-20200508112925621

定时任务

每天凌晨1点30分50秒,检查it表,it_end_time是当前日期的接口项目,提取出来,获取该项目下所有的用例,批量执行一次,并生成log日志。

扩展:使用django发邮件,附件是批量执行的报告

建议:

多线程执行该任务

定时任务:
- 需求是:每天的凌晨1:30:20去检查it表,查看 it_end_time 是当天的,就把其关联的所有的用例执行一遍。
- 问题:如何实现?思路是随着django环境的运行,启动一个线程,调用一个 job 来实现需求。

UI自动化测试

UI测试(User Interface Test,译作:用户界面测试),UI测试侧重产品的UI交互是否正确,模拟后端进行测试也可以,放在单元测试里去做也可以。

Web自动化测试

常用的web自动化测试工具有:selenium,Playwright、QTP、Cypress、Rapise、TestProject、Ranorex、Katalon、Subject7、Serenity等等

以下内容可参考:

selenium IDE

提供了脚本录制功能和导出脚本。

火狐浏览器安装

火狐的插件管理中搜索selenium ide.

image-20200512090502535

将该插件添加到浏览器。

image-20200512090532063

添加成功如下:

image-20200512090613112

在Google应用商店

image-20200512090814113

添加该扩展程序。

image-20200512090847889

添加成功如下:

image-20200512090915899

解决:无法访问谷歌应用商店的问题:

  1. 访问:https://www.extfans.com/
  2. 搜索selenium ide
  3. 微信扫一扫输入验证码下载
  4. 打开chrome://extensions/,将下载压缩包中的selenium-ide.crx文件拖拽到该页面中,进行添加下载。

image-20200512091531173

请添加图片描述

请添加图片描述

请添加图片描述

selenium webdriver

img

Selenium是由杰森·哈金斯(Jason Huggins)于2004年在思特沃克(ThoughtWorks)开发的一款专门为Web应用而开发的自动化测试工具(最早是作为火狐浏览器的一个插件存在,内核基于javascript实现), 适合进行功能测试、验收测试,同时支持所有基于web的管理任务自动化。简单来说,就是用程序模拟人操作浏览器网页,可以实现UI自动化测试。

Selenium基于webdriver协议可以控制浏览器模仿用户操作完成UI自动化测试,同时它也是一个生态系统,其主要功能包括:测试与浏览器的兼容性,测试应用程序看是否能够很好得工作在不同浏览器和操作系统之上。测试系统功能,创建回归测试检验软件功能和用户需求。Selenium包含了Selenium IDE、Selenium RC(Selenium2.0以后已经webdriver替代了)、Selenium Grid。

Selenium发展到今天已经经历4个版本:Selenium1.0,Selenium2.0,Selenium3.0,Selenium4.0

特点:

  • 开源(使用Apache License 2.0协议发布)、免费、简单易学、操作灵活、功能齐全。
  • 支持市面上基本所有的浏览器:FirefoxChrome、IE(7, 8, 9, 10, 11)、Edge、Opera、Safari等等。
  • 跨平台:windows、linux、mac
  • 多语言支持:javapython、Ruby、C#、JavaScript、C++
  • 对web页面有良好的支撑。
  • 支持分布式执行测试用例。

官方文档:https://www.selenium.dev/documentation/

安装步骤

  1. 安装要测试的浏览器的webdriver插件
  2. 安装对应编程语言的开发工具模块

下载地址:https://www.selenium.dev/downloads/

webdriver插件安装
浏览器类型 下载地址
Chrome http://chromedriver.storage.googleapis.com/index.html
Firefox https://github.com/mozilla/geckodriver/releases
IE http://selenium-release.storage.googleapis.com/index.html
Edge https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/
PhantomJS(无头浏览器) https://phantomjs.org/

注意:UI自动测试依赖于浏览器环境,所以浏览器务必不要设置为自动更新!!!下载的浏览器webdriver驱动一定要对应当前测试的浏览器版本。

image-20220618095924226

配置不同的浏览器驱动
for chrome

selenium 配置Chrome的驱动,该驱动需要和Chrome的版本保持一致(大版本)

下载驱动链接:https://npm.taobao.org/mirrors/chromedriver

  1. 打开https://npm.taobao.org/mirrors/chromedriver

image-20200512101137797

  1. 再次查看驱动版本和浏览器版本是否一致

image-20200512101212351

image-20200512101310697

  1. 根据平台下载对应的压缩包

image-20200512101353379

  1. 将下载后的压缩包内的chromedriver.exe文件拷贝到解释器的Scripts目录。因为该目录已经添加到了系统得Path中了。

image-20200512101458848

  1. 测试
import time
# 1. 导入webdriver
from selenium import webdriver
# 2. 实例化指定浏览器的webdriver对象
# driver = webdriver.Chrome(executable_path=r'D:\video\s28-testing-day18-selenium\note\chromedriver.exe')
driver = webdriver.Chrome()
# 3. 访问指定的url,进行相关操作
try:
    driver.get(url='https://www.baidu.com')
    
    driver.find_element_by_id('kw').send_keys('听雨危楼')
    driver.find_element_by_id('su').click()

# 4. 完事后,关闭浏览器
except Exception as e:
    print(e)
finally:
    time.sleep(2)
    driver.quit()


for Firefox
  1. 打开github的火狐浏览器驱动地址:https://github.com/mozilla/geckodriver,
  2. 找到下载链接:https://github.com/mozilla/geckodriver/releases/tag/v0.26.0

image-20200512103116777

  1. 将下载到本地的压缩包中的geckodriver.exe文件,拷贝到python解释器的Scripts目录中即可。

image-20200512103417125

  1. 测试:
import time
# 1. 导入webdriver
from selenium import webdriver
# 2. 实例化指定浏览器的webdriver对象
# driver = webdriver.Chrome(executable_path=r'D:\video\s28-testing-day18-selenium\note\chromedriver.exe')
# driver = webdriver.Firefox(executable_path=r'D:\video\s28-testing-day18-selenium\note\geckodriver.exe')
driver = webdriver.Firefox()
# 3. 访问指定的url,进行相关操作
try:
    driver.get(url='https://www.baidu.com')

    driver.find_element_by_id('kw').send_keys('听雨危楼')
    driver.find_element_by_id('su').click()

# 4. 完事后,关闭浏览器
except Exception as e:
    print(e)
finally:
    time.sleep(2)
    driver.quit()


for ie

必要的配置:

安装webdriver驱动

  1. 打开:http://selenium-release.storage.googleapis.com/index.html,选择一个跟selenium版本一致的链接。

image-20200512104226792

  1. 这里选择32为版本的压缩包,因为32位版本相对于64位性能高一些。

image-20200512104423634

  1. 将压缩包内的可执行文件拷贝到python解释器的Scripts目录中。

image-20200512104603938

  1. 测试:
import time
# 1. 导入webdriver
from selenium import webdriver
# 2. 实例化指定浏览器的webdriver对象
# driver = webdriver.Ie(executable_path=r'D:\video\s28-testing-day18-selenium\note\IEDriverServer.exe')
driver = webdriver.Ie()
# 3. 访问指定的url,进行相关操作
try:
    driver.get(url='https://www.baidu.com')

    driver.find_element_by_id('kw').send_keys('听雨危楼')
    driver.find_element_by_id('su').click()

# 4. 完事后,关闭浏览器
except Exception as e:
    print(e)
finally:
    time.sleep(2)
    driver.quit()


for Safari

参考:https://www.cnblogs.com/Neeo/articles/10671532.html#safari

更多参考:https://www.cnblogs.com/Neeo/articles/10671532.html#%E7%BD%91%E9%A1%B5%E6%B5%8F%E8%A7%88%E5%99%A8

快速使用

python中安装selenium

pip install selenium

打开浏览器
from selenium import webdriver
from selenium.webdriver import ChromeOptions

option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告

# 开启浏览器的无头模式
option.add_argument('--headless')
option.add_argument('--disable-gpu')  # 允许在无GPU的环境下运行,可选
option.add_argument('--window-size=1920x1080')  # 建议设置

# 这里必须得将chrome.exe添加到环境变量中,否则得加上其路径
driver = webdriver.Chrome(options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测

# 浏览器最小化窗口
driver.minimize_window()

# 通过driver.get方法打开一个网址,相当于新建一个窗口
driver.get("https://www.luffycity.com")

time.sleep(5)

# 关闭浏览器
driver.quit()



基本操作
操作 描述
driver.set_window_size(宽度, 高度) 设置浏览器窗口大小,单位:px 像素
driver.get_window_size() 获取浏览器窗口大小,返回结果为字典格式
driver.minimize_window() 浏览器最小化窗口
driver.current_window_handle 获取当前窗口对象
driver.get(url地址) 根据url地址打开一个新的浏览器窗口,或当前窗口跳转到地址url地址页面
driver.current_url 获取当前浏览器窗口的url地址
driver.title 获取当前浏览器窗口的页面标题
driver.execute_script("alert('xxoo');") 执行 js代码
driver.get_cookies() 获取cookies
driver.save_screenshot('a.png') 屏幕截图,保存图片的类型必须是 png
driver.page_source #获取页面的内置
driver.forward() 浏览器前进下一页
driver.back() 浏览器返回上一页
driver.refresh() 刷新当前浏览器
driver.close() 关闭当前浏览器窗口,如果浏览器只有一个标签页,则表示关闭浏览器
driver.quit() 关闭浏览器
import time

from selenium import webdriver
from selenium.webdriver.chrome.service import Service

service = Service(executable_path="./chromedriver.exe")
driver = webdriver.Chrome(service=service)
# 设置浏览器的宽高
driver.set_window_size(1555, 768)
# 获取浏览器的宽高
ret = driver.get_window_size()
print(ret) # {'width': 1555, 'height': 770}
driver.get("https://www.luffycity.com")
print(f"网页标题:{driver.title}")
print(f"当前地址:{driver.current_url}")
time.sleep(3)
# 刷新浏览器当前页面
driver.refresh()
time.sleep(3)
# 刷新浏览器当前页面
driver.refresh()
time.sleep(3)

# 在当前窗口打开一个新页面
driver.get("https://www.baidu.com")
time.sleep(3)
# 返回上一步访问的url地址,相当于返回路飞
driver.back()
time.sleep(3)
# 前进到下一步访问的url地址,相当于前进到百度
driver.forward()

# 等待10秒退出,如果当前窗口只有一个浏览器页面,则表示退出当前浏览器
time.sleep(10)
driver.close()


元素定位

我们通过webdriver打开一个网络页面,目的是为了操作当前页面已完成浏览器中的一些UI测试步骤,所以必然需要操作网页。而网页的内容组成是由HTML标签(element,也叫元素),所以基于selenium操作网页实际上本质就是操作元素。那么要操作元素就必须先获取元素对象。selenium中关于元素的获取也叫元素的定位,提供了8种元素定位操作可以让测试人员获取一个或多个HTML元素。

基于xpath、css selector与class属性值获取元素
import time

from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By

service = Service(executable_path="./chromedriver.exe")
driver = webdriver.Chrome(service=service)

# 打开一个页面
driver.get("https://www.luffycity.com")

# 基于xpath路径获取元素
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/img[1]')

# # 基于css选择符获取元素
# element = driver.find_element(By.CSS_SELECTOR, '#__layout > div > div.mbox > div > img.close')

# # 基于class属性值来获取元素
# element = driver.find_elements(By.CLASS_NAME, 'close')
# # 如果使用find_elements获取多个元素,返回结果是一个列表,需要通过下标来选择操作的是哪一个?
# element = element[0]


# 等待3秒以后点击(这个等待不是必须的,而是为了方便查看而已)
time.sleep(3)
print(element.click())

# 等待10秒退出,如果当前窗口只有一个浏览器页面,则表示退出当前浏览器
time.sleep(10)
driver.close()



import time

from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By

option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告
service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测

# 打开一个页面
driver.get("https://www.baidu.com")

# 基于xpath路径获取元素
element = driver.find_element(By.ID, 'kw')
# 往输入框元素中通过键盘录入数据使用 send_keys()
element.send_keys("路飞学城")

# 找到提交按钮并点击
element = driver.find_element(By.ID, 'su')
element.click()

time.sleep(3)
# 从搜索结果找到有路飞学城的链接内容,并点击
element = driver.find_element(By.LINK_TEXT, '路飞学城')
element.click()
"""
错误提示:
NoSuchElementException: Message: no such element: Unable to locate element: {"method":"link text","selector":"路飞学城"}
异常,没有这样的一个元素:基于  {"method":"link text","selector":"路飞学城"} 查找不到元素(本地元素不可达)
原因是百度在用户搜索内容以后,采用了ajax进行异步刷新页面数据的,所以会导致selenium代码在元素时,百度服务器有可能没有返回数据结果,没有数据结果则没有该结果连接,因此报错
"""

# 等待10秒退出,如果当前窗口只有一个浏览器页面,则表示退出当前浏览器
time.sleep(10)
driver.close()


基于tag获取元素,往往用于获取iframe内嵌框架页中的元素。

import time

from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By

option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告
service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测

# 打开一个页面
driver.get("https://mail.163.com")

iframe_list = driver.find_elements(By.TAG_NAME, 'iframe')
form_iframe = iframe_list[0]
# 切换窗口到指定iframe
driver.switch_to.frame(form_iframe)

driver.find_element(By.NAME, 'email').send_keys('moooluo2022')
time.sleep(3)
driver.find_element(By.NAME, 'email').clear()

# 等待10秒退出,如果当前窗口只有一个浏览器页面,则表示退出当前浏览器
time.sleep(10)
driver.close()



小练习:在上面的163邮箱登陆表单中,新增一段代码,实现自动输入密码操作。

import time

from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By

option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告
service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测

# 打开一个页面
driver.get("https://mail.163.com")

iframe_list = driver.find_elements(By.TAG_NAME, 'iframe')
form_iframe = iframe_list[0]
# 切换窗口到指定iframe
driver.switch_to.frame(form_iframe)

driver.find_element(By.NAME, 'email').send_keys('moooluo2022')
# time.sleep(3)
# driver.find_element(By.NAME, 'email').clear()

driver.find_element(By.NAME, 'password').send_keys('123456')

driver.find_element(By.ID, 'dologin').click()

# 等待10秒退出,如果当前窗口只有一个浏览器页面,则表示退出当前浏览器
time.sleep(10)
driver.close()



元素操作
操作 描述
click() 点击当前元素
send_keys() 给当前表单元素输入文本内容
clear() 给当前表单元素清空内容
submit() 点击提交按钮,提交表单,要使用submit提交表单则按钮必须有form表单才行,如果ajax提交的不行。
get_attribute() 获取元素的指定属性值
value_of_css_property() 获取元素的CSS样式的指定属性的属性值,不准确.
is_displayed() 查看元素是否显示可见
is_enabled() 查看表单元素是否启用
is_selected() 查看元素是否被选择,一般判断表单元素,如radio单选框或checkbox多选框
size 获取元素的尺寸大小
text 获取元素的文本内容
模拟路飞登陆操作脚本

代码:

import time
from getpass import getpass
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By


def get_driver():
    # 初始化一个谷歌浏览器实例对象
    service = Service("chromedriver.exe")
    driver = webdriver.Chrome(service=service)
    return driver


def task(username, password):
    # 打开一个网址
    driver.get("https://www.luffycity.com")
    time.sleep(3)  # 可以不用,这句代码的作用主要是便于观察

    # 关闭广告
    driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/img[1]').click()

    # 点击登陆
    driver.find_element(By.CLASS_NAME, 'signin').click()
    time.sleep(3)

    # 往账号输入框输入账号
    element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/div[1]/div[1]/input')
    element.send_keys(username)

    # 往密码输入框输入密码
    element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/div[1]/div[2]/input')
    element.send_keys(password)

    element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/button')
    element.click()

    time.sleep(5)
    driver.quit()


if __name__ == '__main__':
    username = input("账号: ")
    # python的getpass会与pycharm内置的run终端形成阻塞,
    # 所以使用了getpass,则需要改成cmd或pycharm的Terminal终端来运行python脚本
    password = getpass("密码:")
    driver = get_driver()
    task(username, password)



查看操作元素的相关属性
import time
from getpass import getpass
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By


# 初始化一个谷歌浏览器实例对象
service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service)

# 打开一个网址
driver.get("http://localhost:63342/code/7.html?_ijt=gom325r5bev2q5u5q3uelkchje")

# # 查看元素的显示模式,是否是可见
# element = driver.find_element(By.NAME, 'access_token')
# display = element.is_displayed()
# print(display)
#
# # 查看元素的属性值
# print(element.get_attribute("value"))
# outline = element.value_of_css_property('border')
# print(outline)

# element = driver.find_element(By.XPATH, '/html/body/form/input[4]')
# enabled = element.is_enabled()
# print(enabled)

# # 提交表单
# element = driver.find_element(By.XPATH, '/html/body/form/input[5]')
# element.submit()


element = driver.find_element(By.XPATH, '/html/body/h1')
print(element.text)

time.sleep(5)
driver.quit()



规避监测

现在不少网站有对selenium采取了监测机制。这些监测机制会导致我们使用selenium访问网站时出现各种拦截机制,如出现验证码等。

解决方案:

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service


option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告

service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测


driver.get("https://www.baidu.com")

driver.find_element(By.ID, "kw").send_keys("路飞学城")
driver.find_element(By.ID, "su").click()

time.sleep(5)
driver.quit()



鼠标事件

在上面的selenium操作中,我们获取元素可以直接调用click方法实现鼠标点击效果。但是,如果要进行更加复杂多样的鼠标操作,如鼠标右键、鼠标移动、拖动等等,则需要依赖于 ActionChains (动作链) 模块提供的鼠标操作来完成。

from selenium.webdriver.common.action_chains import ActionChains


基本使用

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.action_chains import ActionChains

# 初始化一个谷歌浏览器实例对象
option=ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})


# 打开一个网址
driver.get("https://www.luffycity.com")

# 关闭广告
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/img[1]')
element.click()

time.sleep(5)
# 定位元素,定位到顶部导航菜单的"题库"上面
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/header/div/div/nav/a[6]')
# move_to_element 把鼠标悬停在指定元素上面
# context_click 执行鼠标右键
# 动作链在设置动作以后,不会自动执行,所以需要在动作链的末尾,调用perform执行整个动作链
ActionChains(driver).move_to_element(element).context_click().perform()

time.sleep(5)
driver.quit()



ActionChains动作链可以保存1个或多个连贯的鼠标操作,但是最终执行鼠标操作需要调用perform()方法。常用鼠标操作:

操作 描述
double_click(element) 鼠标双击
context_click(element) 鼠标右击
drag_and_drop(source, target) 鼠标拖动(将一个元素移动到另一个元素位置)
drag_and_drop_by_offset(source, xoffset, yoffset) 鼠标拖动(将一个元素移动到指定坐标位置)
move_to_element(element) 鼠标移动并悬放到指定元素的上方
move_by_offset(xoffset, yoffset) 将鼠标从当前位置移动到指定坐标处

测试页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
    .box{
        width: 100px;
        height: 100px;
        background: red;
    }
    .box:hover{
        background: blue;
    }
    </style>
</head>
<body>
    <div class="box"></div>
    <script>
    var box = document.querySelector(".box")
    box.ondblclick = function(){
        box.style.background = "yellow";
    }
    </script>
</body>
</html>


测试双击与鼠标选放

import time

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.action_chains import ActionChains

service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service)

# 打开一个网址
driver.get("C:/Users/Administrator/Desktop/code/11-测试页面.html")

element = driver.find_element(By.CLASS_NAME, 'box')
ActionChains(driver).move_to_element(element).perform()
time.sleep(1)
ActionChains(driver).double_click().perform()
time.sleep(3)
driver.quit()


拖拽元素

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions, ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.keys import Keys


option=ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

# 设置浏览器窗口最大化
driver.maximize_window()

# 进入页面
driver.get('https://www.jq22.com/yanshi10850')

iframe = driver.find_element(By.TAG_NAME, "iframe")
driver.switch_to.frame(iframe)

element1 = driver.find_element(By.XPATH, '//*[@id="bar"]/li[1]')
element2 = driver.find_element(By.XPATH, '//*[@id="foo"]')
time.sleep(3)
ActionChains(driver).drag_and_drop(element1, element2).perform()

time.sleep(10)
driver.quit()


键盘事件

前面我们在元素操作中使用了send_keys()方法来模拟键盘输入,而针对复杂的组合键盘操作,webdriver提供了键盘上几乎所有的按键方法,使用前导入Keys类即可。

from selenium.webdriver.common.keys import Keys


方法名 描述
send_keys(Keys.ENTER) 回车键Enter
send_keys(Keys.BACK_SPACE) 删除键BackSpace
send_keys(Keys.SPACE) 空格键(Space)
send_keys(Keys.TAB) 制表键(Tab)
send_keys(Keys.ESCAPE) 复位键Esc
send_keys(Keys.CONTROL,'a') 全选Ctrl+A
send_keys(Keys.CONTROL,'c') 复制Ctrl+C
send_keys(Keys.CONTROL,'x') 剪切Ctrl+X
send_keys(Keys.CONTROL,'v') 粘贴Ctrl+V
send_keys(Keys.F1) 键盘F1
…… ……
send_keys(Keys.F12) 键盘F12
基本使用

页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>

    </style>
</head>
<body>
<textarea name="content" id="" cols="30" rows="10">hello!EveryBody!</textarea>
</body>
</html>


代码:

import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.keys import Keys

service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service)
driver.get("C:/Users/Administrator/Desktop/code/12-测试页面.html")
time.sleep(3)
element = driver.find_element(By.TAG_NAME, "textarea")
element.send_keys(Keys.CONTROL, 'a')
element.send_keys(Keys.BACK_SPACE)

time.sleep(3)
element.send_keys("2012年9月25日,辽宁舰正式交付中国海军;2019年12月17日,山东舰入列,中国海军进入双航母时代;2022年6月17日,福建舰来了,我们有三艘航母了。")

time.sleep(5)
driver.quit()



回车键等操作

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.keys import Keys


option=ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

# 设置浏览器窗口最大化
driver.maximize_window()
# driver.minimize_window()  # 最小化
# 进入页面
driver.get('https://www.baidu.com/')
# 定位输入框
element = driver.find_element(By.ID, "kw")
# 向输入框中输入内容
element.send_keys("路飞学城")
time.sleep(2)
# 删除上一步中多输入的文字
element.send_keys(*[Keys.BACK_SPACE for i in range(2)])
element.send_keys("在线教育")

time.sleep(2)
# 使用回车代替点击按钮
element.send_keys(Keys.ENTER)
time.sleep(2)



屏幕截图

在selenium中,截取网页的图片有多种方式,一般常用的有2种方式:截取可见区域和截取指定区域。

截取可见区域

主要是通过driver.save_screenshot来完成。

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service

# 初始化一个谷歌浏览器实例对象

option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",
                       {'source': 'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

driver.get('https://www.luffycity.com')

# 关闭广告
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/img[1]')
element.click()

# 调用浏览器驱动对象的save_screenshot方法就可以实现截图
driver.save_screenshot('./luffycity.png')  # 图片必须保存为 png 格式
driver.quit()



save_screenshot使用过程中,需要注意2点:

  1. save_screenshot方法保存的图片格式必须是png格式,否则不行。
  2. save_screenshot保存图片的目录,必须是已经存在的,否则无法保存图片。
截取指定区域

通过先获取HTML文档中的元素对象,再通过元素对象调用screenshot 来进行截取。

import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service


# 初始化一个谷歌浏览器实例对象
option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",
                       {'source': 'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

driver.get('https://www.luffycity.com')

# 关闭广告
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/img[1]')
element.click()

# 点击登陆
driver.find_element(By.CLASS_NAME, 'signin').click()
time.sleep(3)

# 往账号输入框输入账号
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/div[1]/div[1]/input')
element.send_keys("13928835901")

# 往密码输入框输入密码
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/div[1]/div[2]/input')
element.send_keys("123")

element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/button')
element.click()

time.sleep(2)
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div')
# 通过元素的 screenshot 方法直接保存图片
element.screenshot('./login_result.png')
time.sleep(3)
driver.quit()



等待机制

针对一些通过ajax局部刷新或延时显示的数据,在selenium使用过程中如果没有等待数据显示就直接执行后续代码就会有问题。

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service

option=ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("C:/Users/Administrator/Desktop/test/测试自动化-day04/素材/chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

driver.get("http://www.baidu.com")

driver.find_element(By.ID, "kw").send_keys("路飞学城")
driver.find_element(By.ID, "su").click()
# time.sleep(3)
driver.find_element(By.PARTIAL_LINK_TEXT, "路飞学城").click()
time.sleep(3)
driver.quit()



上面虽然使用了time.sleep(3)让selenium强制等待了3秒,但是这种操作不够灵活,因为数据加载有时候不需要3秒就显示出来了,而程序并没有办法知道而导致最终呆呆的等待了3秒才继续执行。当然,也有可能3秒后数据还没有出来,那么程序则依然报错。所以开发中针对上述问题,我们是不能使用time.sleep来草草了事的,而是要使用selenium提供的另外两种等待机制。

显式等待
import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions


option=ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

driver.get("http://www.baidu.com")

driver.find_element(By.ID, "kw").send_keys("路飞学城")
driver.find_element(By.ID, "su").click()
# 使用time.sleep这种机械化的强制等待,实际上是有可能出问题的。即便没有问题,也会存在着让测试代码浪费等待时间的情况。
# time.sleep(3)
# driver.find_element(By.PARTIAL_LINK_TEXT, "路飞学城").click()

# 显式等待
WebDriverWait(driver=driver, timeout=60, poll_frequency=0.5, ignored_exceptions=None).until(expected_conditions.presence_of_element_located((By.PARTIAL_LINK_TEXT, '路飞学城'))).click()

time.sleep(3)
driver.quit()



selenium提供了 WebDriverWait类实现显式等待机制,WebDriverWait类接收4个参数来指定等待机制。

  • driver,浏览器驱动对象
  • timeout,最长超时等待时间,单位(秒)
  • poll_frequency,轮询检测等待条件的时间,也就是每隔多长时间检测一次条件,默认是0.5秒
  • ignored_exceptions,timeout超时后的异常信息,默认抛出NoSuchElementException

基本使用:

WebDriverWait(driver=driver, timeout=10, poll_frequency=0.5, ignored_exceptions=None)


WebDriverWait类提供了两个方法来完成等待条件的检测实现:

  • until(self, method, message=''),method为等待检测条件的判断方法,until表示直到返回True,用的较多。
  • until_not(self, method, message=''),method为等待检测条件的判断方法,until_not表示直到返回False。

上面代码用到的method指定为expected_conditions模块并调用其presence_of_element_located方法判断指定元素是否存在。

expected_conditions模块提供了各种判断:

  • presence_of_element_located判断某个元素是否存在于HTML文档中。
  • presence_of_elements_located 判断是否至少有1个元素存在HTML中。只要有1个元素存在,则该方法就返回True。

显式等待的另一种写法,首先实例化一个显式等待对象,这样可以在各个地方灵活的调用:

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions


option=ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

# 1. 先创建一个显式等待对象
wait = WebDriverWait(driver=driver, timeout=60, poll_frequency=0.5, ignored_exceptions=None)

driver.get("http://www.baidu.com")

driver.find_element(By.ID, "kw").send_keys("路飞学城")
driver.find_element(By.ID, "su").click()
# 使用time.sleep这种机械化的强制等待,实际上是有可能出问题的。即便没有问题,也会存在着让测试代码浪费等待时间的情况。
# time.sleep(3)
# driver.find_element(By.PARTIAL_LINK_TEXT, "路飞学城").click()

# 2. 显式等待对象调用判断等待方法
wait.until(expected_conditions.presence_of_element_located((By.PARTIAL_LINK_TEXT, '路飞学城'))).click()

time.sleep(3)
driver.quit()



隐式等待

也叫全局等待。我们可以直接通过浏览器驱动对象driver调用隐式等待,并且用法也相对简单:

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions


option=ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver=webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",{'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})
# 设置隐式等待
driver.implicitly_wait(time_to_wait=10)  # 只需要一个等待超时时间参数

driver.get("https://www.baidu.com")

driver.find_element(By.ID, "kw").send_keys("路飞学城")
driver.find_element(By.ID, "su").click()

driver.find_element(By.PARTIAL_LINK_TEXT, "路飞学城").click()

time.sleep(3)
driver.quit()



implicitly_wait等待时间单位为秒,如上例所示,我们指定了10秒。需要注意的是,隐式与显式等待有明显的区别,隐式等待应用于全局,每当使用driver驱动找某个元素时,隐式等待机制就会被触发(导致测试代码的运行速度变慢),如果元素存在,则继续执行,否则,它将以轮询的方式判断元素是否定位成功,直至等待超时,抛出错误NoSuchElementException。而显式等待则只是指定某(些)个元素是否存在时执行。

在等待机制的选择上,我们可以在三种等待机制中灵活选择:

  • 普通(静态页面较多)网页,强制等待和显式等待可以相互搭配,提高效率。适用于前后端不分离的UI场景测试。
  • 动态页面较多的时候,则可以全局设置隐式等待。适用于前后端分离的UI场景测试
窗口切换

在测试客户端时,往往存在有些功能流程需要点击打开并操作多个窗口页面的情况。此时就需要使用selenium在多个窗口中相互切换操作了。selenium中提供了三个方法给测试开发人员完成窗口切换操作。

方法名 描述
driver.switch_to.window 切换普通窗口
driver.switch_to.frame 切换进入iframe窗口
driver.switch_to.default_content 切换退出iframe窗口
driver.window_handles 窗口数组
切换普通窗口
import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.wait import WebDriverWait  # 等待页面加载某些元素
from selenium.webdriver.support import expected_conditions

option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])
option.add_experimental_option('useAutomationExtension', False)
service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",
                       {'source': 'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})

wait = WebDriverWait(driver, 10)

try:
    driver.get("https://www.baidu.com")

    driver.find_element(By.ID, "kw").send_keys("路飞学城")
    driver.find_element(By.ID, "su").click()
    wait.until(expected_conditions.presence_of_element_located((By.LINK_TEXT, '路飞学城'))).click()
    # 查看当前浏览器中打开的窗口列表
    print(driver.window_handles)
    time.sleep(3)
    # 根据数组下标索引切换窗口
    driver.switch_to.window(driver.window_handles[0])
    time.sleep(3)
    driver.find_element(By.LINK_TEXT, '百度百科').click()
    print(driver.window_handles)  # 此时应该有3个窗口了
    time.sleep(3)
    driver.switch_to.window(driver.window_handles[1]) # 切换到路飞窗口
finally:
    time.sleep(3)
    driver.quit()



切换iframe窗口

两个方法:

  • switch_to.frame(iframe),进入窗口
  • switch_to.default_content(),退出窗口
import time
import getpass
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service


def get_driver():
    """获取浏览器驱动对象"""
    option = ChromeOptions()
    option.add_experimental_option('excludeSwitches', ['enable-automation'])
    option.add_experimental_option('useAutomationExtension', False)
    service = Service("chromedriver.exe")
    driver = webdriver.Chrome(service=service, options=option)
    driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument",
                           {'source': 'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'})
    # 设置隐式等待
    driver.implicitly_wait(10)
    return driver

def task(url, username, password, to, theme):
    """
    发送邮件
    :param url  打开站点地址
    :param username 登陆账号
    :param password 登陆密码
    :param to 收件人邮箱账号
    :param theme 邮件的主体
    """
    driver.get(url)
    iframe = driver.find_elements(By.TAG_NAME, 'iframe')
    driver.switch_to.frame(iframe[0])
    driver.find_element(By.CLASS_NAME, 'dlemail').send_keys(username)
    driver.find_element(By.CLASS_NAME, 'dlpwd').send_keys(password)
    driver.find_element(By.ID, 'dologin').click()
    # 登陆以后刷新了页面了,所以需要重新获取driver浏览器驱动对象,否则无法进行后续操作
    driver.switch_to.window(driver.window_handles[0])
    driver.find_element(By.ID, '_mail_component_149_149').click()
    driver.find_element(By.CLASS_NAME, 'nui-editableAddr-ipt').send_keys(to)
    driver.find_elements(By.CLASS_NAME, 'nui-ipt-input')[2].send_keys(theme)

    content_iframe = driver.find_element(By.CLASS_NAME, 'APP-editor-iframe')
    driver.switch_to.frame(content_iframe)
    driver.find_element(By.TAG_NAME, "p").send_keys("这是通过selenium添加的邮件内容!!!")
    driver.switch_to.default_content()
    # 发送邮件
    driver.find_elements(By.CLASS_NAME, 'nui-btn-text')[2].click()

if __name__ == '__main__':
    url = 'https://mail.163.com'
    theme = '一份测试邮件'
    to = '649641514@qq.com'
    content = '测试邮件内容.................................'
    # username = input('用户名: ').strip()
    username = "moooluo2022"
    # pycharm中直接运行python脚本会导致getpass进入阻塞,所以需要使用终端命令行运行python脚本
    password = getpass.getpass(prompt="密码: ")
    driver = get_driver()
    try:
        task(url, username, password, to, content)
    except Exception as e:
        print(f"发送邮件出错:{e}")
    finally:
        time.sleep(20)
        driver.quit()



执行js代码

实际上,我们之前有提到关于selenium实际上操作浏览器都是通过webdriver完成对应的功能操作的,而webdriver实际上是通过js代码来控制浏览器的。因此针对如果selenium本身如果没有提供的操作,则我们可以使用js代码来完成一些窗口操作。例如:滚动条。

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service


option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告

service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测

driver.get("https://www.baidu.com")

# # 执行没有返回的值的js代码
# driver.execute_script('confirm("hello, luffycity!")')
#
# # 关闭alert弹窗
# time.sleep(3)
# # 让driver浏览器驱动对象,切换焦点(切换上下文)到弹窗
# alert = driver.switch_to.alert
# # 点击确定,以达到关闭弹窗的效果
# # alert.accept()
# alert.dismiss()   # 关闭弹窗,相当于点击了取消

"""让浏览器执行js代码并获取代码的返回值"""

"""例如:获取cookie"""
# result = driver.execute_script('return document.cookie')
# cookies = result.split(";")
# cookies_dict = {i[0]:i[1] for i in [item.split("=") for item in cookies]}
# print(cookies_dict.get("BIDUPSID"))
# 有了cookie以后,以后就可以跳过登陆测试其他的功能操作了。

"""例如:获取本地存储中的token"""
result = driver.execute_script("return localStorage")
print(result) #
time.sleep(5)
driver.quit()



操作滚动条

import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service

service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service)

driver.get("https://www.luffycity.com")

# 关闭广告
element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/img[1]')
element.click()

num = 0
while num <= driver.execute_script("""return parseInt(getComputedStyle(document.querySelector('.main'))["height"])"""):
    num += 1
    driver.execute_script(f"window.scrollTo(0, {20*num})")
    time.sleep(0.01)

time.sleep(3)
driver.quit()



无头浏览器

所谓的无头浏览器(Headless),实际上就是不需要打开浏览器去操作浏览器完成对应UI效果。

常用的无头浏览器有:

  1. phontomjs

    from selenium import webdriver
    
    def foo():
        """
        如果报如下错误:
        selenium.common.exceptions.WebDriverException: Message: 'phantomjs' executable needs to be in PATH.
        原因是在执行时,没有在 path中找到驱动,这里的解决办法是实例化driver对象时,添加executable_path参数,引用驱动的绝对路径
        """
    
        driver = webdriver.PhantomJS(executable_path=r"C:\Python36\Scripts\phantomjs-2.1.1-windows\bin\phantomjs.exe") # 解决如上报错
        driver.implicitly_wait(time_to_wait=10)
        driver.get('https://www.baidu.com')
        print(driver.title)  # 百度一下,你就知道
        driver.quit()
    
    if __name__ == '__main__':
        foo()
    
    
    
  2. 谷歌无头

  3. 火狐无头

如无特殊情况,推荐使用谷歌和火狐无头

参考:https://www.cnblogs.com/Neeo/articles/10671532.html#无头浏览器

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By

option = ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告

# 开启浏览器的无头模式
option.add_argument('--headless')
option.add_argument('--disable-gpu')  # 允许在无GPU的环境下运行,可选
option.add_argument('--window-size=1920x1080')  # 建议设置

service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测
# 设置隐式等待
driver.implicitly_wait(time_to_wait=10)  # 只需要一个等待超时时间参数

# 打开网页
driver.get("https://www.baidu.com")
# 搜索内容
driver.find_element(By.ID, "kw").send_keys("路飞学城")
driver.find_element(By.ID, "su").click()

# 点击搜索结果
driver.find_element(By.PARTIAL_LINK_TEXT, "路飞学城").click()

# 调用浏览器驱动对象的save_screenshot方法就可以实现截图
driver.save_screenshot('./baidu_search_result.png')  # 图片必须保存为 png 格式

driver.quit()



使用无头浏览器,在运行一些经过验证的测试脚本时,可以达到节约系统资源的优点,而且少了浏览器的UI效果,UI测试脚本的运行速度会加快。

文件上传

上传文件非常的简单,也就是在send_keys中添加上文件路径即可。

from selenium import webdriver

driver = webdriver.Chrome()

driver.get('http://127.0.0.1:8000/import_case/2')
driver.find_element_by_name('file_obj').send_keys(r'C:\Users\Anthony\Desktop\a.jpg')
driver.find_element_by_xpath('/html/body/div/div/div/form/input[3]').click()

滚动操作
百度ai识别验证码

https://ai.baidu.com/

  1. 首先要有一个百度ai的账号(百度云盘的账号也能用)
  2. 访问控制台,选择文字识别

image-20200512174219506

  1. 选择创建应用

image-20200512174519419

  1. 配置应用

![FireShot Capture 002 - 百度智能云-管理中心 - console.bce.baidu.com](http://rml40v12c.hd-bkt.clouddn.com/FireShot Capture 002 - 百度智能云-管理中心 - console.bce.baidu.com.png)

随便输入一些描述信息。

  1. 获取api KEY 和 seccet key

image-20200512174854026

  1. 获取 token

参考:https://cloud.baidu.com/doc/OCR/s/zk3h7xz52

import requests
import base64
# client_id 为官网获取的 API Key, client_secret 为官网获取的 Secret Key
host = 'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=SBHMsV1cYBeOXSAL0X975GCG&client_secret=LedXa2pPqKT9WmO2qU1FegDg9u2Gbe27'
response = requests.get(host)
if response:
    access_token = response.json()['access_token']


  1. 获取识别结果
import requests
import base64
'''
通用文字识别
'''

request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic"
# 二进制方式打开图片文件
f = open(r'D:\video\s28-testing-day18-selenium\note\b.png', 'rb')
img = base64.b64encode(f.read())

params = {"image": img}
access_token = '24.83c36a5a583d87923919104793022a56.2592000.1591866873.282335-19847256'
request_url = request_url + "?access_token=" + access_token
headers = {'content-type': 'application/x-www-form-urlencoded'}
response = requests.post(request_url, data=params, headers=headers)
if response:
    print(response.json()['words_result'][0]['words'])


实战:SaaS项目登录实战
import base64
import requests
import unittest
from selenium import webdriver
from HTMLTestRunnerSelenium import HTMLTestRunner



class MyCase(unittest.TestCase):
    user = "18211101742"
    password = "root1234"
    code_img = r'D:\video\s28-testing-day18-selenium\note\c.png'

    @classmethod
    def setUpClass(cls):
        cls.driver = webdriver.Chrome()
        cls.driver.implicitly_wait(10)

    @classmethod
    def tearDownClass(cls):
        cls.driver.quit()


    def setUp(self):
        self.driver.get('http://www.neeo.cc:6005/login/')


    def test_case_01(self):

        self.driver.find_element_by_id('id_username').send_keys(self.user)
        self.driver.find_element_by_id('id_password').send_keys(self.password)

        # 首先获取验证码图片,然后调用百度AI的文字识别接口,接着将验证码图片发送到百度服务器,得到验证码的识别结果,然后将结果send到 input 框中

        # 获取img图片
        self.driver.find_element_by_id("imageCode").screenshot(self.code_img)
        # 将图片发送到百度ai的文字识别接口
        self.driver.find_element_by_id('id_code').send_keys(self.foo())

        # 点击确定
        self.driver.find_element_by_css_selector('input[type=submit]').click()
        try:
            text = self.driver.find_element_by_class_name('col-xs-7').find_element_by_tag_name('span').text
            if text:
                # 访问失败
                self.assertEqual('', '验证码输入错误')
            else:
                pass
        except Exception:
            pass

    def test_case_02(self):
        self.driver.find_element_by_id('id_username').send_keys(self.user)
        self.driver.find_element_by_id('id_password').send_keys(self.password)

        # 首先获取验证码图片,然后调用百度AI的文字识别接口,接着将验证码图片发送到百度服务器,得到验证码的识别结果,然后将结果send到 input 框中

        self.driver.find_element_by_id('id_code').send_keys("xxxxxxxx")

        # 点击确定
        self.driver.find_element_by_css_selector('input[type=submit]').click()

        try:
            text = self.driver.find_element_by_class_name('col-xs-7').find_element_by_tag_name('span').text
            if text:
                # 访问失败
                self.assertEqual('', '验证码输入错误')
            else:
                pass
        except Exception:
            self.assertEqual('', '验证码输入错误')
            
            
    def foo(self):
        request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic"
        # 二进制方式打开图片文件
        f = open(self.code_img, 'rb')
        img = base64.b64encode(f.read())

        params = {"image": img}
        access_token = '24.83c36a5a583d87923919104793022a56.2592000.1591866873.282335-19847256'
        request_url = request_url + "?access_token=" + access_token
        headers = {'content-type': 'application/x-www-form-urlencoded'}
        response = requests.post(request_url, data=params, headers=headers)
        if response:
            return response.json()['words_result'][0]['words']
        else:
            return ''

if __name__ == '__main__':

    suite = unittest.makeSuite(testCaseClass=MyCase)
    f = open('./report.html', 'wb')
    HTMLTestRunner(verbosity=2, title='xxxx', description='ooooo', stream=f).run(suite)

    f.close()



生成测试报告
selenium集成到pytest实现UI测试自动化

selenium初始化,utils/seleniumer.py代码:

from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By

option = ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告

# 开启浏览器的无头模式
option.add_argument('--headless')
option.add_argument('--disable-gpu')  # 允许在无GPU的环境下运行,可选
option.add_argument('--window-size=1920x1080')  # 建议设置

service = Service("chromedriver.exe")
driver = webdriver.Chrome(service=service, options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测


注意:把chromedriver.exe保存到站点根目录下。

image-20220620151300062

单元测试代码,users/test_login.py,代码:

    def test_luffycity_login(self):
        """selenium结合pytest实现自动化UI测试"""
        from seleniumer import driver, By
        import time
        request = Request()
        allure.dynamic.title("测试路飞登陆")
        request.logger.info(f"开始请求测试接口:测试路飞登陆")
        url = 'https://www.luffycity.com'
        request.logger.info(f"打开登陆地址:{url}")
        driver.get(url)
        # 关闭广告
        element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/img[1]')
        element.click()

        # 点击登陆
        driver.find_element(By.CLASS_NAME, 'signin').click()

        # 往账号输入框输入账号
        element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/div[1]/div[1]/input')
        username = "13928835901"
        request.logger.info(f"填写登陆账号:{username}")
        element.send_keys(username)

        # 往密码输入框输入密码
        password = "123"
        element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/div[1]/div[2]/input')
        request.logger.info(f"填写登陆密码:{password}")
        element.send_keys(password)

        element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div/div[2]/button')
        element.click()

        # 通过元素的 screenshot 方法直接保存图片
        time.sleep(2)
        element = driver.find_element(By.XPATH, '//*[@id="__layout"]/div/div[2]/div')
        element.screenshot('./login_result.png')
        # 采用附件记录
        allure.attach.file('./login_result.png')

        driver.quit()


提交代码版本,代码:

git add .
git commit -m "feature: selenium集成到pytest实现UI测试自动化"
# 推送代码记录到远程服务器的代码仓库
git push origin master

APP自动化测试

APP移动端测试是指对移动端应用进行的测试,测试应用功能是否满足特定的需求。移动端测试一般分3类:APP功能测试、APP自动化测试、APP安全测试。其中常用的APP自动化测试工具有:Robtium、macaca、Appium、UiAutomator、Monkey、MonkeyRunner、Instrumentation、Athrun等等。

工具 支持语言 跨平台 跨应用
Robotium Java Android 不支持
Macaca Java、Python、node.js Android、IOS 支持 可以作为简洁版的Appium工具
Appium Java、c#、Python、PHP、Perl、Ruby、node.js Android、IOS、H5 支持

Appium

基本介绍

Appium由Sauce labs公司(是美国的一家提供自动化的软件测试服务公司)基于node.jsexpress框架开发http server,是一个开源、跨平台的移动端自动化测试框架,可以用来测试原生及混合的移动端应用。Appium支持IOS、Android及H5等移动端应用。Appium使用WebDriver的json wire协议来驱动Apple系统的UIAutomation库(由苹果官方提供的自动化测试库)、Android系统的UIAutomator框架(由Android官方提供的自动化测试库)。

img

与selenium的关系

appium继承了selenium的webdriver,也就是selenium2.0,所以appium在调试中实际上也调用了selenium的某些功能。

appium起到了一个电脑连接移动端的桥梁,然后我们可以在电脑上调用selenium工具来调试移动端应用。

appium原理

appium本质上就是一个使用node.js编写的http server(Http服务器),它对外暴露了一系列REST API接口提供给测试开发人员进行调用。

这个http server的功能其实很简单:监听一个端口,然后接收由client发送来的command。翻译这些command,把这些command转成移动设备可以理解的形式发送给移动设备,然后移动设备执行完这些command后把执行结果返回给httpserver,http server再把执行结果返回给client。

在这里client其实就是发起command的设备,一般来说就是我们编写代码执行的机器,执行appium测试代码的机器。狭义点理解,可以把client理解成是代码,当然这些代码可以是java/ruby/python/js的,只要它实现了webdriver标准协议就可以。

这样的设计思想带来了一些好处:

  • 可以带来多语言的支持;
  • 可以把server放在任意机器上,哪怕是云服务器都可以;(是的,appium和webdriver天生适合云测试)

1670590766087

安装步骤

安装java环境

目前,java环境普遍来说使用的都是java8,所以我们直接下载安装1.8版本的sdk即可(jdk是Java语言的软件开发工具包,是整个java开发的核心,它包含了JAVA的运行环境,JAVA工具和JAVA基础的类库)。

下载地址:https://www.oracle.com/java/technologies/downloads/

鼠标右键安装(注意,不要把它安装到有中文的路径下,所以最好默认路径即可),一路点击确定下一步即可完成安装。

注意:javasdk环境是依赖于JAVA_HOME的,依次打开控制面板系统与安全系统高级系统设置环境变量系统变量新建。变量名中输入JAVA_HOME,变量值中填写刚才获取到的路径C:\tool\Java\jdk1.8.0_201(注意,此处根据自己的实际路径填写,别瞎复制)。

image-20220618135144208

保存了JAVA_HOME环境变量,点击下方的Path环境变量,里面把java sdk安装目录下bin目录加入到环境变量中。

image-20220618135206230

cmd终端输入java -version,出现如下内容表示安装完成。

image-20220618182351919

安装Android SDK

[Android SDK](https://baike.baidu.com/item/Android SDK)(software development kit,译作:软件开发工具包),是用于为特定的软件包、软件框架、硬件平台、操作系统等建立应用软件的开发工具的集合。Android SDK 指的是Android专属的软件开发工具包。

下载地址:https://www.androiddevtools.cn/#sdk-tools

image-20220618044813946

把下载下来的sdk压缩包解压到一个没有中文路径的目录下。例如:我的是 C:/tool/

image-20220618182733621

解压完成以后,进入sdk目录下,点击 SDK Manager.exe(sdk开发工具管理器,提供了类似pip的功能)。

按下面图中所示,勾选安卓开发最基本的7个模块到本地。

image-20220618045458786

image-20220618045447895

image-20220618045436474

安装完成以后,Android SDK Tools会在安装成功以后,从上面隐藏掉,不用在意。

接下来,需要把以下三个路径加入到环境变量中,让系统能识别 android sdk(注意根据自己实际的解压路径填写,不要瞎复制)。

C:\tool\android-sdk-windows\tools
C:\tool\android-sdk-windows\platform-tools
C:\tool\android-sdk-windows\build-tools\29.0.3


image-20220618183513128

cmd终端下输入命令adb,出现如下效果则安装正确。

image-20220618050152136

  • adb.exe( Android Debug Bridge,安卓调试桥),调试app用的命令行工具,借助这个工具,我们可以直接在PC端就可以通过USB或者wifi就可以管理设备或模拟器的状态。保存在android-sdk-windows\platform-tools
  • aapt.exe(Android Asset Packaging Tool),安卓开发使用的一个自动资源打包工具,可以用来检测apk的包名和activity名称。保存在android-sdk-windows\build-tools\<安卓sdk版本>

需要注意的问题

  1. 你的ANDROID_HOME环境变量没配置好

报错Appium异常:An unknown server-side error occurred while processing the command. Original error: Could not find 'adb.exe' in PATH. Please set the ANDROID_HOME or ANDROID_SDK_ROOT environment variables to the corect Android SDK root directory path.

1670798348704

1670798433198

  1. 缺个jar包

报错Appium异常:An unknown server-side error occurred while processing the command. Original error: Cannot verify the signature of 'C:\Users\Administrator\AppData\Local\Programs\Appium\resources\app\node_modules\appium\node_modules\appium-uiautomator2-server\apks\appium-uiautomator2-server-v4.17.4.apk'. Original error: Could not find 'apksigner.jar'

解决办法:下载我的百度网盘中有Android SDK目录下jar包 ,将下载好的jar包放在platform-tools目录下

安装模拟器

appium既支持使用真机测试,也支持模拟器测试。不过我们测试过程中实际上也并非一定要使用真机,因为我们测试的并非是移动端的操作系统而是app本身,所以很多时候测试开发过程中使用模拟器会更加方便,最关键的是模拟器可以轻松切换不同版本。这里推荐使用夜神模拟器(它不仅有windows版本,也有ios版本,而像其他的雷神模拟器,则仅支持windows版本,没有ios版本)。

下载地址:https://www.yeshen.com/

提示:如果windows下同时安装了安卓模拟器和docker-desktop的同学,如果无法顺利启动模拟器,可能需要执行以下命令,切换模拟器与docker-desktop的虚拟化服务。

# 使用模拟器
bcdedit /set hypervisorlaunchtype off
# 使用docker-desktop
bcdedit /set hypervisorlaunchtype auto


模拟器默认在安装以后会在bin目录下提供一个adb.exe安卓调试桥接工具(夜神模拟器叫nox_adb.exe),但是这个工具的版本有可能与我们安装的android sdk的abd.exe版本不一样,这样会导致无法使用appium调试模拟器中的移动端应用,所以需要把android sdk的abd.exe复制并改名替换掉模拟器bin目录下的adb.exe。真机测试则没有这个问题

找到android sdk安装目录下platform-tools目录下的adb.exe。

image-20220618092459525

找到夜神安装目录的bin目录下的nox_adb.exe,并备份,然后替换成上面的。

同时打开模拟器中的配置,修改为手机版面,并开启开发者模式,并勾选开启USB调试功能。

image-20220618092931642

image-20220618093136287

image-20220618093145540

image-20220618093200471

点击7下召唤神龙~。。。。说错了,重来,点击7点是打开当前设备的开发者选项。

image-20220618093218482

返回上一步,就可以看到多了一个开发者选项了,接下来,开启USB调试选项。

image-20220618135425171

image-20220618135529642

安装appium

因为版本更新的原因,实际上appium是存在着2个分支版本的,分别是appium Server 与 appium Desktop。其中,appium Server的安装比较复杂需要先安装node.js,所以目前来说官方已经不再更新与维护appium Server分支了,而是推荐测试开发者安装 appium Desktop版本。所以此处,我们直接通过以下链接到github下根据自己当前使用的操作系统安装appium Desktop即可。

下载地址:https://github.com/appium/appium-desktop/releases

image-20220618034957823

下载到本地以后双击exe文件,一路默认安装即可。

完成上面操作以后,打开终端输入adb devices ,看到如下效果,则表示安装环境全部完成。

image-20220618132439830

adb devices的结果是当前连接到PC端的所有的移动端设备列表。左侧参数是设备ID,右侧参数是设备的连接状态。

如果使用模拟器则显示效果如下:
127.0.0.1:5555 device # 表示雷电模拟器
127.0.0.1:62001 device # 表示夜神模拟器

如果使用真机则显示效果如下:
UJN0221722001562 device

注意:鸿蒙手机实际上虽然不是安卓系统,但是鸿蒙手机为了兼容安卓,实际上也提供了adb调试,但是很遗憾的是,我们不能直接使用USB调试,只能改用wifi调试才可以测试鸿蒙系统下的app。

adb命令

以下是开发中比较常用的adb命令:

命令 描述
adb devices 查询连接到系统的所有移动端设备状态,常见状态:
device:连接正常
offline:已断开
unauthorized:未授权(手机端弹出的调试框没有允许)
adb -s 设备ID 指定设备使用adb,设置ID通过上面adb devices可以获取
adb -s 设备ID shell [命令] 使用Android Linux内核的命令
adb -s 设备ID install -r 包名+路径 安装apk包,-r表示覆盖安装,可以使用aapt d badging apk文件路径查看app包名。
adb -s 设备ID uninstall 包名 卸载apk
adb -s 设备ID push 电脑路径 手机路径 电脑文件上传至手机
adb -s 设备ID pull 手机路径 电脑路径 手机文件下载至电脑
adb -s 设备ID shell pm list packages 列出手机中装的所有APP的包名
adb -s 设备ID shell pm clear apk包名 清除应用数据及缓存
**`adb -s 设备ID shell dumpsys window windows grep mFocusedApp`**

安装:

# 通过自动打包工具aapt,查看下载回来的apk的包名,并把源apk文件名改成包名。
aapt d badging C:\Users\Administrator\Desktop\test\jd.apk

# 通过adb指定设备,进行安装
adb -s 127.0.0.1:62001 install C:\Users\Administrator\Desktop\test\com.jingdong.app.mall.apk

# 卸载apk包
adb -s 127.0.0.1:62001 uninstall com.jingdong.app.mall


安装appium-inspector

Appium Inspector是appium Server UI内置的一个元素定位工具,在早期版本中是内置的,新版本已经分离了,所以可以通过以下链接进行安装。

下载地址:https://github.com/appium/appium-inspector/releases

1670784902548

image-20220702095210851

安装Appium-Python-Client

Python想要操作appium,就要有专门的的连接工具模块-appium-python-client。直接安装即可。

pip install appium-python-client


OK。经过上面的步骤以后,appium的安装步骤就全部完成了。

基本使用

  1. 打开appium,点击startServer。

image-20220625054432307

  1. 打开模拟器或真机,选择要启动的应用,通过cmd终端查找当前应用的包名(appPackage)与激活入口地址(appActivity)。

    image-20220625054509773

    例如我们在模拟器中选择并打开“设置“。

    adb devices
    adb -s 127.0.0.1:62001 shell dumpsys window windows | grep mFocusedApp
     # adb shell dumpsys window windows | grep mFocusedApp
    
    
    

    效果:

    image-20220625054935457

  2. 通过python操作移动端打开“设置”应用。

    import time
    from appium.webdriver.webdriver import WebDriver
    
    # Appium 服务器初始化参数
    desired_capabilities = {
      "platformName": "Android",  # 系统类型 IOS/Android
      "platformVersion": "7",     # 操作系统版本
      "deviceName": "127.0.0.1:62001",  # 设备类型,设备ID,IOS必须填写
      # adb shell dumpsys window windows | grep mFocusedApp
      "appPackage": "com.jingdong.app.mall",  # 要打开的APP包名
      "appActivity": ".MainFrameActivity"             # APP激活的首屏名称
    }
    
    driver = WebDriver("http://127.0.0.1:4723/wd/hub", desired_capabilities)
    
    time.sleep(5)
    
    driver.quit()
    
    
    
    

jsonwp协议

在前面的Appium基本介绍时,我们提到过Appium Server实际上就是一个HTTP服务器,它对外暴露了一系列的restful API接口给开发者进行远程调用。

img

所以我们上面编写的python代码打开设置应用这个过程,本质上就是通过jsonwp(json wire protocol, WebDriver 开发者编写的一种通信协议)协议来实现对远程APP的操作的。

请求方法 uri地址 描述
POST /session 新建会话
DELETE /session/会话ID 删除会话
GET /status 获取appium服务器状态信息

更多restful API接口操作:https://w3c.github.io/webdriver/#x6-5-endpoints

postman操作

1670798982308

// 新建会话
// POST http://127.0.0.1:4723/wd/hub/session
{
    "desiredCapabilities": {
        "platformName": "Android",
        "platformVersion": "7",
        "deviceName": "127.0.0.1:62001",
        "appPackage": "com.android.settings",
        "appActivity": ".Settings"
    }
}

// 删除会话【会话session ID在上面的请求操作的响应字典中可以找到】
// DELETE http://127.0.0.1:4723/wd/hub/session/d9cd07ad-1170-49fd-99cc-a4fb4f4e4435


capability

初始化参数(Capability)是JSON数据类型编码的键和值,当一个新的自动化会话被请求时,Appium客户端发送此参数到服务端。此参数传递到Appium drivers用于设置各种重要事项测试工作。每种客户端语言都有特定的Appium客户端构建参数,但最终他们以JSON数据发送到Appium Server的。

初始化参数(Capability)可以编写在WebDriver测试代码中或者存放在Appium Server GUI中(Inspector会话)。

启动参数文档:https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/caps.md

中文翻译文档:https://github.com/appium/appium/blob/master/docs/cn/writing-running-appium/caps.md

通用参数
描述
automationName 自动化测试的引擎 Appium (默认)或者 Selendroid
platformName 使用的手机操作系统 iOS, Android, 或者 FirefoxOS
platformVersion 手机操作系统的版本 例如 7.1, 4.4
deviceName 使用的手机或模拟器设备名称 iPhone Simulator, iPad Simulator, iPhone Retina 4-inch, Android Emulator, Galaxy S4, 等等.... 在 iOS 上,使用 Instruments 的 instruments -s devices 命令可返回一个有效的设备的列表,例如:iPhone X等。在 Andorid 上虽然这个参数目前已被忽略,但仍然需要添加上该参数。
app 本地绝对路径_或_远程 http URL 所指向的一个安装包(.ipa,.apk,或 .zip 文件)。Appium 将其安装到合适的设备上。请注意,如果您指定了 appPackageappActivity 参数(见下文),Android 则不需要此参数了。该参数也与 browserName 不兼容。 /abs/path/to/my.apkhttp://myapp.com/app.ipa
browserName 做自动化时使用的浏览器名字。如果是一个应用则只需填写个空的字符串 'Safari' 对应 iOS,'Chrome', 'Chromium', 或 'Browser' 则对应 Android
newCommandTimeout 用于客户端在退出或者结束 session 之前,Appium 等待客户端发送一条新命令所花费的时间(秒为单位) 例如 60
language (Sim/Emu-only) 为模拟器设置语言 例如 fr
locale (Sim/Emu-only) 为模拟器设置所在区域 例如 fr_CA
udid 连接真机的唯一设备号 例如 1ae203187fc012g
orientation (Sim/Emu-only) 模拟器当前的方向 竖屏横屏
autoWebview 直接转换到 Webview 上下文(context)。默认值为 false true, false1670789521668
noReset 在当前 session 下不会重置应用的状态。默认值为 false true, false
fullReset (iOS)删除所有的模拟器文件夹。(Android) 要清除 app 里的数据,请将应用卸载才能达到重置应用的效果。在 Android, 在 session 完成之后也会将应用卸载掉。默认值为 false true, false
Android 独有
描述
appActivity Activity 的名字是指从你的包中所要启动的 Android acticity。他通常需要再前面添加. (例如 使用 .MainActivity 代替 MainActivity MainActivity, .Settings
appPackage 运行的 Android 应用的包名 com.example.android.myApp, com.android.settings
appWaitActivity 用于等待启动的 Android Activity 名称 SplashActivity
appWaitPackage 用于等待启动的 Android 应用的包 com.example.android.myApp, com.android.settings
appWaitDuration 用于等待 appWaitActivity 启动的超时时间(以毫秒为单位)(默认值为 20000) 30000
deviceReadyTimeout 用于等待模拟器或真机准备就绪的超时时间 5
androidCoverage 用于执行测试的 instrumentation 类。 传送 -w 参数到如下命令 adb shell am instrument -e coverage true -w com.my.Pkg/com.my.Pkg.instrumentation.MyInstrumentation
enablePerformanceLogging (仅适用于 Chrome 与 webview)开启 Chromedriver 的性能日志。(默认值为 false true, false
androidDeviceReadyTimeout 用于等待设备在启动应用后准备就绪的超时时间。以秒为单位。 例如 30
androidInstallTimeout 用于等待在设备中安装 apk 所花费的时间(以毫秒为单位)。默认值为 90000 例如 90000
adbPort 用来连接 ADB 服务器的端口(默认值为 5037 5037
useKeystore 使用自定义的 keystore 给 apk 签名,默认值为 false truefalse
keystorePath 自定义 keystore 的路径, 默认路径为 ~/.android/debug.keystore 例如 /path/to.keystore
keystorePassword 自定义 keystore 的密码 例如 foo
keyAlias key 的别名 例如 androiddebugkey
keyPassword key 的密码 例如 foo
chromedriverExecutable webdriver 可执行文件的绝对路径(如果 Chromium 内嵌一个自己提供的 webdriver,则应使用他去替换掉 Appium 自带的 chromedriver) /abs/path/to/webdriver
autoWebviewTimeout 用于等待 Webview 上下文(context)激活的时间(以毫秒为单位)。默认值为 2000 例如 4
dontStopAppOnReset 在使用 adb 启动应用之前,不要终止被测应用的进程。如果被测应用是被其他钩子(anchor)应用所创建的,设置该参数为 false 后,就允许钩子(anchor)应用的进程在使用 adb 启动被测应用期间仍然存在。换而言之,设置 dontStopAppOnResettrue 后,我们在 adb shell am start 的调用中不需要包含 -S标识(flag)。忽略该 capability 或 设置为 false 的话,就需要包含 -S 标识(flag)。默认值为 false truefalse
unicodeKeyboard 使用 Unicode 输入法。 默认值为 false truefalse
resetKeyboard 在设定了 unicodeKeyboard 关键字的 Unicode 测试结束后,重置输入法到原有状态。如果单独使用,将会被忽略。默认值为 false truefalse
noSign 跳过检查和对应用进行 debug 签名的步骤。仅适用于 UiAutomator,不适用于 selendroid。 默认值为 false truefalse
disableAndroidWatchers 禁用 android 监视器(watchers)。监视器用于见识应用程序的无响应状态(anr)和崩溃(crash),禁用会降低 Android 设备或模拟器的 CPU 使用率。该 capability 仅在使用 UiAutomator 时有效,不适用于 selendroid,默认设置为 false truefalse
chromeOptions 允许对 ChromeDriver 传 chromeOptions 的参数。了解更多信息请查阅 chromeOptions chromeOptions: {args: ['--disable-popup-blocking']}
recreateChromeDriverSessions 当移除非 ChromeDriver webview时,终止掉 ChromeDriver 的 session。默认设置为 false truefalse
nativeWebScreenshot 在 web 的上下文(context),使用原生(native)的方法去截图,而不是用过代理的 ChromeDriver。默认值为 false truefalse
androidScreenshotPath 在设备中截图被保存的目录名。默认值为 /data/local/tmp 例如 /sdcard/screenshots/
autoGrantPermissions 让Appium自动确定您的应用需要哪些权限,并在安装时将其授予应用。默认设置为 false truefalse
iOS 独有
描述
udid 连接的真实设备的唯一设备编号 (Unique device identifier),可以通过xcode来获取。 例如 1ae203187fc012g
bundleId 被测应用的 bundle ID 。用于在真实设备中启动测试,也用于使用其他需要 bundle ID 的关键字启动测试。在使用 bundle ID 在真实设备上执行测试时,你可以不提供 app 关键字,但你必须提供 udid 例如 io.appium.TestApp
launchTimeout 以毫秒为单位,在 Appium 运行失败之前设置一个等待 instruments 的时间 例如 20000
autoAcceptAlerts 当警告弹出的时候,都会自动去点接受。包括隐私访问权限的警告(例如 定位,联系人,照片)。默认值为 false。不支持基于 XCUITest 的测试。 truefalse
autoDismissAlerts 当警告弹出的时候,都会自动去点取消。包括隐私访问权限的警告(例如 定位,联系人,照片)。默认值为 false。不支持基于 XCUITest 的测试。 truefalse
nativeInstrumentsLib 使用原生 intruments 库(即关闭 instruments-without-delay)。 truefalse
interKeyDelay 以毫秒为单位,按下每一个按键之间的延迟时间 例如 100
showIOSLog 是否在 Appium 的日志中显示设备的日志。默认值为 false truefalse
screenshotWaitTimeout 以秒为单位,生成屏幕截图的最长等待时间。默认值为:10 例如 5
webviewConnectRetries 用于获取 webview 失败时,发送连接信息到远程调试器的次数。默认次数为: 8 例如 12
appName 被测应用的名字。 用于支持 iOS 9 以上系统的应用的自动化。 例如 UICatalog

进阶操作

APP应用操作
方法 描述 参数
driver.launch_app() 启动desiredCapabilities配置的APP
driver.close_app() 关闭desiredCapabilities配置的APP
driver.reset() 重置应用,删除应用相关数据操作
driver.install_app(app_path,**options) 安装指定app到手机或模拟器中 app_path为app安装包绝对路径。
driver.remove_app(app_id,**options) 卸载app app_id为app包名
driver.is_app_installed(bundle_id) 检查app是否有安装 bundle_id为app包名
driver.activate_app(app_id) 切换app app_id为app包名
driver.background_app(seconds) 后台运行app seconds为运行时长
driver.current_activity 查看当前激活的app的activity名称
driver.start_activity(app_package,app_activity) 激活指定APP app_package为包名,app_activity为当前app的activity名称
import time
from appium.webdriver.webdriver import WebDriver

# Appium 服务器初始化参数
desired_capabilities = {
    "platformName": "Android",  # 系统类型 IOS/Android
    "platformVersion": "7",  # 操作系统版本
    "deviceName": "127.0.0.1:62001",  # 设备类型,设备ID,IOS必须填写
    "appPackage": "com.android.launcher3",  # 要打开的APP包名
    "appActivity": ".launcher3.Launcher"  # APP激活的首屏名称
}

driver = WebDriver("http://127.0.0.1:4723/wd/hub", desired_capabilities)

path = "C:/Users/Administrator/Desktop/code/com.jingdong.app.mall.apk"
app_package = "com.jingdong.app.mall"

print(driver.is_app_installed(app_package))
time.sleep(2)

if not driver.is_app_installed(app_package):
    """如果没有安装,则直接安装"""
    driver.install_app(path)
    print(f"{app_package},已安装")

elif driver.is_app_installed(app_package):
    """如果已经安装,则先卸载再安装"""
    driver.remove_app(app_package)
    print(f"{app_package},已卸载")
    try:
        driver.install_app(path)
        print(f"{app_package},安装成功")
        driver.activate_app(app_package)
        print(f"{app_package},已打开")
        time.sleep(5)

    except Exception:
        print(f"{app_package},安装失败")
    finally:
        driver.quit()



多应用测试
import time
from appium.webdriver.webdriver import WebDriver

# Appium 服务器初始化参数
from selenium.webdriver.common.by import By

desired_capabilities = {
    "platformName": "Android",
    "platformVersion": "7",
    "deviceName": "127.0.0.1:62001",
    "appPackage": "com.android.launcher3",  # 要打开的APP包名
    "appActivity": ".launcher3.Launcher"  # APP激活的首屏名称
}

driver = WebDriver("http://127.0.0.1:4723/wd/hub", desired_capabilities)
apps = {
    "browser": [ # 浏览器
        "com.android.browser",
        ".BrowserActivity"
    ],
    "contacts": [ # 通讯录
        "com.android.contacts",
        ".activities.PeopleActivity"
    ]
}
driver.start_activity(apps["browser"][0], apps["browser"][1])
driver.start_activity(apps["contacts"][0], apps["contacts"][1])
print(driver.current_package)
print(driver.current_activity)
time.sleep(5)

driver.quit()



元素定位

测试开发时,要对一些APP中的元素进行测试操作,都需要会配合元素定位UI工具来获取元素,然后才能在代码中对元素进行操作。

元素定位UI工具

常用的元素定位UI工具,有:uiautomatorviewer、Appium Desktop Inspector、Webditor等工具。

uiautomatorviewer

uiautomatorviewer是Android SDK自带的元素UI定位工具,在我们安装好Android SDK以后,默认就用有的。

image-20220702060835637

双击打开效果如下:

image-20220702060934609

点击以后,效果如下:

image-20220702052703149

注意:uiautomatorviewer会与后面的工具产生冲突,所以使用uiautomatorviewer就不能使用下面2种工具了。

元素的bounds属性表示当前元素在APP Activatity中的距离左上角的坐标。

image-20220702145733482

Appium Desktop Inspector

Appium Desktop Inspector是Appium Desktop官方开发的工具,在早期版本中默认集成进来的,在最新版本中,需要我们手动安装,当然上面我们Appium环境安装完成以后就已经安装过这个Appium Desktop Inspector工具了。Appium Desktop Inspectord的使用必须保证在Appium Server启动状态才可以使用。

Appium Desktop Inspector是基于jsonwp协议进行截屏审查元素的。所以需要创建会话才能获取当前APP的截屏。

{
    "platformName": "Android",
    "platformVersion": "7",
    "deviceName": "127.0.0.1:62001",
    "appPackage": "com.jingdong.app.mall",
    "appActivity": ".MainFrameActivity",
    "noReset": false
}


image-20220702151818186

所以直接打开效果如下:

image-20220702053258377

Webditor

Webditor是python第三方模块UIautomator2( UIautomator2是 Android 内置的开源的自动化测试java框架,仅支持Android平台的原生应用测试,后来发展了python-uiautomator2,封装了谷歌自带的uiautomator测试框架,提供便利的python接口)提供的元素定位UI工具,是比上面2种元素定位UI工具更加强大的辅助工具,不仅支持Android,也支持IOS(不过需要单独安装tidevice)。不过要使用Webditor,需要先在当前python环境中安装python-uiautomator2模块。

# 安装python-uiautomator2模块
pip install uiautomator2

pip install weditor


使用工具,不过模拟器中得有adx软件

python -m uiautomator2 init
python -m weditor


效果如下:

image-20220702062714338

结合上面三种工具,我们可以发现在Android中对元素进行定位,一般可以依靠元素的以下属性进行定位。

属性名 uiautomatorviewer Appium Desktop Inspector Weditor 描述
id resource-id resource-id/id resourceId 资源ID
text text text text 文本
className class class className 标签名
description content-desc content-desc/accessibility id description 资源描述
xpath 不支持 xpath XPathLite xpath路径
元素定位方法

appium的webdriver提供了11种元素定位方法,在selenium的基础上扩展了三个。

方法名 描述
driver.find_element(AppiumBy.ID, "resource-id") 根据资源ID进行定位
driver.find_element(AppiumBy.CLASS_NAME, "className") 通过className标签名获取元素
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("搜索文本")') 根据TEXT文本获取元素
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "content-desc") 根据资源描述获取元素
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector().scrollable(true).instance(0)).scrollIntoView(new UiSelector().textContains("搜索文本").instance(0));') 滚动查找
元素操作

对元素的操作有:

操作 描述
click() 点击
send_keys() 输入文本内容
text() 获取元素的文本内容
get_attribute() 获取元素的指定属性值
import time

from appium.webdriver.common.appiumby import AppiumBy
from appium.webdriver.webdriver import WebDriver

# Appium 服务器初始化参数
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait

desired_capabilities = {
    "platformName": "Android",
    "platformVersion": "7",
    "deviceName": "127.0.0.1:62001",
    "appPackage": "com.jingdong.app.mall",
    "appActivity": ".main.MainActivity"
}
driver = WebDriver("http://127.0.0.1:4723/wd/hub", desired_capabilities)

driver.implicitly_wait(10)

# 通过资源ID获取元素
ele = driver.find_element(AppiumBy.ID, "com.jingdong.app.mall:id/bqd")
print(ele)
# 点击同意协议
ele.click()


# # 通过className标签名获取元素
# ele = driver.find_element(AppiumBy.CLASS_NAME, "android.widget.Button")
# # 点击允许拨打电话
# ele.click()    
#
# # 根据TEXT文本获取元素
# # ele = driver.find_element_by_android_uiautomator('new UiSelector().text("允许")')
# ele = driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("允许")')
# # 点击允许获取位置信息
# ele.click()
#
# # 通过资源ID获取元素
# ele = driver.find_element(AppiumBy.ID, "com.android.packageinstaller:id/permission_allow_button")
# # 点击允许获取设备位置信息
# ele.click()
#
# ele = driver.find_element(AppiumBy.CLASS_NAME, "android.webkit.WebView")
# # 点击允许获取设备位置信息
# ele.click()


# 显式等待,点击搜索框,进入搜索页面
WebDriverWait(driver=driver, timeout=60, poll_frequency=1, ignored_exceptions=None).until(expected_conditions.visibility_of_element_located((AppiumBy.CLASS_NAME, "android.widget.ViewFlipper"))).click()


# 输入搜索内容
ele = driver.find_element(AppiumBy.ID, "com.jd.lib.search.feature:id/a7u")
ele.send_keys("金士顿")

# 根据资源描述查找元素
ele = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "搜索,按钮")
# 点击搜索框
ele.click()


# 滚动查找指定文本,并点击进入
# AppiumBy.ANDROID_UIAUTOMATOR 表示 本次查找元素是使用底层UIAUTOMATOR2框架中的java的UiSelector类提供的方式来查找数据
ele = driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector().scrollable(true).instance(0)).scrollIntoView(new UiSelector().textContains("京东自营").instance(0));')
ele.click()


time.sleep(10)
driver.quit()



客户端测试

自动遍历测试

在前面我们已经学习了移动端UI自动化测试工具Appium。但是即便Appium很强大,但是在今天的互联网发展趋势下,随着开发业务变更快速、业务线众多且流程复杂、甚至被测试的客户端依赖第三方接口较多的问题下,依然会暴露了很多问题,是Appium所无法轻易解决的。例如:

  • UI自动化测试只能覆盖核心业务逻辑,后续开发的新功能不断迭代,客户端根本来不及实现自动化测试
  • 产品业务不断迭代的情况下,测试量不断增大,新版本发布后,客户端老功能来不及全面回归,容易出现漏测情况
  • 随着时间不断推移,不断增加强度的测试工作下,测试人员容易产生疲乏,对细小错误的敏感度下降,或者产生排斥现象
  • 产品的界面深度不断加深,且包含大量的展示信息功能,出现部分页面的内容丢失或数据显示异常等情况。
  • 专项测试回归难度不断加大:内存泄漏、健壮性测试、弱网等测试太多
  • 界面崩溃等

作为测试人员就需要极可能早地避免针对上述情况的出现,因此我们的测试工具就需要把UI自动化用例维护成本降到最低,尽可能全面地覆盖回归业务。此时我们就需要自动遍历测试技术了。

自动遍历测试技术

使用自动遍历测试技术,可以让我们的测试工作使用更少的代码去覆盖更多的回归测试业务。常见的自动遍历工具,除了Android官方提供的Money,app crawler以外,还有百度的smartmonkey、腾讯的newmonkey、字节的zhangzhao maxim、 seveniruby的appcrawler。其中大部分第三方开发的工具都是基于官方的monkey进行二次开发所衍生出来的,而eveniruby的appcrawler则是基于Appium所开发的工具。这两类的工具的使用对比存在以下优缺点:

基于Monkey实现的自动遍历测试工具:不可控,性能好,速度快,不具备跨平台能力。

基于Appium实现的自动遍历测试工具:可控可定制,性能差,速度慢,具备跨平台能力。

Monkey

Monkey就是一个在模拟器或设备上运行的程序,可以自动生成随机用户事件,模拟用户的随机操作。Monkey是Android SDK自动工具,所以我们不需要单独安装直接可以使用。在Money使用过程中支持以下操作:

  1. 基本配置选项,如设置随机事件的数量
  2. App操作约束,可以指定1个或多个App进行遍历测试
  3. 定制事件类型与频率,如可以设置点击事件频率、滑动事件频率、返回页面事件频率等
  4. 提供大量测试选项
命令 描述
adb shell monkey 10000 对所有的App随机进行10000个操作
adb shell monkey -p com.jingdong.app.mall 2000 对指定App进行2000个随机操作
adb shell monkey -p com.jingdong.app.mall -s 50 2000 对指定包进行的随机操作设置随机时间种子,便于回溯操作
adb shell monkey -p com.jingdong.app.mall -vv -s 50 2000 > monkey.log 对指定包进行的随机操作进行详细日志记录
adb shell monkey -p com.jingdong.app.mall -vv -s 50 --throttle 50 2000 对指定包进行的随机操作设置限流操作,让每一个随机操作固定间隔100毫秒
adb shell monkey -p com.jingdong.app.mall -vv -s 50 --throttle 50 --pct-touch 100 2000 设置某种事件类型的触发频率,--pct-touch表示触摸类型事件
adb shell monkey -p com.jingdong.app.mall --pct-motion 80 2000 设置某种事件类型的触发频率,--pct-motion表示滑动类型事件,例如直线滑动
adb shell monkey -p com.jingdong.app.mall --pct-trackball 80 2000 设置某种事件类型的触发频率,--pct-trackball表示轨迹类型事件,例如移动点击、曲线滑动
adb shell monkey -p com.jingdong.app.mall --pct-majornav 80 2000 设置某种事件类型的触发频率,--pct-majornav表示导航事件类型,例如回退页面、菜单按钮操作等

appcrawler

是国人seveniruby(思寒)基于appium开发的开源的多平台自动遍历工具,支持Android和IOS,支持真机和模拟器,灵活性高,可以通过yaml配置文件定制自动遍历规则的工具,还提供截图以及HTML测试报告。

GitHub地址:https://github.com/seveniruby/AppCrawler

快速开始

appcrawler的使用提供了2种模式,终端参数模式与配置文件模式。终端参数模式比配置文件模式优先级高。

注意:appcrawler是基于Appium开发出来的,所以使用的时候,务必保证Appium Server是启动状态。

终端参数模式
java -jar appcrawler-2.7.4-hogwarts.jar --capability "appPackage=com.jingdong.app.mall,appActivity=.main.MainActivity"


配置文件模式

生成配置文件

java -jar appcrawler-2.7.4-hogwarts.jar --demo


注意:在window下生成的配置文件,默认编写格式是GBK编码,而appcrawler的运行需要基于utf-8模式,所以务必在执行之前进行编码转换。

image-20220716122854061

基于配置文件模型来启动appcrawler

java -jar appcrawler-2.7.4-hogwarts.jar -c demo.yaml


重要配置选项

执行参数 描述
capability 与appium一致
testcase 配置启动app以后的基本测试用例,用于遍历开始之前的一些前置操作,比如自动登录
selectedList 设置自动遍历范围,只有出现在这个列表里的控件范围才会被遍历,默认是全局范围遍历
firstList 优先遍历
lastList 最后遍历
triggerActions 设置特定条件下触发执行的动作,在遍历过程中需要随时处理的一些操作,比如弹框、登录等
tagLimitMax 相似控件(同一类型或同一个祖先的空间)最多点击几次
backButton 后退按钮列表,默认在所有控件遍历完成后,才会最后点击后退按钮。目前具备了自动判断返回按钮的能力,默认不需要配置
maxDepth 最大遍历深度,默认为10层。
testcase:
  name: "AppCrawler TestCase"
  steps:
  - given: []   # 所有的前置条件
    when: null   # 前置条件满足后的行为
    then: []   # 断言集合
    xpath: "//*[text='酒水饮料']"   # 查找xpath路径
    action: "click"  # 行为
    actions: []  # 动作链
    times: -1    # 次数


demo.yaml,代码:

---
capabilityDescription: "appium的capability通用配置,其中automationName代表自动化的驱动引擎,除了支持appium的\
  所有引擎外,额外增加了adb和selenium的支持"
capability:
  appium: "http://127.0.0.1:4723/wd/hub"
  appPackage: "com.ss.android.article.lite"
  appActivity: ".activity.SplashActivity"
  noReset: "true"
  fullReset: "false"
  dontStopAppOnReset: "false"
waitAppLoadedTimeoutDescription: "隐式等待app加载完成的最大时间 ms"
waitAppLoadedTimeout: 10000
waitAppLoadedDescription: "显式等待app加载完成的判断条件"
waitAppLoaded:
- given: []
  when: null
  then: []
  xpath: "//*[contains(@text, 'App')]"
  action: ""
  actions: []
  times: -1
implicitlyWaitTestCaseDescription: "在测试用例执行阶段隐式等待一个控件出现的最大时间 ms"
implicitlyWaitTestCase: 3000
implicitlyWaitCrawlDescription: "在遍历阶段隐式等待一个控件出现的最大时间 ms"
implicitlyWaitCrawl: 0
testcaseDescription: "测试用例设置,用于遍历开始之前的一些前置操作,比如自动登录"
testcase:
  name: "AppCrawler TestCase"
  steps:
  - xpath: "//*[@resource-id='com.ss.android.article.lite:id/b_'][1]"
    action: "click"
maxTimeDescription: "最大运行时间"
maxTime: 10800
maxDepthDescription: "默认的最大深度10, 结合baseUrl可很好的控制遍历的范围"
maxDepth: 10
selectedListDescription: "默认遍历列表,只有出现在这个列表里的控件范围才会被遍历"
selectedList:
- given: []
  when: null
  then: []
  xpath: "//*[@resource-id='com.ss.android.article.lite:id/b06']//*[@clickable='true']"
  action: ""
  actions: []
  times: -1
triggerActionsDescription: "在遍历过程中需要随时处理的一些操作,比如弹框、登录等"
triggerActions:
- given: []
  when: null
  then: []
  xpath: "permission_allow_button"
  action: ""
  actions: []
  times: 3
- given: []
  when: null
  then: []
  xpath: "允许"
  action: ""
  actions: []
  times: 3
blackListDescription: "黑名单列表 matches风格, 默认排除内容包含2个数字的控件"
blackList:
- given: []
  when: null
  then: []
  xpath: ".*[0-9]{2}.*"
  action: ""
  actions: []
  times: -1
- given: []
  when: null
  then: []
  xpath: "Get Music"
  action: ""
  actions: []
  times: -1
firstListDescription: "优先遍历列表,同时出现在selectedList与firstList中的控件会被优先遍历"
firstList: []
lastListDescription: "最后遍历列表,同时出现在selectedList与lastList中的控件会被最后遍历"
lastList:
- given: []
  when: null
  then: []
  xpath: "//*[@selected='true']/..//*"
  action: ""
  actions: []
  times: -1
- given: []
  when: null
  then: []
  xpath: "//*[@selected='true']/../..//*"
  action: ""
  actions: []
  times: -1
backButtonDescription: "后退按钮列表,默认在所有控件遍历完成后,才会最后点击后退按钮。目前具备了自动判断返回按钮的能力,默认不需要配置"
backButton:
- given: []
  when: null
  then: []
  xpath: "Navigate up"
  action: ""
  actions: []
  times: -1
xpathAttributesDescription: "在生成一个控件的唯一定位符中应该包含的关键属性"
xpathAttributes:
- "name()"
- "name"
- "label"
- "value"
- "resource-id"
- "content-desc"
- "text"
- "id"
- "name"
- "innerText"
- "tag"
- "class"
sortByAttributeDescription: "陆续根据属性进行遍历排序微调,depth表示从dom中最深层的控件开始遍历,list表示dom中列表优先,\
  selected表示菜单最后遍历,这是默认规则,一般不需要改变"
sortByAttribute:
- "depth"
- "list"
- "selected"
findByDescription: "默认生成控件唯一定位符的表达式风格,可选项 default|android|id|xpath,默认会自动判断是否使用android定\
  位或者ios定位"
findBy: "xpath"
baseUrlDescription: "设置一个起始点,从这个起始点开始计算深度,比如默认从登录后的界面开始计算"
baseUrl: []
appWhiteListDescription: "app白名单,允许在这些app里进行遍历"
appWhiteList: []
urlBlackListDescription: "url黑名单,用于排除某些页面的遍历"
urlBlackList: []
urlWhiteListDescription: "url白名单,仅在这些界面内遍历"
urlWhiteList: []
beforeRestartDescription: "在重启session之前做的事情"
beforeRestart: []
beforeElementDescription: "在遍历每个控件之前默认执行的动作"
beforeElement: []
afterElementDescription: "在遍历每个控件之后默认执行的动作"
afterElement: []
afterElementWaitDescription: "在遍历每个控件之后默认等待的时间,用于等待新页面加载"
afterElementWait: 1000
afterAllDescription: "在遍历完当前页面内的所有控件后,是否需要刷新或者滑动"
afterAll: []
afterAllMaxDescription: "afterAll的最大重试次数,比如连续滑动2次都没新元素即取消"
afterAllMax: 2
tagLimitMaxDescription: "相似控件最多点击几次"
tagLimitMax: 20
tagAttributesDescription: "用于判断是否是相似控件的关键属性,祖先节点具备相同的属性认为是相似"
tagAttributes:
- "name()"
- "name"
- "label"
- "resource-id"
- "content-desc"
- "id"
- "name"
- "tag"
- "class"
tagLimitDescription: "设置部分相似控件的最大遍历次数"
tagLimit:
- given: []
  when: null
  then: []
  xpath: "确定"
  action: ""
  actions: []
  times: 1000
- given: []
  when: null
  then: []
  xpath: "取消"
  action: ""
  actions: []
  times: 1000
- given: []
  when: null
  then: []
  xpath: "share_comment_guide_btn_name"
  action: ""
  actions: []
  times: 1000
- given: []
  when: null
  then: []
  xpath: "//*[contains(@class, 'List')]//*"
  action: ""
  actions: []
  times: 2
assertGlobalDescription: "全局断言"
assertGlobal: []
suiteNameDescription: "报告中的测试套件名字可以由列表内的控件内容替换,增强报告中关键界面的辨识度"
suiteName:
- "//*[@selected='true']//android.widget.TextView/@text"
screenshotDescription: "是否截图"
screenshot: true
reportTitleDescription: "报告的title"
reportTitle: "AppCrawler"
resultDirDescription: "结果目录,如果为空会自动创建对应时间戳_报名的结果目录"
resultDir: ""
showCancelDescription: "是否展示跳过的控件记录"
showCancel: true
pluginListDescription: "插件列表,暂时禁用,太高级了,很多人不会用"
Description: "。在selectedList firstList lastList等很多配置中,需要填充的是测试步骤Step类型。Step类型由given(\
  满足条件)when(条件满足的行为)then(断言)三部分组成。Step可以简化为xpath(定位表达式,支持xpath 正则 包含关系)与action(点击\
  \ 输入等行为)。"
pluginList: []



跨平台设备管理方案

selenium grid

Selenium Grid是Selenium套件的一部分,最新版本是Grid2.0。它专门用于并行运行多个测试用例在不同的浏览器、操作系统和机器上,也就说当我们作为测试开发需要同时在不同的浏览器、操作系统和机器上运行测试用例时就可以使用过它实现分布式执行测试用例

Grid的基本使用架构分:Hub(一个集线器服务端)与Node(1个或多个分布式子节点)

image-20220716140217711

安装与使用

Selenium Grid 下载地址:https://www.selenium.dev/downloads/

image-20220716140441613

快速入门

官方文档:https://www.selenium.dev/documentation/grid/getting_started/

因为Selenium Grid是基于Selenium项目的,所以安装使用时必须保证当前运行环境中已经提前安装了selenium,同时还要保证webddriver要么已经配置到当前系统环境的环境变量中,要么就与接下来下载的Selenium Grid 的jar包在同一个目录。

Selenium Grid提供了2种模式使用:单点模式与分布式。

其中单点模式,Selenium Grid提供的一种让hub(集线器服务端)与node(分布式节点)在同一个服务器下测试使用的最简单配置模式。而分布式,则是允许hub节点与node节点不在同一个服务器下测试使用的配置方式。

单点模式
java -jar selenium-server-4.3.0.jar standalone  # 相当于同时启动了1个hub与1个node节点


image-20220716150544800

访问selenium的控制界面:http://192.168.233.1:4444/ui

访问selenium的状态信息:http://192.168.233.1:4444/status

image-20220716150746905

分布式模式

分布式的前提是,必须hub服务端所在的操作系统与node分布式节点所在操作系统属于同一个网络,也就是相互之间可以访问。

启动Hub节点

java -jar selenium-server-4.3.0.jar hub  # 启动bub节点,默认端口:4444
java -jar selenium-server-4.3.0.jar hub   --port 4455 # --port 指定端口


启动Node节点

java -jar selenium-server-4.3.0.jar node #启动node节点,默认端口是5555
java -jar selenium-server-4.3.0.jar node --port 5566  # --port 指定端口
java -jar selenium-server-4.3.0.jar node --port 5566  --hub http://192.168.233.1:4444   # --hub 指定远程的hub集线器服务端


在hub接线器服务端中使用python代码操作控制node节点系统环境下的所有客户端(浏览器)

from selenium import webdriver
chrome_options = webdriver.ChromeOptions()
driver = webdriver.Remote(command_executor='http://localhost:4444', options=chrome_options)
driver.get("https://www.baidu.com")


image-20220716155647944

基于配置文件启动node节点

java -jar selenium-server-4.3.0.jar node --config config.toml


[node]
port = 5566
hub=192.168.233.1:4444


OpenSTF

OpenSTF(Smartphone Test Farm)是一个web端移动设备管理平台,可以让测试开发人员从浏览器端进行远程调试、管理移动端设备。

github地址:https://github.com/openstf/stf

Super short screencast showing usage

安装使用

openstf支持原生安装,也支持docker安装。

mac os安装如下:

brew install rethinkdb graphicsmagick zeromq protobuf yasm pkg-config


这里我们基于vmware安装的ubuntu操作系统下使用docker安装。

ubuntu20.04镜像下载地址:

链接:https://pan.baidu.com/s/19qZ2-vtMbz4amIJG3KNPcQ?pwd=40nl 
提取码:40nl 
复制这段内容后打开百度网盘手机App,操作更方便哦


image-20220716204648280

vmware运行了网盘下载的ubuntu20.04镜像以后,在ubuntu终端下,直接运行如下命令:
alias docker=podman


拉取镜像

docker hub:https://registry.hub.docker.com/u/openstf

docker pull openstf/stf:latest
# docker pull sorccu/adb:latest
docker pull rethinkdb:latest


启动镜像

# 务必先启动rethinkdb容器,/home/moluo表示当前用户家目录
mkdir -p /home/moluo/stf/rethinkdb
docker run -d --name rethinkdb --privileged -v /home/moluo/stf/rethinkdb:/data --net host rethinkdb rethinkdb --bind all --cache-size 8192 --http-port 8090
docker ps # 查看上面的rethinkdb容器是否启动了

# 如果前面没有拉取adb镜像则跳过,否则启动adb容器,/home/moluo表示当前用户家目录
# mkdir -p /home/moluo/stf/dev/bus/usb
# docker run -d --name adbd --privileged -v /home/moluo/stf/dev/bus/usb:/dev/bus/usb --net host sorccu/adb:latest

# 最后才启动stf容器,
#     --public-ip的值是当前主机的IP地址,可以通过 ip a来查看,如果stf安装在外网,则--public-ip则是服务器的公网地址
#    --adb-host 与 --adb-port 则是adb工具所在的IP地址,如果stf在外网,而adb在内网,则需要使用内网穿透工具使用内网穿透工具提供的IP地址与端口
#    --allow-remote  表示允许远程调试终端设备,如果没有加上则只能在当前主机下调试终端设备
# 如果在本机ubuntu下连接设备进行调试,则使用下面一句命令,启动stf,否则使用最后使用一句,指定adb工具的所在IP地址
# docker run -d --name stf --net host openstf/stf stf local --public-ip 192.168.233.129
docker run -d --name stf --privileged --net host openstf/stf stf local --public-ip 192.168.233.129 --adb-host 192.168.233.1 --adb-port 5037 --allow-remote

# 查看是否正常启动
docker ps 


如果上面操作无误,则可以通过ubuntu提供的地址http://127.0.0.1:7100直接访问stf的控制面板

接入远程移动端设备

# 先重启adb服务
adb kill-server
# adb版本 < 1.0.32开启命令,可以通过 adb version来查看
# adb -a -P 5037 fork-server server
# adb版本 > 1.0.32开启命令:
adb nodaemon server -a -P 5037

# 打开移动端设置的开发者选项,并设置开启USB调试
# 开启adb远程调试功能,设置就是 adb devices的第一列值
# adb -s <设备ID>  tcpip 5555
# 例如,我的夜神模拟器,
adb -s  127.0.0.1:62001  tcpip 5555

# 管理真机设备
# 连接真机设备的条件:
# 1. 使用完好无损的USB数据线,把手机连接到adb所在的电脑中。
# 2. 如果没有数据线,则可以通过wifi让手机与adb所在的电脑处于同一个网络中,使用IP连接调试
# 3. 如果满足条件1或条件2,则需要打开当前真机设备的开发人员选项,并开启USB调试。
# 完成上面的条件3以后,使用adb给物理真机开启5555远程调试端口
adb -s  UJN0221722001562  tcpip 5555

# 重启ubuntu中的srf容器以后,重新访问 http://192.168.233.129:7100
docker restart stf

# 如果访问stf站点没有显示移动端设备,则断开移动端设备或者关闭重启安卓模拟器,再接着重新执行上面的adb kill-server 往后的操作。


专项测试

app启动性能分析

APP启动性能指标

性能选项 描述 耗时
冷启动 APP在手机启动后第一次运行,或者APP进程被kill掉后在再次启动。 5 秒
温启动 App进程存在,当时Activity可能因为内存不足被回收。
这时候启动App不需要重新创建进程,但是Activity的onCreate还是需要重新执行的
2 秒
热启动 App进程存在,并且Activity对象仍然存在内存中没有被回收 1.5 秒

image-20220723094545877

冷启动运行流程

冷启动通常会发生在以下两种情况:

  • 设备启动以来首次启动应用程序
  • 系统杀死应用程序之后再次启动应用程序

在冷启动的最开始,系统需要负责做三件事:

  1. 加载以及启动app
  2. app启动之后立刻显示一个空白的预览窗口
  3. 创建app进程

一旦系统完成创建app进程后,app进程将要接着负责完成下面的工作:

  1. 创建Application对象
  2. 创建并且启动主线程MainThread
  3. 创建启动第一个Activity
  4. Inflating views
  5. 布局屏幕
  6. 执行第一次绘制

一旦app进程完完成了第一次绘制工作,系统进程就会用main activity替换前面显示的预览窗口,这个时候,用户就可以正式开始与app进行交互了。

image-20220723073456329

常用性能分析工具

  1. adb logcat:adb内置的性能分析工具,但是这个工具仅仅了App启动过程中的冷启动的总体时间,而且这个事件并不够准确。
  2. adb录屏+ffmpeg视频拆帧:较准确,需要人工操作,随着视频的时长增加,耗时也会增加。
  3. uiautomator:自动化工具,进行 200ms 巡检界面变化
  4. traceview:Android内置的分析工具
  5. 代码埋点:最准确,而且覆盖度更广,但是需要修改代码,要开发人员设置埋点代码

adb logcat

​ 例如测试今日头条App的启动性能,使用步骤:

命令 描述
adb shell pm clear com.ss.android.article.lite 清理app缓存数据
adb shell am force-stop com.ss.android.article.lite 停止application进程
adb shell am start -S -W com.ss.android.article.lite/.activity.SplashActivity 启动 APP
adb logcat | grep -i displayed 获取日志数据
C:\Users\Administrator>adb shell am start -S -W com.ss.android.article.lite/.activity.SplashActivity
Stopping: com.ss.android.article.lite
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.ss.android.article.lite/.activity.SplashActivity }
Status: ok
Activity: com.ss.android.article.lite/.activity.SplashActivity
ThisTime: 1617
TotalTime: 1617
WaitTime: 1619
Complete

TotalTime代表当前Activity启动总时间(毫秒)

使用 ffmpeg 拆帧

ffmpeg是一个第三方开源的视频编辑工具,下载工具:http://ffmpeg.org/download.html

下载压缩包回到本地目录下(不要出现中文目录!!),解压并把内部的bin目录设置为环境变量。

使用步骤:

命令 描述
adb shell pm clear com.jingdong.app.mall 清除app应用缓存
adb shell am force-stop com.jingdong.app.mall 停止application进程
adb shell screenrecord --bugreport --time-limit 30 /data/cache/jd.mp4 & 进行30秒录屏,并存放到手机的指定地址
adb shell am start -S -W com.jingdong.app.mall/.MainFrameActivity 启动APP,跳转到指定界面,这个步骤也可以手动完成
adb pull /data/cache/jd.mp4 . 把录屏文件从手机拉取到电脑
ffmpeg -i jd.mp4 -r 10 frames_%04d.jpg 将视频拆帧,每秒拆成10份,存储为jpg格式图片

再通过逐帧分析图片,找到点击的时间以及打开指定界面的时间,从而推算相对比较准确的启动时间。

补充:

adb提供的录屏工具,除了录制app启动过程以外,实际在测试过程中也可以用于记录bug现场或者场景测试过程的。

webview性能分析

目前在市面上的App无非三种:原生App(IOS、Android),混合App(H5+安卓外壳程序、H5+IOS外壳程序)、H5。

混合App中能运行使用H5网页,全依靠webview视口。

chrome浏览器自带的webview调试工具:chrome://inspect/。注意:连接的模拟器或真机必须是打开开发者选项并开启USB调试,同时chrome浏览器尽量采用高版本(>=89),

手机连接成功以后,直接打开chrome浏览器直接访问chrome://inspect/调试工具界面,然后在手机中打开一个网页或打开混合APP,点击inspect进入webview调试界面(这个界面需要FQ。)

image-20220723085257843

点击打开network选项,即可查看webview中页面元素加载的性能参数。

image-20220723085808732

image-20220723085831437

性能指标 描述
Queueing 当前资源在等待队列中的等待时间
Stalled 资源损耗废弃的时间
Request sent 发送请求到服务端的时间
Waiting (TTFB) 资源加载时间
Content Download 内容下载时间

H5性能分析

借用W3C协议提供的性能参数来完成自动化H5性能测试。因为W3C标准是浏览器标准,市面上所有的浏览器都支持W3C标准,它规定使用者可以通过api查询性能信息。

img

指标 描述
Prompt for unload 访问一个新页面时,旧页面卸载完成的时间
redirect 重定向,用户注销登陆时返回主页面和跳转到其它的网站等
App cache 检查缓存,是否打开
DNS 表示 DNS 查询的时间,如果是长连接或者请求文件来自缓存等本地存储则返回fetchStart时间点
TCP 与服务器建立链接的时间
Requests 客户端发起请求的时间
Response 拿到服务器第一个响应字节到最后一个响应字节的时间
Processing 各种状态的时间点,例如加载状态等等
onLoad 触发load事件执行的时间

在chrome浏览器中,执行js代码可获取各个资源加载阶段的性能损耗时间(单位是:毫秒):

window.performance.timing

借用appium/selenium提供execute_script方法都可以执行js代码实现自动化获取性能指标,如下代码:

import time
from selenium import webdriver
from selenium.webdriver import ChromeOptions
from selenium.webdriver.chrome.service import Service


option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])  # 规避网站监测
option.add_experimental_option('useAutomationExtension', False)  # 去掉开发者警告

driver = webdriver.Chrome(executable_path="chromedriver.exe", options=option)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {'source':'Object.defineProperty(navigator,"webdriver",{get:()=>undefind})'}) # 规避网站监测

driver.get("https://www.baidu.com")

# 显示所有阶段的时间点
result = driver.execute_script("return JSON.stringify(window.performance.timing)")
print(result)

# 显示指定资源的性能时间,比如img
result = driver.execute_script('return JSON.stringify(window.performance.getEntriesByName(document.querySelector("img").src)[0], null, 2)')
print(result)

time.sleep(5)
driver.quit()

兼容性测试

使用自动遍历测试技术(例如:monkey或者appcrawler)在各个不同型号或不同版本的手机上对app进行使用测试并生成报告即可。

adb shell monkey -p com.ss.android.article.lite -vv -s 50 2000 > monkey.log

卡顿分析

可以使用systrace工具来进行卡顿分析。Systrace是一个Android平台提供的工具,可以记录设备活动。它将来自Android内核的数据(例如CPU调度程序,磁盘活动和应用程序线程)组合起来生成一个报告。报告提供了Android设备在给定时间段内的系统进程的总体情况。该报告还检查捕获的跟踪信息,以突出显示它所观察到的问题,例如UI 卡顿或高功耗等。

img

环境安装

systrace在我们安装了Android SDK开发环境以后,默认内置在platform-tools/systrace子目录下,所以不需要单独安装systrace,但是要使用systrace工具则必须清楚systrace仅支持python2,不支持python3

image-20220731104157465

所以需要在本地开发机子上安装python2.7解释器,并保证python2.7的安装根目录以及scripts子目录被设置为系统环境变量即可。

image-20220731103813485

image-20220731103712563

安装相关依赖

python27 -m pip install pywin32
python27 -m pip install six

基本使用

# 切换终端目录到stytrace.py所在目录下
cd C:\tool\android-sdk-windows\platform-tools\systrace
# 保证adb已经连接上了移动端【模拟器或者真机】
# 以下命令可以列出当前systrace.py针对目标移动端的性能参数以及收录系统信息
python27 systrace.py -e 127.0.0.1:62001 -l

# 以下命令可以在指定时间内收录移动端设备的性能参数,并可以通过-o 指定输出html报告。
python27 systrace.py -e UJN0221722001562 -o systrace.html

运行命令开始收录指定移动端设备的性能参数。

image-20220731104908232

上面提示,已经开始收录,此时我们可以通过手动操作指定移动端的App或者通过自动遍历工具来进行操作App来完成使用过程中移动端的相关性能参数的变化。这个录制过程时间不能太短,一般建议在半个小时左右或以上。

例如,我们可以使用money来录制参数

adb shell monkey -p com.ss.android.article.lite --throttle 1000 2000
# 使用真机打开pingduoduo
adb -s UJN0221722001562 shell monkey -p com.xunmeng.pinduoduo  --throttle 1000 5000

完成性能参数的收录以后,可以将html报告上传到perfetto.dev站点chrome://tracing/中解析报告即可。

系统资源分析

UI资源加载性能分析

可以通过GPU绘制柱状图来查看App系统资源加载性能,通过手机的开发者选项->监控->GPU呈现模式分析->在屏幕上显示为条形图。

image-20220723154902706

image-20220731114628420

image-20220723155327802

从上图柱状图可以看到,从左往右统计并显示了app最近运行的128帧页面中的资源加载情况。每一个柱子就代表了一帧(一帧就是一个画面),其中从下往上不同颜色代表的就是当前帧中的UI资源加载的不同阶段,而柱子越长则代表绘制当前帧的时间越长。

颜色 描述
墨绿色 代表在连续两帧页面中的时间间隔
深绿色 代表app在用户输入事件回调中损耗的时间
绿色 代表了当前帧中所有动画属性计算损耗的时间
淡绿色 代表了当前帧中onLayout与onMeasure方法消耗的总时间
深蓝色 代表了当前帧中创建或更新DisplayList的时间
浅蓝色 代表了当前帧中加载图形的损耗时间
红色 代表了当前中所有DisplayList绘制的总时间
橙色 代表了CPU等待GPU渲染完成的总工作时间

内存资源使用性能分析

adb shell dumpsys procstats --hours 2

打印信息格式说明:

ProcessName /  User / VersionCode
Status:(MinPSS-AvgPSS-MaxPSS/MinUSS-AvgUSS-MaxUSS over samples)

Status 描述
百分比 表示在统计时间内,进程在各种状态下的损耗
例如:100%,表示在统计时间内,该进程一直处于运行状态下。
TOTAL 表示在统计时间内,进程的综合占用情况。
Imp Fg 表示在统计时间内,进程加载到前台的占用时间比例
Service 表示当前进程是否是服务。
Persistent 表示当前进程是否一直驻留在内存当中。
Top 表示当前进程是否是顶层进程。
Receiver 表示当前时间是否是广播进程。

网络流量使用分析

# 1. 找到应用UID
adb -s UJN0221722001562 shell dumpsys package com.xunmeng.pinduoduo | grep userId

# 2. 根据应用UID查找对应应用进程的网络流量数据【目前该命令仅支持真机环境下使用,不支持模拟器】
adb -s UJN0221722001562 shell dumpsys netstats  | grep 10253  # 10253就是上面得到的用户ID

# 3. 结果如下:
# st=1502421960  rb=10120 rp=64 tb=5020 tp=19 op=0
# rb与rp 表示在一定时间间隔内进程接收的字节数与数据包数量
# tb与tp 表示在一定时间间隔内进程发送的字节数与数据包数量

耗电量测试

Battery Historain是谷歌开发的Android耗电量分析工具,可以结合adb生成的耗电量日志来进行测试耗电量分析。

清理耗电量数据

adb -s UJN0221722001562 shell dumpsys batterystats --reset
adb -s UJN0221722001562 shell dumpsys batterystats --enable full-wake-history

运行测试用例或手动操作测试功能,并收集耗电量数据

adb  -s UJN0221722001562 bugreport bugreport.zip   # Androd 

得到报告以后上传到battery-historian的耗电量分析工具上即可。

编译安装并运行battery-historian项目

因为battery-historian项目是基于go语言开发的,务必在本地安装好go(>=1.8.1),Android SDK与Python2.7等开发环境。

同时battery-historian并不支持手机模拟器的耗电量数据,所以我们也必须在真机环境下才能进行耗电量测试。

Github地址:https://github.com/google/battery-historian

Mac安装文档:https://www.cnblogs.com/xiaxveliang/p/14869914.html

这里我们直接使用第三方搭建好的battery-historian项目在外网:https://bathist.ef.lc/。

上传报告:*

image-20220723191139037

耗电量报告效果如下:

image-20220723191109517

弱网测试

弱网测试主要是为了测试移动端App在网络较差的极端情况下(如电梯、地下室等封闭环境)的表现。一般我们会认为如果网络速度低于3G网络就是弱网环境了。

在开发测试过程中,我们可以使用charles、fiddler、NEWT、Atc网络代理软件或浏览器开发者工具F12来模拟弱网环境。

网络条件 上行速率(kbps) 下行速率(kbps)
弱网 10 30
2G 15~20 50
3G 300~350 2000~3000
4G 40000 80000

弱网条件测试点:

测试点 描述
丢包 请求或者响应被丢弃
错包 包数据不完整
乱包 包的顺序是乱的,先出去的包不保证先回来
高延迟 响应数据回来时间长,甚至大于客户端等待时间
网络断断续续 网络经常断开又连接,类似于出入电梯,高铁或地铁停留或行驶

浏览器开发者工具F12

image-20220731090137353

点击Add...

image-20220731144326156

charles

Charles 是在 PC 端常用的网络封包截取工具,在做移动开发时,我们为了调试与服务器端的网络通讯协议,常常需要截取网络封包来分析。除了在做移动开发中调试端口外,Charles 也可以用于分析第三方应用的通讯协议。配合 Charles 的 SSL 功能,Charles 还可以分析 Https 协议。Charles 通过将自己设置成系统的网络访问代理服务器,使得所有的网络访问请求都通过它来完成,从而实现了网络封包的截取和分析。

下载地址:https://www.charlesproxy.com/latest-release/download.do

image-20220731090237847

开启弱网

image-20220731090304754

弱网配置

image-20220731090354527

App弱网设置

安装charles证书

image-20220731145229111

image-20220731093405220

image-20220731093454257

image-20220731145353016

image-20220731093626814

保证开启了charles的网络代理

image-20220731091237059

image-20220731091310823

打开本地网络代理设置。

image-20220731091350630

打开手机网络代理->设置->WLAN->在可用WLAN中找到当前手机已经连接到电脑中的WLAN,长按连接的WLAN,并选择修改网络->在高级选项中设置代理选项为手动,然后填写charles所在电脑的IP地址为代理服务器主机名,端口为8888即可。如果是真机的弱网测试,务必要保证手机与chalers所在的电脑在同一个网络。

image-20220731091944033

image-20220731091917064

image-20220731092015392

填写当前电脑IP地址,一般是本机以太网地址。

image-20220731094225304

手机首次通过配置连接到charles代理服务端时会出现以下弹出框,直接点击Allow即可。

image-20220731094135590

模拟器下会反复出现以下警告,直接不断点击继续即可。

image-20220731093923098

有了弱网环境以后,就可以使用自动遍历测试工具,如Monkey,allcrawler进行弱网测试并记录测试的过程了。

健壮性测试

健壮性测试用于测试App系统在出现故障时,是否能够自动恢复或者忽视故障继续运行。

我们可以设置App在无网络或使用上面的charles工具模拟弱网环境,然后结合之前学习过的money或appcrawler等自动遍历工具来测试App在当前极端环境下是否会因为某些功能,或某些数据、或某些模块出现异常而导致App出现崩溃或停滞现象。

例如,使用monkey在弱网下间隔5秒执行一个事件,运行50个事件测试App的健壮性。

adb shell monkey -p com.ss.android.article.lite  --throttle 5000 1000

HttpRunner

HttpRunner 是一款面向 HTTP(S) 与websocket协议的通用测试框架,只需编写维护一份 YAML/JSON的用例脚本,即可实现自动化测试、性能测试、线上监控、持续集成等多种测试需求。

特点:

  • 继承 Requests 的全部特性,轻松实现 HTTP(S) 的各种测试需求,同时支持ws协议。
  • 采用多种形式的用例描述测试场景(支持 YAML/JSON 、也支持脚本用例,pytest/gotest),保障测试用例描述的统一性和可维护性
  • 借助辅助函数(debugtalk.py),在测试脚本中轻松实现复杂的动态计算逻辑
  • 支持完善的测试用例分层机制,充分实现测试用例的复用
  • 测试前后支持完善的 hook (钩子)机制
  • 响应结果支持丰富的校验机制
  • 基于 HAR 实现接口录制和用例生成功能(har2case子命令)
  • 结合 Locust 框架,无需额外的工作即可实现分布式性能测试
  • 执行方式采用 CLI 调用,可与 Jenkins 等持续集成工具完美结合
  • 测试结果统计报告简洁清晰,附带详尽统计信息和日志记录
  • 极强的可扩展性,轻松实现二次开发和 Web 平台化,主流框架均可集成、junit、testNG、unittest、pytest、request、Devops

HttpRunner目前最新的版本是v4版本,其中HttpRunner v4 相比于之前的 v1/v2/v3/hrp 版本进行叠加封装,同时采用了 Golang/Python 两种编程语言,底层会有两套相对独立的执行引擎,目标是兼具 Golang 的高性能和 pytest 的丰富生态。

hrp就是HttpRunner+(HttpRunnerPlus)。

版本 v1 v2 v3 HttpRunner+ v4
发布时间 2018.3.7 2019.1.1 2020.03.10 2021.11.18 2022.5.5
开发语言 Python Python Python Golang Golang + Python
版本号规范(semver)
网络协议 HTTP(S)/1.1 HTTP(S)/1.1 HTTP(S)/1.1 HTTP(S)/1.1 多协议 HTTP(S)/HTTP2/WebSocket/TCP/RPC
脚本转换工具 HAR HAR HAR HAR HAR/Postman/Swagger/Curl
cli工程脚⼿架
测试⽤例(集)格式 v1 v2 v2 v2 v2
测试⽤例分层机制 v1 v2 v2 v2 v2
脚本格式类型 YAML/JSON YAML/JSON YAML/JSON/pytest YAML/JSON YAML/JSON/pytest/gotest
脚本格式校验 jsonschema TODO
脚本编写语法提示 pytest 链式调用 gotest 链式调用 gotest 链式调用 + pytest 链式调用
脚本执行引擎 Python unittest Python unittest Python pytest Go 自研 Go 自研 + Python pytest
插件化语言(debugtalk.py) Python Python Python 多语言(Go/Python) 多语言(Go/Python/Java/etc.)
参数提取机制 regex + 点分隔符 jmespath + regex + 点分隔符 jmespath jmespath + regex jmespath + regex
skip 机制 TODO
接口测试报告 html 自研(jinja2) html 自研(jinja2) pytest-html/allure html 自研(Go template) html 自研(Go template)+ pytest-html/allure
性能测试引擎 Python Locust Python Locust Python Locust Go Boomer Go Boomer
运行环境依赖 Python 2.7/3.3+ Python 2.7/3.5+ Python 3.7+ pytest 无需依赖 Go 引擎无需依赖pytest 引擎依赖 Python 3.7+
网络性能采集
安装部署方式 pip pip pip curl/wget curl/wget

开发文档:https://httprunner.com/docs/

基本安装

windows

下载地址:https://github.com/httprunner/httprunner/releases

下载完成以后解压放到本地目录下,并给该目录配置hrp服务的环境变量,我的电脑——属性——高级系统设置——环境变量,在PATH下新增系统变量。

Linux/mac

bash -c "$(curl -ksSL https://httprunner.com/script/install.sh)"

查看版本

hrp -v

查看帮助

hrp -h

快速上手

创建项目

hrp startproject hr_demo

目录结构

├─ har/             测试用例脚本转换工具(把har录制脚本转换成测试用例)
├─ reports/       报告存放目录
├─ testcases/    用例存放目录,
                          1个文件就是一个测试用例,支持2种形态的测试用例脚本:文本形态(JSON、YAML)与代码形态(pytest 和 go test)
                          1个测试用例下基本涵盖两个:config【当前测试用例的全局配置】与teststeps【测试用例的测试步骤】
├─ .env             本地项目的环境配置参数,不会被git提交到代码仓库
├─ .gitignore    git代码管理的忽视内容文件
├─ debugtalk.py   插件函数存储文件
                              如果扩展名是py,则表示当前HttpRunner项目采用pytest来驱动,
                              如果扩展名是go,则表示当前HttpRunner项目采用gotest来驱动,
└─ proj.json     项目描述配置文件,用于生成html测试报告

运行测试用例

# 运行测试用例
hrp run testcases/www.luffycity.yml --gen-html-report
# 也可以一次性运行多个测试用例
hrp run testcases/requests.yml testcases/ref_testcase.yml --gen-html-report

config:
    name: "接口测试-测试用例"
    verify: False
    base_url: https://api.luffycity.com/api/v1
teststeps:
-
    name: 请求免费课
    request:
        method: GET
        url: $base_url/course/free/
    validate:
        - eq: ["status_code", 200]
        - eq: ["body.code", 0]
        - length_greater_or_equals: ["body.data", 1]


断言函数:https://httprunner.com/docs/user-guide/enhance-tests/validate/#内置结果校验函数

在reports目录下可以查看测试报告:

image-20220731163714751

运行性能测试[PyTest 格式的底层引擎基于pytest 执行引擎,不支持性能测试]

hrp boom testcases/www.luffycity.yml --spawn-count 100 --spawn-rate 10

  • spawn-count:表示我们期望达到的最大并发用户数
  • spawn-rate:表示达到期望最大并发前每秒增加的并发用户数

输入性能参数:

image-20220731163936401

各项参数说明:

指标名称 指标说明
Users 当前虚拟用户数
State HttpRunner当前运行状态
Total RPS 总RPS
Total Response 总响应时间
Total Fail Ratio 总请求错误率
Accumulated Transactions 事务总通过/失败数,包含Action事务
TYPE 请求类型
NAME 请求名称
REQUESTS 请求数
FAILS 请求失败数
MEDIAN 响应时间中位数
AVERAGE 响应时间平均值
MIN 响应时间最小值
MAX 响应时间最大值
CONTENT SIZE 响应内容大小
REQS/SEC 每秒请求数
FAILS/SEC 每秒请求失败数

基于django结合pytest、jenkins实现CICD持续集成、实现测试平台项目。

posted @ 2022-12-09 21:40  凫弥  阅读(163)  评论(0编辑  收藏  举报