接口测试基础知识

接口测试概念

接口测试是测试系统组件间接口的一种测试。
接口测试主要用于检测外部系统与系统之 间以及内部各个子系统之间的交互点。测试的重点是要检查数据的交换,传递和控制管理过 程,以及系统间的相互逻辑依赖关系等。

接口测试目的

  • 核心:保证系统的稳定
  • 手段:持续集成
  • 目的:提高测试效率,提升用户体验,降低产品研发成本

接口测试一般流程

  • 列出需求
  • 安排资源,编写接口用例 -> 用例评审
  • 编写接口测试代码 -> 代码评审codeReview
  • 执行接口测试

接口测试关注点

  • 功能:功能实现,实现与设计一致, 接口通过性测试
  • 健壮性: 边界值,容错性
  • 性能: 并发及压测
  • 稳定性: 长期运行的稳定性
  • 安全性: SQL注入, session依赖, 数字签名, http接口的安全性

常见接口种类

  • Http/Https接口: 通过http/https协议传送接口数据(通常按字符串/二进制传输), 如常见的网页表单, https安全性更好
  • RESTful Api: REST表述性状态传递. 一种设计风格,基于http/https协议, 把一切接口视为资源, 接口要分版本,在统一的域名下管理, 不同的方法(get/post..)做不同的事,通常请求及响应使用json格式
  • Web Service: SOAP简单面向对象协议, 基于http实现的一种RPC方案.接口返回一些对象,可以直接通过操作对象,实现我们需要的业务处理.使用xml格式传输数据
  • RPC接口: RPC为远程方法调用, 有不同的实现方案,基于TCP/Http协议的都有. RPC可以想我们本地导入和调用对象一样使用. Dubbo接口也是一种RPC接口.

常见接口数据类型

  • 请求数据类型(Content-Type):
    • application/x-www-form-urlencoded: 常规只有文本的网页表单
    • application/json: RESTful Api常用格式, 结构清晰, 含有多层嵌套
    • multipart/form-data: 既有文本,又有上传文件或富文本框的混合数据表单
    • text/xml: xml格式, RPC接口常用格式
  • 响应数据类型
    • string/html: 返回字符串或网页源码
    • json: RESTful Api常用响应格式, 结构清晰
    • xml: RPC接口常用格式

常见接口安全验证方式

  • Auth_1.0/Auth_2.0: 通用接口授权方式
  • Session依赖: 需要登录之后才能进行接口操作
  • Token验证: 先要使用自己的appid/appsecret通过获取token接口验证身份获取一个token(令牌,有一定有效期), 然后带着token访问接口
  • 数字签名: 将原本的参数按一定规则进行组合,配合时间戳或appsecret, 通过加密算法生成一个签名sign, 携带签名进行接口请求

常见接口请求方法(RESTful规范)

  • GET: 获取资源
  • POST: 修改资源
  • PUT: 上传资源
  • DELETE: 删除资源
  • HEAD: 只请求页面首部
  • PATCH: 补丁
  • OPTIONS: 运行客户端查看服务器性能
    ……

常见状态码

  • 200系: 成功
    • 200 OK - [GET]:获取资源成功
    • 201 CREATED - [POST/PUT/PATCH]:创建/修改成功
    • 202 Accepted - [*]:任务接受
    • 204 NO CONTENT - [DELETE]:删除成功
  • 300系: 重定向
    • 301 Moved Permanently: 永久重定向
    • 302 Found: 临时重定向
  • 400: 资源错误
    • 400 INVALID REQUEST - [POST/PUT/PATCH]:用户请求错误
    • 401 Unauthorized - [*]:没有权限(鉴权失败, 接口层)
    • 403 Forbidden - [*] 资源禁止访问(服务器层,没有访问权限)
    • 404 NOT FOUND - [*]:资源不存在
    • 405 Method Not Allowd: 访问的方法不允许, 如用POST访问只支持GET请求的接口
    • 406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)
    • 410 Gone -[GET]:资源被永久删除
    • 422 Unprocesable entity - [POST/PUT/PATCH] 当创建对象时,发生验证错误
  • 500系: 服务器内部错误(接口崩溃或有Bug)
    • 500 INTERNAL SERVER ERROR - [*]:服务器发生错误

接口业务类型

  • 返回数据型接口: 只从数据库读取数据
  • 业务操作型接口: 需要写数据库(接口测试需要要涉及参数化或环境清理)

接口概念

接口又称API(Application Programming Interface,应用程序编程接口),是一些预先定义的函数,目的是提供应用程序与开发人员基于某软件或硬件得以访问一组例程的能力

简单概括为以下3点:

  • 程序代码(函数方法)
  • 屏蔽实现细节
  • 可以被访问/调用来获取信息或实现某些功能(提供访问地址,定义了访问规则)

接口自述(通俗的来说):

  • 首先我有一些功能(功能函数)
  • 你不用关心我怎么实现的(屏蔽细节)
  • 我会给你一个我的地址(接口地址)
  • 你按照地址找到我,按照我规定的格式(请求类型)告诉我所需要的信息(参数)就行
  • 我会给你个反馈(响应信息)

常见接口类型

HTTP接口:通过HTTP协议传输的接口,可以传输文本表单数据,也可以传输Json类型的对象数据或xml类型的数据

RPC: 远程方法调用,随着分布式系统的出现,当你需要调用部署到其他服务器上的方法时,需要用到RPC。RPC只是提出了这样一个问题,有很多种解决方案,比如WebService(基于SOAP协议), REST(基于HTTP协议)。

SOAP: 简单面向对象协议,基于HTTP,使用xml作为默认传输格式

Web Service: 基于SOAP协议的一种RPC实现方案。相比传统的HTTP接口只传输文本请求和文本相应,通过Web Service可以直接拿到远程的一个对象,并能够直接调用该对象的属性和方法,比HTTP更高级。

REST/RESTful API: REST,表述性状态转移。一种HTTP接口的设计风格,将一切接口视为资源,要求接口路径同意管理,分版本管理,规定了GET/POST等请求以及HTTP状态码的使用规范,默认使用JSON格式传输。RESTful API即满足REST风格即设计规范的API接口

什么是接口测试?

接口测试是测试系统组件间接口的一种测试。接口测试主要用于检测外部系统与系统之间以及内部各个
子系统之间的交互点。测试的重点是要检查数据的交换、传递和控制管理过程,以及系统间的相互逻辑依赖关系等。

为什么要做接口测试?

  • 接口测试介于单元测试与系统测试之间,单元测试一般由开发完成(不要相信开发)
  • 接口是各种系统功能的基础,一旦接口出现问题可能会引起许多系统功能的问题并且不容易定位
  • 开展接口测试可以及早发现问题,有效降低测试成本
  • 接口一般较UI相对稳定,利于进行自动化和持续集成

接口测试都测什么?

接口测试一般有以下岗位实施:

  • 手工测试岗:先提测接口再提出功能,兼做接口自动化
  • 服务端测试岗:梳理代码,审核接口实现逻辑是否与业务设计一致,技术实现逻辑的合理性,异常流测试,接口压测及安全性测试
  • 测试开发岗:专职做接口(或UI)的自动化用例开发,测试工具开发

image-20240714230830936

怎样掌握接口测试?

  1. 了解OSI网络模型,TCP/UDP协议,掌握HTTP/HTTPS协议,了解RPC, Web Service及REST,理解Session和Cookie
  2. 掌握常用的接口测试工具如curl命令,Postman,Jmeter,LR,SoupUI,AB等
  3. 掌握基本的抓包工具如Chrome开发者工具,Fiddler,Charles,Wireshark,tcpdumps等
  4. 掌握一门编程语言Python或Java
  5. 了解Nginx, Apache, Tomcat等服务器中间件
  6. 掌握数据库基本查询命令,及一些NoSQL(如Redis)操作,用于检查响应结果
  7. 掌握基本的Linux日子查询和筛选命令

接口测试重难点

  1. 动态变量参数化
  2. 接口依赖及中间变量问题
  3. 异步接口结果验证问题
  4. 相应参数及嵌套很多的验证问题
  5. 接口测试框架的稳定性问题
  6. 资源清理问题
  7. 多接口场景测试

网络基础知识:IP,域名, DNS及端口

IP地址

​ IP地址是IP协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址

查看IP命令

  • Windows: ipconfig
  • Linux: ifconfig

Python练习:检查字符串是否ip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# windoows
def is_ip(ip):
num_list = ip.split(".")
for num in num_list:
if not num.isdigit() or not 0 <= int(num) <=255:
return False
return True

print(is_ip("101.1.0.201"))

def check_ipv4(str):
ip = str.strip().split(".")
return False if len(ip) != 4 or False in map(lambda x:True if x.isdigit() and 0<= int(x) <= 255 else False, ip) else True

端口

“端口”是英文port的意译,可以认为是设备与外界通讯交流的出口。
如果把IP地址比作一间房子,端口就是出入这间房子的门。一个IP地址的端口可以有65536(即:2^16)个

端口类型

  • 公认端口:从0到1023,紧密绑定于一些服务
  • 注册端口:人1024到49151,许多服务绑定这些端口,这些端口同样用于许多其它目的。
  • 动态或私有端口:从49152到65535。理论上,不应为服务分配这此端口。实际上,机器通常从1024起分配动态端口。

常见软件默认端口

  • Apache/Nginx(HTTP服务): 80
  • Tomcat: 8080
  • Oracle: 1521
  • MySQL: 3306
  • SQL Server: 1433
  • PostgreSQL: 5432
  • MongoDB: 27017
  • Redis: 6379
  • Memcached: 11211

查看端口命令

  • Windows: netstat -ano
  • Linux: netstat -ntlp

解决端口占用问题

  • Windows: netstat -ano | findstr “8080”,找到占用端口的程序的PID -> 打开任务管理器 -> 设置显示PID -> 找到并结束对于程序
  • Linux: netstat -ntlp | grep “8080”, 找到对应的程序 -> ps -ef | grep “程序名” 找到对于的pid -> kill 相应的id

域名及DNS

由于IP地址不容易记忆,为IP地址赋予了一个利于记忆的别名,称为域名

如何查看域名所对于的ip?
DNS

DNS即域名解析系统,域名和IP地址相互映射的一个分布式数据库,提供域名转到对应ip的服务

网络基础知识:OSI七层模型及TCP协议

OSI七层模型

OSI即开放系统互连参考模型,一种网络架构,分为7层

image-20240714231254726

  • 上三层—应用层,控制软件方面
    • 应用层:文件传输,电子邮件,文件服务,虚拟终端 TFTP,HTTP,SNMP,FTP,SMTP,DNS,Telnet
    • 表示层:数据格式化,代码转换,数据加密
    • 会话层:解除或建立与别的接点的联系(会话)
  • 下四层—数据流层,用来管理硬件
    • 传输层:提供端对端的接口 TCP,UDP
    • 网络层:为数据包选择路由 IP,ICMP,RIP,OSPF,BGP,IGMP
    • 数据链路层 传输有地址的帧以及错误检测功能 SLIP,CSLIP,PPP,ARP,RARP,MTU
    • 物理层 以二进制数据形式在物理媒体上传输数据 ISO2110,IEEE802,IEEE802.2

OSI七层模型及各层协议

TCP及UDP协议

TCP和UDP都是传输层的协议

  • TCP:传输控制协议
  • UDP: 数据报文协议

TCP和UDP的区别

  • UDP的特点如下:
  1. 无链接
  2. UDP使用尽最大努力交付,不保证可靠性
  3. UDP是面向报文的,UDP对应用层交付下来的报文,既不合并,也不拆分,而是保留这些报文的边界。应用层交给UDP多长的报文,UDP就照样发送,即一次发送一个报文
  4. UDP没有拥塞控制
  5. UDP支持一对一、一对多、多对一和多对多的交互通信
  6. UDP的首部开销小,只有8字节
  • TCP的特点:
  1. TCP是面向连接的
  2. 每条TCP连接只能用于两个断点,一对一
  3. TCP提供可靠交付的服务:连接传输数据、无差错、不丢失、不重复、并且按序到达
  4. TCP提供全双工通信
  5. 面向字节流。TCP根据对方给出的窗口和当前网络拥塞的程度来决定一个报文应该包含多少个字节

HTTP协议

HTTP:超文本传输协议,是用于从WWW服务器传输超文本到本地浏览器的传输协议。
HTTP协议是一种无状态协议,主要包含请求和相应两大部分:

请求

请求是我们发送给接口的数据对象,包含接口地址(URL),请求方法,参数,请求头(Headers), Cookies, 数据等

GET请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET https://www.sojson.com/open/api/weather/json.shtml?city=%E5%8C%97%E4%BA%AC HTTP/1.1
Host: www.sojson.com
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: __cfduid=dccd65c484a7657b468327b66023fefc41534934250; yjs_id=59d1c42afa817b578b4b562d1f72651f; ctrl_time=1
'''
第1行: 请求方法 接口地址 HTTP协议版本
第2-N行:请求headers(如果有Cookie,最后一行为Cookie)
空一行
请求数据(POST等方法使用,此处为空)
'''

POST请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
POST http://openapi.tuling123.com/openapi/api/v2 HTTP/1.1
Content-Type: application/json
cache-control: no-cache
Postman-Token: 1a39439e-61c8-4e59-82a1-736a362c5962
User-Agent: PostmanRuntime/7.2.0
Accept: */*
Host: openapi.tuling123.com
accept-encoding: gzip, deflate
content-length: 468
Connection: keep-alive

{
"reqType":0,
"perception": {
"inputText": {
"text": "附近的酒店"
},
"inputImage": {
"url": "imageUrl"
},
"selfInfo": {
"location": {
"city": "北京",
"province": "北京",
"street": "信息路"
}
}
},
"userInfo": {
"apiKey": "ec961279f453459b9248f0aeb6600bbe",
"userId": "206379"
}
}
URL

URL:统一资源定位符,接口的访问地址(包含服务器地址+接口地址)

1
2
3
4
5
6
7
8
9
# url组成格式
# 协议\\: 服务器地址:端口号\资源路径?参数1=值1&参数2=值2

# URL编码
'''
URL编码是一种浏览器用来打包请求参数及表单参数的格式, 参数和参数之间使用&分割,非ASCII码使用%加16进制编码替换
如:https://www.sojson.com/open/api/weather/json.shtml?city=北京
编码后为:https://www.sojson.com/open/api/weather/json.shtml?city=%E5%8C%97%E4%BA%AC
'''
请求方法
序号 方法 描述
1 GET 请求指定的页面信息,并返回实体主体
2 POST 向指定资源提交数据进行处理请求(例如提交表单或者上传文件)数据被包含在请求体中
3 HEAD 类似于get请求,只不过返回的响应中没有具体的内容,用于获取报头
4 PUT 从客户端向服务器传送的数据取代指定的文档的内容
5 DELETE 请求服务器删除指定的页面
6 CONNECT 预留给能够将连接改为管道方式的代理服务器
7 OPTIONS 允许客户端查看服务器的性能
8 TRACE 回显服务器收到的请求,主要用于测试或诊断
GET请求和POST请求的区别
  • GET请求:
    • GET请求可被缓存
    • GET请求保留在浏览器历史记录中
    • GET请求可被收藏为书签
    • GET请求不应在处理敏感数据时使用
    • GET请求有长度限制
    • GET请求只应当用于取回数据
  • POST请求:
    • POST请求不会被缓存
    • POST请求不会保留在浏览器历史记录中
    • POST不能被收藏为书签
    • POST请求对数据长度没有要求
请求参数(URL参数)

如:https://www.sojson.com/open/api/weather/json.shtml?city=北京

  • 中的city=北京,向接口传递一个参数“city”,参数值为“北京”
  • 不同的参数之间用&隔开,非ASCII码参数会自动url encode
请求Headers(请求头)

image-20240714231741474

请求数据(又称为Request Body 或 Data)

请求数据类型(Content-Type)(重点)

  • application/x-www-form-urlencoded: 网页表单格式(默认)
  • application/json:REST接口常用格式
  • text/xml:xml格式,RPC接口,Dubbo接口常用格式
  • test/html: html格式
  • multipart/form-data: 混合表单,支持上传图片

数据编码

  • ASCII码: 单字节,美国信息交换标准码, 包含数字,字母,英文标点及一些控制字符
  • ISO-8859-1:又称Latin1,单字节,向下兼容ASCII,用于支持部分于欧洲使用的语言
  • ANSI编码:单字节表示英文,双字节表示汉字,对ASCII的扩展,不同的国家和地区制定了不同的标准,中文中的GBK,GB2312属于ANSI编码
  • Unicode编码: 采用二个字节编码(英文和中文的字符都以双字节存放),与ANSI码不兼容
  • UTF-8:是目前互联网上使用最广泛的一种Unicode 编码方式,又称万国码
  • Base64: 一种用64个字符来表示任意二进制数据的方法。
    • Base64编码的作用:由于某些系统中只能使用ASCII字符。Base64就是用来将非ASCII字符的数据转换成ASCII字符的一种方法。
    • 而且base64特别适合在http,mime协议下快速传输数据。

指定请求数据编码(解决中文乱码):
请求Headers设置Content-Type: application/json; charset=utf-8

响应(Response)

接口返回的信息,包含HTTP状态码,响应头和相应信息

1
2
3
4
5
6
HTTP/1.1 200 OK
Date: Thu, 23 Aug 2018 06:32:26 GMT
Transfer-Encoding: chunked
Connection: keep-alive

{"intent":{"actionName":"","code":10005,"intentName":"","parameters":{"lon":"","checkout_date":"2018-08-25","star":"0","city":"北京","days":"1","order":"","price_range":"","nearby_place":"酒店","brand":"","checkin_date":"2018-08-24","place":"信息路","lat":"","needgeo":"0"}},"results":[{"groupType":1,"resultType":"url","values":{"url":"http://m.elong.com/hotel/0101/nlist/#indate=2018-08-24&outdate=2018-08-25&keywords=%E4%BF%A1%E6%81%AF%E8%B7%AF"}},{"groupType":1,"resultType":"text","values":{"text":"亲,已帮你找到相关酒店信息"}}]}

常见的响应格式

  • html
  • json
  • xml

HTTP状态码

  • 1** 信息,服务器收到请求,需要请求者继续执行操作
  • 2** 成功,操作被成功接收并处理
  • 3** 重定向,需要进一步的操作以完成请求
  • 4** 客户端错误,请求包含语法错误或无法完成请求
  • 5** 服务器错误,服务器在处理请求的过程中发生了错误

HTTP响应码

  • 200: 成功
  • 301/302: 请求重定向到另外一个接口
  • 400: 请求语法错误
  • 403:资源没有访问权限
  • 404:资源不存在(有可能是请求url错误或参数不正确)
  • 405:请求方法不被允许(比如接口只允许Post,使用Get请求接口)
  • 500:服务器内部错误(通常是服务器挂了或接口Bug)
  • 502: 网关失效
  • 504: 网关请求超时

HTTP与HTTPS

HTTP协议传输的数据都是未加密的,HTTPS协议是由HTTP+SSL协议构建的可进行加密传输、身份认证的网络协议,要比HTTP协议安全。

HTTPS和HTTP的区别

  • HTTPS协议需要到ca申请证书,一般免费证书较少,因而需要一定费用。
  • HTTP是超文本传输协议,信息是明文传输,HTTPS则是具有安全性的SSL加密传输协议。
  • HTTP和HTTPS使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
  • HTTP的连接很简单,是无状态的;HTTPS协议是由HTTP+SSL协议构建的可进行加密传输、身份认证的网络协议,比HTTP协议安全。

Cookie和Session

  • *Cookie/Cookies: *是指某些网站为了辨别用户身份*、进行session跟踪储存在用户本地*终端上的数据(通常经过加密)。
  • Session:服务端为客户端访问所建立和维持的会话,通常会生成一个唯一的id,会话有一定的有效期。
    由于HTTP是无状态的,即服务器不知道用户上一次做了什么,默认也无法识别用户身份。
    比较流行的做法是:
  • 用户访问时服务端建立会话(Session)
  • 将会话id(Session ID)随响应返回,并保存在客户端的Cookies里
  • 后续的访问中,服务器通过辨识,客户端请求时携带的Cookies内容来识别用户

Cookie和Session的区别

  • cookie是存在客户端(浏览器)的进程内存中和客户端所在的机器硬盘上
  • cookie只能能够存储少量文本,大概4K大小
  • cookie是不能在不同浏览器之间共享
  • Session存在服务器端,存在网站进程的内存中
  • Session在初次设置session的时候,会在session池中实例化一个session对象,以sessionid 的值作为key,同时会将key以cookie的形式保存到客户端的内存中
  • Session的作用域只存在当前浏览器的会话中,当浏览器关闭以后就会将sessionid丢失,但是服务器的Session对象要20分钟以后才会回收

授权与加密

常见的接口安全策略:

  1. Session/Cookie机制: 即需要登录,登录后可访问各个接口,最常用的一种策略,适用于内部接口。
  2. 固定appid模式: 用户注册时会生成一个唯一的appid,用户调用接口时需要携带appid,适用于公开接口,安全性较差。
  3. 动态token模式: token即身份令牌,用户访问接口需要使用个人appid临时申请一个token,token有一定有效期,适用于公开接口,安全性较appid模式好。
  4. 开放协议: Basic Auth/ Oauth1.0 / Oauth2.0: 适用于开放接口。
  5. 数字签名: 将所有请求参数及参数值进行排列拼接,加上用户私钥,再进行Md5或其他加密生成一个请求的签名(sign),请求是需要携带签名,服务器收到请求后,会对请求重新计算签名并核实与请求所携带签名是否一致。安全性较高,可以有效防止请求被篡改。适用于内部接口及微服务接口。

常见的加密算法
在接口数据传输过程中常对一些敏感数据(如密码)进行Base64编码或MD5加密,以增加安全性。
加密算法分为对称式加密算法和非对称式加密算法,对称式加解密使用同一个秘钥,非对称式使用不同的秘钥。

  • 对称式加密
    • DES: 数据加密标准,速度较快,适用于加密大量数据的场合
    • AES: 高级加密标准,速度快,安全级别高
  • 非对称式加密
    • RSA: 是一个支持变长密钥的公共密钥算法, 分公钥和私钥,SSH协议使用该算法
    • MD5: 最常用的一种加密方法,是一种摘要算法。

缓存

HTTP 缓存机制作是 web 性能优化的重要手段,当用户第一次请求服务器资源时,服务器将资源缓存到客户端本地,在一定时间内(缓存有效期内)当用户再次向服务器请求同样的资源时,可以直接从缓存中读取,而不用从服务器下载。

接口测试中缓存相关注意点

  • 在更新或调试接口是,注意是否需要清理缓存(或临时禁用缓存)
  • 缓存有一定的有效期
  • 接口性能测试中会关注缓存的命中率

resquests库

requests安装

  • Windows: 打开cmd命令行,输入pip install requests,等待安装完成即可
  • Linux: (建议使用Python3),终端中输入pip3 install requests,等待安装完成即可
  • Mac: (建议使用Python3), sudo python3 -m pip install requests,等待安装完成即可

requests的使用

发送GET请求

  1. 组装请求: 请求可能包含url,params(url参数),data(请求数据),headers(请求头),cookies等,最少必须有url
  2. 发送请求,获取响应:支持get,post等各种方法发送,返回的是一个响应对象
  3. 解析响应: 输出响应文本
1
2
3
4
5
6
7
8
9
# 导入requests包
import requests

# 1. 组装请求
url = "http://httpbin.org/get" # 这里只有url,字符串格式
# 2. 发送请求,获取响应
res = requests.get(url) # res即返回的响应对象
# 3. 解析响应
print(res.text) # 输出响应的文本

带参数的GET请求

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests 

url = "http://www.tuling123.com/openapi/api?key=ec961279f453459b9248f0aeb6600bbe&info=你好" # 参数可以写到url里
res = requests.get(url=url) # 第一个url指get方法的参数,第二个url指上一行我们定义的接口地址
print(res.text)

# or
import requests

url = "http://www.tuling123.com/openapi/api"
params = {"key":"ec961279f453459b9248f0aeb6600bbe","info":"你好"} # 字典格式,单独提出来,方便参数的添加修改等操作
res = requests.get(url=url, params=params)
print(res.text)

传统表单类POST请求(x-www-form-urlencoded)

1
2
3
4
5
6
import requests 

url = "http://httpbin.org/post"
data = {"name": "hanzhichao", "age": 18} # Post请求发送的数据,字典格式
res = requests.post(url=url, data=data) # 这里使用post方法,参数和get方法一样
print(res.text)

JSON类型的POST请求(application/json)

1
2
3
4
5
6
7
8
9
import requests 

url = "http://httpbin.org/post"
data = '''{
"name": "hanzhichao",
"age": 18
}''' # 多行文本, 字符串格式,也可以单行(注意外层有引号,为字符串) data = '{"name": "hanzhichao", "age": 18}'
res = requests.post(url=url, data=data) # data支持字典或字符串
print(res.text)

data参数支持字典格式也支持字符串格式,如果是字典格式,requests方法会将其按照默认表单urlencoded格式转换为字符串,如果是字符串则不转化
如果data以字符串格式传输需要遵循以下几点:

  • 必须是严格的JSON格式字符串,里面必须用双引号,k-v之间必须有逗号,布尔值必须是小写的true/false等等
  • 不能有中文,直接传字符串不会自动编码

一般来说,建议将data声明为字典格式(方便数据添加修改),然后再用json.dumps()方法把data转换为合法的JSON字符串格式

1
2
3
4
5
6
7
8
9
10
11
import requests 
import json # 使用到JSON中的方法,需要提前导入

url = "http://httpbin.org/post"
data = {
"name": "hanzhichao",
"age": 18
} # 字典格式,方便添加
headers = {"Content-Type":"application/json"} # 严格来说,我们需要在请求头里声明我们发送的格式
res = requests.post(url=url, data=json.dumps(data), headers=headers) # 将字典格式的data变量转换为合法的JSON字符串传给post的data参数
print(res.text)

或直接将字典格式的data数据赋给post方法的JSON参数(会自动将字典格式转为合法的JSON文本并添加headers)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import requests 

url = "http://openapi.tuling123.com/openapi/api/v2"
data = {
"reqType":0,
"perception": {
"inputText": {
"text": "附近的酒店"
},
"inputImage": {
"url": "imageUrl"
},
"selfInfo": {
"location": {
"city": "北京",
"province": "北京",
"street": "信息路"
}
}
},
"userInfo": {
"apiKey": "ec961279f453459b9248f0aeb6600bbe",
"userId": "206379"
}
}
res = requests.post(url=url, json=data) # JSON格式的请求,将数据赋给json参数
print(res.text)

JSON类型解析

序列化和反序列化

程序中的对象,如Python中的字典、列表、函数、类等,都是存在内存中的,一旦断电就会消失,不方便传递或存储,所以我们需要将内存中的对象转化为文本或者文件格式,来满足传输和持久化(存储)需求

  • 序列化: 内存对象 -> 文本/文件
  • 反序列化: 文本 -> 内存对象

对象在HTTP中的传输过程

HTTP协议是超文本传输协议,是通过文本或二进制进行传输的,所以我们发送的请求要转化成文本进行传输,收到的响应也是文本格式,如果是JSON,一般还需要将文本格式重新转化为对象

JSON对象(Python字典) -> 转为文本请求 -> 发送请求-> 服务器收到文本请求 -> 将文本请求转化为对象,获取其中的参数,处理业务-> 返回文本格式的响应 -> 客户端转为对象格式来从响应中取值

JSON对象与Python字典的区别

JSON对象是javascript object即javascript中的对象,是一种通用的格式,格式严格,不支持备注。

JSON文本和JSON对象的区别:

  • JSON文本是符合JSON格式的文本,实际上是一个字符串
  • JSON对象是内存中一个对象,拥有属性和方法,可以通过对象获取其中的参数信息

Python的字典的格式和JSON格式,稍有不同:

  • 字典中的引号支持单引号和双引号,JSON格式只支持双引号
  • 字典中的True/False首字母大写,JSON格式为true/false
  • 字典中的空值为None, JSON格式为null

JSON格式操作方法

  • 序列化(字典 -> 文本/文件句柄): json.dumps()/json.dump()
  • 反序列化(文本/文件句柄 -> 字典) : json.loads()/json.load()

序列化

1
2
3
4
5
import json # 需要导入JSON包

data = {'name': '张三', 'password': '123456', "male": True, "money": None} # 字典格式
str_data = json.dumps(data) # 序列化,转化为合法的JSON文本(方便HTTP传输)
print(str_data)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests 
import json

res = requests.post("http://www.tuling123.com/openapi/api?key=ec961279f453459b9248f0aeb6600bbe&info=怎么又是你")
print(res.text) # 输出为一行文本

res_dict = res.json() # 将响应转为json对象(字典)等同于`json.loads(res.text)`
print(json.dumps(res_dict, indent=2, sort_keys=True, ensure_ascii=False)) # 重新转为文本
'''
json.dumps() 参数
indent: 缩进空格数,indent=0输出为一行
sork_keys=True: 将json结果的key按ascii码排序
ensure_ascii=Fasle: 不确保ascii码,如果返回格式为utf-8包含中文,不转化为\u...
'''

反序列化

1
2
3
4
5
import json

res_text = '{"name": "\u5f20\u4e09", "password": "123456", "male": true, "money": null}' # JSON文本格式的响应信息
res_dict = json.loads(res_text) # 转化为字典
print(res_dict['name']) # 方便获取其中的参数值

文件的序列化与反序列化

  1. 序列化:字典 -> 文件句柄
1
2
3
4
5
import json

res_dict = {'name': '张三', 'password': '123456', "male": True, "money": None} # 字典格式
f = open("demo1.json","w")
json.dump(res_dict, f)
  1. 序列化: 文件句柄 -> 字典
1
2
3
4
5
6
7
// json 数据
{
"name": "张三",
"password": "123456",
"male": true,
"money": null
}
1
2
3
4
5
6
import json

f = open("demo.JSON","r", encoding="utf-8") # 文件中有中文需要指定编码
f_dict = json.load(f) # 反序列化将文件句柄转化为字典
print(f['name']) # 读取其中参数
f.close()

什么时候使用JSON对象(字典)什么时候使用JSON文本?
一般在组装data参数时,建议使用字典格式,发送请求时用json.dumps(data)转化为文本发送,收到请求后使用json.loads(res.text)转化为字典,方便我们获取其中的参数信息

requests库详解

请求方法

  • equests.get()
  • requests.post()
  • requests.put()
  • requests.session(): 用于保持会话(session)
    除了requests.session()外,其他请求方法的参数都差不多,都包含url,params, data, headers, cookies, files, auth, timeout等等

请求参数

  • url: 字符串格式,参数也可以直接写到url中
  • params:url参数,字典格式
  • data: 请求数据,字典或字符串格式
  • headers: 请求头,字典格式
  • cookies: 字典格式,可以通过携带cookies绕过登录
  • files: 字典格式,用于混合表单(form-data)中上传文件
  • auth: Basic Auth授权,数组格式 auth=(user,password)
  • timeout: 超时时间(防止请求一直没有响应,最长等待时间),数字格式,单位为秒

响应解析

  • res.status_code: 响应的HTTP状态码
  • res.reason: 响应的状态码含义
  • req.text:响应的文本格式,按req.encoding解码
  • req.content: 响应的二进制格式
  • req.encoding: 解码格式,可以通过修改req.encoding='utf-8'来解决一部分中文乱码问题
  • req.apparent_encoding:真实编码,由chardet库提供的明显编码
  • req.json(): (注意,有括号),响应的json对象(字典)格式,慎用!如果响应文本不是合法的json文本,或报错
  • req.headers: 响应头
  • req.cookies: 响应的cookieJar对象,可以通过req.cookies.get(key)来获取响应cookies中某个key对应的值
1
2
3
4
5
6
7
8
9
10
11
12
import requests 

res = requests.get("https://www.baidu.com")
print(res.status_code, res.reason) # 200 OK
print(res.text) # 文本格式,有乱码
print(res.content) # 二进制格式
print(res.encoding) # 查看解码格式 ISO-8859-1
print(res.apparent_encoding) # utf-8
res.encoding='utf-8' # 手动设置解码格式为utf-8
print(res.text) # 乱码问题被解决
print(res.cookies.items()) # cookies中的所有的项 [('BDORZ', '27315')]
print(res.cookies.get("BDORZ")) # 获取cookies中BDORZ所对应的值 27315

带安全认证的请求

需要登录的请求(Cookie/Session认证)

  1. 使用会话保持
1
2
3
4
5
6
import requests

s = requests.session() # 新建一个会话
s.post(url="https://demo.fastadmin.net/admin/index/login.html",data={"username":"admin","password":"123456"}) # 发送登录请求
res = s.get("https://demo.fastadmin.net/admin/dashboard?ref=addtabs") # 使用同一个会话发送get请求,可以保持登录状态
print(res.text)
  1. 抓取cookies

    1
    2
    3
    4
    5
    6
    7
    import requests

    url = "https://demo.fastadmin.net/admin/dashboard?ref=addtabs"
    # 抓取cookie
    cookies = {"PHPSESSID":"9bf6b19ddb09938cf73d55a094b36726"}
    res = requests.get(url=url, cookies=cookies) # 携带cookies发送请求
    print(res.text)

两种方式的对比

  • 使用session方式:每次都要发送两次请求,效率较低
  • 使用携带cookies方式:需要获取cookie,提取组装,cookies中是session有一定有效期,过期之后要重新抓取和更换cookies
  • 如果很多或所有请求都需要登录,可以发一次请求,保持该session为全局变量,其他接口都使用该session发送请求(同样要注意登录过期时间)

appid或token方式

  • appid: 系统为合法用户赋予的访问id,固定的字符串,一般经过加密以确保HTTP传输中的安全
  • token: 即令牌,固定或需要动态申请(有一定有效期),一般由用户信息及申请时间计算加密而成,用于验证接口访问的权限

token与session的区别

操作数据库

​ 在功能、接口测试中常常需要通过数据库的操作,来准备数据、检测环境及核对功能、接口的数据库操作是否正确
​ 在自动化测试中,就需要我们用代码连接数据库自动完成数据准备、环境检查及数据库断言的功能。
​ 使用Python操作MySQL数据库这里我们需要用到三方库PyMySQl

安装pymysql

1
pip install pymysql

数据库操作

  1. 建立数据库连接 conn = pymysql.connect()
  2. 从连接建立操作游标 cur = conn.cursor()
  3. 使用游标执行sql(读/写) cur.execute(sql)
  4. 获取结果(读)/ 提交更改(写) cur.fetchall() / conn.commit()
  5. 关闭游标及连接 cur.close();conn.close()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import pymysql

# 1. 建立连接
conn = pymysql.connect(host='127.0.0.1',
port=3306,
user='root',
passwd='123456', # password也可以
db='api_test',
charset='utf8') # 如果查询有中文需要指定数据库编码

# 2. 从连接建立游标(有了游标才能操作数据库)
cur = conn.cursor()

# 3. 查询数据库(读)
cur.execute("select * from user where name='张三'")

# 4. 获取查询结果
result = cur.fetchall()
print(result)

# 3. 更改数据库(写)
cur.execute("delete from user where name='李四'")

# 4. 提交更改
conn.commit() # 注意是用的conn不是cur

# 5. 关闭游标及连接
cur.close()
conn.close()

查询操作

使用cur.execute(), 执行数据库查询后无返回的是影响的行数,而非查询结果。我们要使用cur.fetchone()/cur.fetchmany()/cur.fetchall()来获取查询结果

  • cur.fetchone(): 获取一条数据(同时获取的数据会从结果集删除),返回元祖('张三','123456')
  • cur.fetchmany(3): 获取多条数据,返回嵌套元祖(('张三','123456'),('李四','123456'),("王五","123456"))
  • cur.fetchall(): 获取所有数据,返回嵌套元祖,(('张三','123456'),)(只有一条数据时)

注意: 获取完数据后,数据会从数据集中删除,再次获取获取不到,如:

1
2
3
4
5
6
7
8
9
10
11
cur.execute(select * from user where name='张三')
print(cur.fetchone()) # 结果: ('张三','123456')
print(cur.fetchone()) # 结果:None
print(cur.fetchall()) # 结果:()

# 所以我们需要重复使用查询结果时,需要将查询结果赋给某个变量
cur.execute(select * from user where name='张三')
result = cur.fetchall()
print(result) # 结果: ('张三','123456')
print(result) # 结果: ('张三','123456')

修改操作

执行修改数据库的操作后不立即生效,使用连接conn.commit()提交后才生效,支持事物及回滚

1
2
3
4
5
6
7
try:
cur.execute("insert into user (name,password) values ('张三', '123456')")
cur.execute("insert into user (name, passwd) values ('李四'), '123456'") # 此处sql出错
conn.commit() # 使用连接提交所有更改
except Exception as e:
conn.rollback() # 回滚所有更改(注意用的是conn)
print(str(e))

封装数据库操作

由于经常要使用到数据库操作,建议将所有数据库操作封装成公用的数据库模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# db.py
import pymysql


class DB:
'''
数据库连接信息建议写到配置文件中,从配置文件中读取
sql语句建议先在手工测试一下没有语法问题再进行封装
通过封装各种sql可以完成各种业务操作
更改数据库有风险,操作需谨慎!!!
'''
def __init__(self):
self.conn = pymysql.connect(host='127.0.0.1',
port=3306,
user='root',
passwd='123456', # passwd 不是 password
db='api_test')
self.cur = self.conn.cursor()

def __del__(self): # 析构函数,实例删除时触发
self.cur.close()
self.conn.close()

def query(self, sql):
self.cur.execute(sql)
return self.cur.fetchall()

def exec(self, sql):
try:
self.cur.execute(sql)
self.conn.commit()
except Exception as e:
self.conn.rollback()
print(str(e))

def check_user(self,name):
result = self.query("select * from user where name='{}'".format(name))
return True if result else False

def add_user(name, password):
sql = "insert into user (name, passwd) values ('{}','{}')".format(name, password)
change_db(sql)

def del_user(self, name)
self.exec("delete from user where name='{}'".format(name))

使用unittest测试框架

用例编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import unittest  # 导入unittest
import requests

class TestUserLogin(unittest.TestCase): # 类必须Test开头,继承TestCase才能识别为用例类
url = 'http://115.28.108.130:5000/api/user/login/'

def test_user_login_normal(self): # 一条测试用例,必须test_开头
data = {"name": "张三", "password": "123456"}
res = requests.post(url=self.url, data=data)
self.assertIn('登录成功', res.text) # 断言

def test_user_login_password_wrong(self):
data = {"name": "张三", "password": "1234567"}
res = requests.post(url=self.url, data=data)
self.assertIn('登录失败', res.text) # 断言


if __name__ == '__main__': # 如果是直接从当前模块执行(非别的模块调用本模块)
unittest.main(verbosity=2) # 运行本测试类所有用例,verbosity为结果显示级别

完整的接口测试用例

一条完整的测试接口用例需要包含:

  1. 数据准备:准备测试数据,可手工准备,也可使用代码准备(通常涉及数据库操作)
  2. 环境检查:如果手工准备的数据,连接数据库进行环境检查会使用例更健壮
  3. 发送请求:发送接口请求
  4. 响应断言/数据库断言:响应断言后,还需要进行数据库断言,以确保接口数据库操作的正确性
  5. 数据清理:如果接口有更数据库操作,断言结束后需要还原更改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# test_user_reg.py
import unittest
import requests
from db import * # 导入db.py文件,见封装数据库篇

# 数据准备
NOT_EXIST_USER = '范冰冰'
EXIST_USER = '张三'


class TestUserReg(unittest.TestCase):
url = 'http://127.0.0.1:5000/api/user/reg/'

def test_user_reg_normal(self):
# 环境检查
if check_user(NOT_EXIST_USER):
del_user(NOT_EXIST_USER)

# 发送请求
data = {'name': NOT_EXIST_USER, 'password': '123456'}
res = requests.post(url=self.url, json=data)

# 期望响应结果,注意字典格式和json格式的区别(如果有true/false/null要转化为字典格式)
except_res = {
"code": "100000",
"msg": "成功",
"data": {
"name": NOT_EXIST_USER,
"password": "e10adc3949ba59abbe56e057f20f883e"
}
}

# 响应断言(整体断言)
self.assertDictEqual(res.json(), except_res)

# 数据库断言
self.assertTrue(check_user(NOT_EXIST_USER))

# 环境清理(由于注册接口向数据库写入了用户信息)
del_user(NOT_EXIST_USER)

def test_user_reg_exist(self):
# 环境检查
if not check_user(EXIST_USER):
add_user(EXIST_USER)

# 发送请求
data = {'name': EXIST_USER, 'password': '123456'}
res = requests.post(url=self.url, json=data)

# 期望响应结果,注意字典格式和json格式的区别(如果有true/false/null要转化为字典格式)
except_res = {
"code": "100001",
"msg": "失败,用户已存在",
"data": {
"name": EXIST_USER,
"password": "e10adc3949ba59abbe56e057f20f883e"
}
}

# 响应断言(整体断言)
self.assertDictEqual(res.json(), except_res)

# 数据库断言(没有注册成功,数据库没有添加新用户)

# 环境清理(无需清理)

if __name__ == '__main__':
unittest.main(verbosity=2) # 运行所有用例

数据分离 - 从Excel中读取数据

之前的用例中,测试数据直接写在代码文件里,不利于修改和构造数据
这里我们使用Excel保存测试数据,实现代码和数据的分离

excel格式为

image-20240715012538450

Excel读取方法:
Python我们使用三方库xlrd来读取Excel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import xlrd

wb = xlrd.open_workbook("test_user_data.xlsx") # 打开excel
sh = wb.sheet_by_name("TestUserLogin") # 按工作簿名定位工作表
print(sh.nrows) # 有效数据行数
print(sh.ncols) # 有效数据列数
print(sh.cell(0, 0).value) # 输出第一行第一列的值`case_name`
print(sh.row_values(0)) # 输出第1行的所有值(列表格式)

# 将数据和标题组装成字典,使数据更清晰
print(dict(zip(sh.row_values(0), sh.row_values(1))))

# 遍历excel,打印所有的数据
for i in range(sh.nrows):
print(sh.row_values(i))

'''
3
5
case_name
['case_name', 'url', 'method', 'data', 'expect_res']
{'case_name': 'test_user_login_normal', 'url': 'http://115.28.108.130:5000/api/user/login/', 'method': 'POST', 'data': '{"name": "张三","password":"123456"}', 'expect_res': '<h1>登录成功</h1>'}
['case_name', 'url', 'method', 'data', 'expect_res']
['test_user_login_normal', 'http://115.28.108.130:5000/api/user/login/', 'POST', '{"name": "张三","password":"123456"}', '<h1>登录成功</h1>']
['test_user_login_password_wrong', 'http://115.28.108.130:5000/api/user/login/', 'POST', '{"name": "张三","password":"1234567"}', '<h1>失败,用户不存在</h1>']
'''

封装读取excel操作:

我们的目的是获取某条用例的数据,需要3个参数,excel数据文件名(data_file),工作簿名(sheet),用例名(case_name)
如果我们只封装一个函数,每次调用(每条用例)都要打开一次excel并遍历一次,这样效率比较低。
我们可以拆分成两个函数,一个函数excel_to_list(data_file, sheet),一次获取一个工作表的所有数据,另一个函数get_test_data(data_list, case_name)从所有数据中去查找到该条用例的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# read_excel.py
import xlrd

def excel_to_list(data_file, sheet):
data_list = [] # 新建个空列表,来乘装所有的数据
wb = xlrd.open_workbook(data_file) # 打开excel
sh = wb.sheet_by_name(sheet) # 获取工作簿
header = sh.row_values(0) # 获取标题行数据
for i in range(1, sh.nrows): # 跳过标题行,从第二行开始取数据
d = dict(zip(header, sh.row_values(i))) # 将标题和每行数据组装成字典
data_list.append(d)
return data_list # 列表嵌套字典格式,每个元素是一个字典

def get_test_data(data_list, case_name):
for case_data in data_list:
if case_name == case_data['case_name']: # 如果字典数据中case_name与参数一致
return case_data
# 如果查询不到会返回None

if __name__ == '__main__': # 测试一下自己的代码
data_list = excel_to_list("test_user_data.xlsx", "TestUserLogin") # 读取excel,TestUserLogin工作簿的所有数据
case_data = get_test_data(data_list, 'test_user_login_normal') # 查找用例'test_user_login_normal'的数据
print(case_data)

测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# test_user_login.py
import unittest
import requests
from read_excel import * # 导入read_excel中的方法
import json # 用来转化excel中的json字符串为字典

class TestUserLogin(unittest.TestCase):
@classmethod
def setUpClass(cls): # 整个测试类只执行一次
cls.data_list = excel_to_list("test_user_data.xlsx", "TestUserLogin") # 读取该测试类所有用例数据
# cls.data_list 同 self.data_list 都是该类的公共属性

def test_user_login_normal(self):
case_data = get_test_data(self.data_list, 'test_user_login_normal') # 从数据列表中查找到该用例数据
if not case_data: # 有可能为None
print("用例数据不存在")
url = case_data.get('url') # 从字典中取数据,excel中的标题也必须是小写url
data = case_data.get('data') # 注意字符串格式,需要用json.loads()转化为字典格式
expect_res = case_data.get('expect_res') # 期望数据

res = requests.post(url=url, data=json.loads(data)) # 表单请求,数据转为字典格式
self.assertEqual(res.text, expect_res) # 改为assertEqual断言

if __name__ == '__main__': # 非必要,用于测试我们的代码
unittest.main(verbosity=2)

增加log功能

1
2
3
4
5
6
7
8
9
10
import logging

logging.basicConfig(level=logging.DEBUG, # log level
format='[%(asctime)s] %(levelname)s [%(funcName)s: %(filename)s, %(lineno)d] %(message)s', # log格式
datefmt='%Y-%m-%d %H:%M:%S', # 日期格式
filename='log.txt', # 日志输出文件
filemode='a') # 追加模式

if __name__ == '__main__':
logging.info("hello")

Log Level:

  • CRITICAL: 用于输出严重错误信息
  • ERROR: 用于输出错误信息
  • WARNING: 用于输出警示信息
  • INFO: 用于输出一些提升信息
  • DEBUG: 用于输出一些调试信息

日志格式:

  • %(levelno)s: 打印日志级别的数值
  • %(levelname)s: 打印日志级别名称
  • %(pathname)s: 打印当前执行程序的路径,其实就是sys.argv[0]
  • %(filename)s: 打印当前执行程序名
  • %(funcName)s: 打印日志的当前函数
  • %(lineno)d: 打印日志的当前行号
  • %(asctime)s: 打印日志的时间
  • %(thread)d: 打印线程ID
  • %(threadName)s: 打印线程名称
  • %(process)d: 打印进程ID
  • %(message)s: 打印日志信息

项目使用log
将所有print改为log,如db.py 部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# db.py
import pymysql
from config import *

# 封装数据库查询操作
def query_db(sql):
conn = get_db_conn()
cur = conn.cursor()
logging.debug(sql) # 输出执行的sql
cur.execute(sql)
conn.commit()
result = cur.fetchall()
logging.debug(result) # 输出查询结果
cur.close()
conn.close()
return result

# 封装更改数据库操作
def change_db(sql):
conn = get_db_conn()
cur = conn.cursor()
logging.debug(sql) # 输出执行的sql
try:
cur.execute(sql)
conn.commit()
except Exception as e:
conn.rollback()
logging.error(str(e)) # 输出错误信息
finally:
cur.close()
conn.close()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# test_user_login.py
import unittest
import requests
from read_excel import * # 导入read_excel中的方法
import json # 用来转化excel中的json字符串为字典
from config import *

class TestUserLogin(unittest.TestCase):
@classmethod
def setUpClass(cls): # 整个测试类只执行一次
cls.data_list = excel_to_list("test_user_data.xlsx", "TestUserLogin") # 读取该测试类所有用例数据
# cls.data_list 同 self.data_list 都是该类的公共属性

def test_user_login_normal(self):
case_data = get_test_data(self.data_list, 'test_user_login_normal') # 从数据列表中查找到该用例数据
if not case_data: # 有可能为None
logging.error("用例数据不存在")
url = case_data.get('url') # excel中的标题也必须是小写url
data = case_data.get('data') # 注意字符串格式,需要用json.loads()转化为字典格式
expect_res = case_data.get('expect_res') # 期望数据

res = requests.post(url=url, data=json.loads(data)) # 表单请求,数据转为字典格式
# 这段logger打印可以单独封装出去,后期可以其他case调用
logging.info("测试用例:{}".format('test_user_login_normal'))
logging.info("url:{}".format(url))
logging.info("请求参数:{}".format(data))
logging.info("期望结果:{}".format(expect_res))
logging.info("实际结果:{}".format(res.text)
self.assertEqual(res.text, expect_res) # 断言

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

更简单的用例编写

使用用例基类

因为每条用例都需要从excel中读取数据,解析数据,发送请求,断言响应结果,我们可以封装一个BaseCase的用例基础类,对一些方法进行封装,来简化用例编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# baseCase.py
import unittest
import requests
import json
import sys
sys.path.append("../..") # 统一将包的搜索路径提升到项目根目录下

from lib.read_excel import *
from lib.case_log import log_case_info

class BaseCase(unittest.TestCase): # 继承unittest.TestCase
@classmethod
def setUpClass(cls):
if cls.__name__ != 'BaseCase':
cls.data_list = excel_to_list(data_file, cls.__name__)

def get_case_data(self, case_name):
return get_test_data(self.data_list, case_name)

def send_request(self, case_data):
case_name = case_data.get('case_name')
url = case_data.get('url')
args = case_data.get('args')
headers = case_data.get('headers')
expect_res = case_data.get('expect_res')
method = case_data.get('method')
data_type = case_data.get('data_type')

if method.upper() == 'GET': # GET类型请求
res = requests.get(url=url, params=json.loads(args))

elif data_type.upper() == 'FORM': # 表单格式请求
res = requests.post(url=url, data=json.loads(args), headers=json.loads(headers))
log_case_info(case_name, url, args, expect_res, res.text)
self.assertEqual(res.text, expect_res)
else:
res = requests.post(url=url, json=json.loads(args), headers=json.loads(headers)) # JSON格式请求
log_case_info(case_name, url, args, json.dumps(json.loads(expect_res), sort_keys=True),
json.dumps(res.json(), ensure_ascii=False, sort_keys=True))
self.assertDictEqual(res.json(), json.loads(expect_res))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# test_user_login.py
from test.case.basecase import BaseCase


class TestUserLogin(BaseCase): # 这里直接继承BaseCase
def test_user_login_normal(self):
"""level1:正常登录"""
case_data = self.get_case_data("test_user_login_normal")
self.send_request(case_data)

def test_user_login_password_wrong(self):
"""密码错误登录"""
case_data = self.get_case_data("test_user_login_password_wrong")
self.send_request(case_data)

按用例标签运行

unittest并没有tag相关功能,一种实现方案是添加自定义装饰器

1
2
3
4
5
6
7
8
9
10
11
12
def tag(tag):
if tag==OptionParser.options.tag: # 运行的命令行参数
return lambda func: func # 如果用例的tag==命令行指定的tag参数,返回用例本身
return unittest.skip("跳过不包含该tag的用例") # 否则跳过用例

# 用装饰器进行标记
'''
这种方法在最后的报告中会出现很多skipped的用例,可能会干扰到因其他(如环境)原因需要跳过的用例
'''
@tag("level1")
def test_a(self):
pass

用例标记方法

1
2
3
4
5
6
7
8
9
10
11
12
13
class TestUserLogin(BaseCase):
def test_user_login_normal(self):
"""level1:正常登录""" # level1及是一个标签,放到docstring哪里都可以
case_data = self.get_case_data("test_user_login_normal")
self.send_request(case_data)

# 通过获取docstring方式
def makesuite_by_tag(tag):
suite = unittest.TestSuite()
for case in collect():
if case._testMethodDoc and tag in case._testMethodDoc: # 如果用例方法存在docstring,并且docstring中包含本标签
suite.addTest(case)
return suite

重新运行上次失败用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pickle
import sys

def save_failures(result, file): # file为序列化保存的文件名,配置在config/config.py中
suite = unittest.TestSuite()
for case_result in result.failures: # 组装TestSuite
suite.addTest(case_result[0]) # case_result是个元祖,第一个元素是用例对象,后面是失败原因等等

with open(file, 'wb') as f:
pickle.dump(suite, f) # 序列化到指定文件

def rerun_fails(): # 失败用例重跑方法
sys.path.append(test_case_path) # 需要将用例路径添加到包搜索路径中,不然反序列化TestSuite会找不到用例
with open(last_fails_file, 'rb') as f:
suite = pickle.load(f) # 反序列化得到TestSuite
run(suite)

使用命令行参数

​ 为测试执行添加其他自定义命令行参数

1
2
3
4
5
6
7
8
9
10
11
# 命令行选项
parser = OptionParser()

parser.add_option('--collect-only', action='store_true', dest='collect_only', help='仅列出所有用例')
parser.add_option('--rerun-fails', action='store_true', dest='rerun_fails', help='运行上次失败的用例')
parser.add_option('--testlist', action='store_true', dest='testlist', help='运行test/testlist.txt列表指定用例')

parser.add_option('--testsuite', action='store', dest='testsuite', help='运行指定的TestSuite')
parser.add_option('--tag', action='store', dest='tag', help='运行指定tag的用例')

(options, args) = parser.parse_args() # 应用选项(使生效)

template & jsonpath

Mock Server

Mock 即模拟,就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法,其最大的优势就是降级前后端耦合度,使前端工程师可以不依赖后端返回数据,先开发前端样式以及逻辑处理

Mock Server 即Mock接口服务器,可以通过配置快速Mock出新的接口

Mock Server的使用范围

  • 前后端分离项目
  • 所测接口依赖第三方系统(还未具备)
  • 所测接口依赖复杂或依赖的接口不稳定,并不作为主要验证对象

Postman还可以基于Collection建立Mock Server,这里不再详述

Python+Flask自己搭建Mock接口

安装依赖

1
pip install flask

生成mock接口数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from flask import Flask, request, jsonify, abort
import random

app = Flask(__name__) # 实例化一个Flask对象

@app.route("/api/user/reg/", methods=["POST"])
def reg():
if not request.json or not 'name' in request.json or not 'password' in request.json:
abort(404)
res = [
{
"code": "100000",
"msg": "成功",
"data": {
"name": "李六",
"password": "e10adc3949ba59abbe56e057f20f883e"
}
},
{
"code": "100001",
"msg": "失败,用户已存在",
"data": {
"name": "李六",
"password": "e10adc3949ba59abbe56e057f20f883e"
}
},
{
"code": "100002",
"msg": "失败,添加用户失败",
"data": {
"name": "李六",
"password": "e10adc3949ba59abbe56e057f20f883e"
}
}
]

return jsonify(random.choice(res))

if __name__ == '__main__':
app.run()