mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-12-28 19:16:02 +08:00
mod
This commit is contained in:
379
极客时间专栏/接口测试入门课/综合技能篇/04 | 案例:如何把流程化的测试脚本抽象为测试框架?.md
Normal file
379
极客时间专栏/接口测试入门课/综合技能篇/04 | 案例:如何把流程化的测试脚本抽象为测试框架?.md
Normal file
@@ -0,0 +1,379 @@
|
||||
<audio id="audio" title="04 | 案例:如何把流程化的测试脚本抽象为测试框架?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/a0/b2344f6a020eaed426d78059402a44a0.mp3"></audio>
|
||||
|
||||
你好,我是陈磊。
|
||||
|
||||
在上一节课中我和你强调了,用什么工具或代码解决测试问题并不重要,拥有接口测试思维才更重要。在今天的课程中,我会带你从零开始打造一个测试框架,建立你自己的技术体系。
|
||||
|
||||
在这里,我推荐你学习一门编程语言,以便你可以更加得心应手、个性化地改造你的测试框架或工具。在这节课中,我会以Python语言来展示我的代码示例,不过语言本身不是重点,你只需要了解这其中的逻辑与方法即可,同样的事情,你可以使用Java、Go等任何你喜欢的语言来完成。
|
||||
|
||||
当然,如果你想学习Python语言的话,我推荐你花一个周末的时间看看[尹会生老师的“零基础学Python”课程](https://time.geekbang.org/course/intro/100008801)。
|
||||
|
||||
## 为什么要开发自己的测试框架?
|
||||
|
||||
之前,我们说到了用Postman来完成接口测试,但随着你的接口测试项目逐渐增加,你会发现越来越难以管理它的脚本,虽然测试工具导出的测试脚本也可以存放到代码仓库,但是,如果只是通过代码来查看是很难看懂的,你必须用原来的测试工具打开,才能更容易看懂原来的脚本做了什么样的操作。
|
||||
|
||||
同时,Postman也有其自身的局限性,最重要的一点就是它支持的接口协议有限,如果你接到了一个它无法完成的接口类型的测试任务,就不得不再去寻找另一个工具。由于接口类型的多样和变化,你会有一堆工具需要维护,这无疑会提高你的学习成本和技术投入成本。
|
||||
|
||||
Postman是如此,其他的工具也是如此,随着接口测试项目的增加,以及被测接口类型的增加,维护的难度会成指数级增长,所以,开发你自己的测试框架非常重要。
|
||||
|
||||
今天这节课,我就带你用Python 3.7来完成接口测试,并通过测试脚本的不断优化和封装,让你拥有一套完全适合你自己的接口测试框架。当然,我不会告诉你如何写出全部代码,我更想让你掌握的是,从不同的测试脚本抽象出一个测试框架的技巧和思路。
|
||||
|
||||
## 搭建测试框架,不要纠结于技术选型
|
||||
|
||||
**在做接口测试脚本开发的技术选型上,我更建议你根据自己的技术实力和技术功底来选择,而不要以开发工程师的技术栈来选择。**
|
||||
|
||||
这是因为,开发工程师和测试工程师关注的点,以及工作的交付目标是不同的。
|
||||
|
||||
- 对于任何一个开发工程师来说,他们主要的工作就是通过写代码实现产品需求或原型设计,他们会关心高并发、低消耗、分布式、多冗余,相对来说,也更加关注代码的性能和可靠性。
|
||||
- 我们作为测试工程师,无论是使用自动化的接口测试,还是界面的手工测试,第一目标都是保障交付项目的质量,那些业务侧的表现,在大多数情况下不是我们关心的重点。
|
||||
|
||||
因此,开发工程师在开发技术栈上的使用频度、使用广度,都会远远高于我们,除非你本来就有对应的知识储备,否则不要强求炫技,为了提高工作效率,你只要使用自己熟悉的技术栈完成自动化接口测试就可以了。
|
||||
|
||||
这里我再强调一下,用什么技术栈来写代码,只是一种帮助你实现接口测试的手段,它不是你要交付的结果。所以你在搭建测试框架时,不要太纠结技术选型。
|
||||
|
||||
## 搭建前的准备工作
|
||||
|
||||
我相信现在你已经准备好,和我一起完成今天的内容了,但在开工之前,我要先把一些基础知识简单介绍给你。
|
||||
|
||||
我们今天会用到Python的一个第三方HTTP协议支持库requests,它可以让我们在和HTTP协议打交道时更轻松;requests项目的描述是“HTTP for Humans”,由此可见,这会是一个人见人爱的HTTP协议库。你可以通过下面这个命令,完成requests的安装:
|
||||
|
||||
```
|
||||
pip install requests
|
||||
|
||||
```
|
||||
|
||||
完成安装后,你就可以使用requests完成我用Postman完成的接口测试了。主要代码段我会在文章中给出,我会尽最大努力给你一个可以直接运行的代码段,这样,即使你看不懂也不用担心,你只要把这些代码复制到一个有Python运行环境的机器上,直接使用就可以了。
|
||||
|
||||
第一个接口的单接口测试脚本如下,我在代码中做了详细的注释,你既可以复制出去直接运行,也可以通过注释看懂代码的作用。这样,你就完成了一个无参数的、GET访问的验证工作。
|
||||
|
||||
```
|
||||
# Python代码中引入requests库,引入后才可以在你的代码中使用对应的类以及成员函数
|
||||
import requests
|
||||
# 建立url_index的变量,存储战场的首页
|
||||
url_index='http://127.0.0.1:12356/'
|
||||
# 调用requests类的get方法,也就是HTTP的GET请求方式,访问了url_index存储的首页URL,返回结果存到了response_index中
|
||||
response_index = requests.get(url_index)
|
||||
# 存储返回的response_index对象的text属性存储了访问主页的response信息,通过下面打印出来
|
||||
print('Response内容:'+response_index.text)
|
||||
|
||||
```
|
||||
|
||||
接下来,是第二个被测试的接口,它是登录接口,是以POST方式访问的,它需要通过Body传递username和password这两个参数,这两个参数都是字符串类型,字符长度不可以超过10,并且不能为空。
|
||||
|
||||
你还记得在上节课中,我们一起用边界值法设计的测试用例吗?如果你忘记了,那么请你在本节课结束后,再回去看一下。这里你用下面的代码段,就可以完成第二个接口的单接口测试脚本了。
|
||||
|
||||
```
|
||||
# python代码中引入requests库,引入后才可以在你的代码中使用对应的类以及成员函数
|
||||
import requests
|
||||
# 建立url_login的变量,存储战场系统的登录URL
|
||||
url_login = 'http://127.0.0.1:12356/login'
|
||||
# username变量存储用户名参数
|
||||
username = 'criss'
|
||||
# password变量存储密码参数
|
||||
password = 'criss'
|
||||
# 拼凑body的参数
|
||||
payload = 'username=' + username + '&password=' + password
|
||||
# 调用requests类的post方法,也就是HTTP的POST请求方式,
|
||||
# 访问了url_login,其中通过将payload赋值给data完成body传参
|
||||
response_login = requests.post(url_login, data=payload)
|
||||
# 存储返回的response_index对象的text属性存储了访问主页的response信息,通过下面打印出来
|
||||
print('Response内容:' + response_login.text)
|
||||
|
||||
```
|
||||
|
||||
无论你是不是看得懂上面的两段代码,你都能看出来,这其中有很多代码都是重叠在一起的,这两段代码的结构很相似,但又有明显的差异性。
|
||||
|
||||
## 开始打造一个测试框架
|
||||
|
||||
我想请你先思考这么一个问题,你在用Postman这类工具做接口测试时,除去你自己构建的访问路由和Requsts参数,其他的是不是就靠工具帮你处理完成了呢?
|
||||
|
||||
那么,我们接口测试的脚本,是不是也可以把一些公共的操作,抽象到一个文件中呢?这样你在写测试脚本时,通过拼凑路由、设计Request入参就可以完成接口测试了。在这样的思路之下,我们来一起改造一下刚刚的脚本。
|
||||
|
||||
第一步,你要建立一个叫做common.py的公共的方法类。下面我给出的这段注释详细的代码,就是类似我们使用Postman的公共方法的封装,它可以完成HTTP协议的GET请求或POST请求的验证,并且和你的业务无关。
|
||||
|
||||
```
|
||||
# 定义一个common的类,它的父类是object
|
||||
class Common(object):
|
||||
# common的构造函数
|
||||
def __init__(self):
|
||||
# 被测系统的根路由
|
||||
self.url_root = 'http://127.0.0.1:12356'
|
||||
# 封装你自己的get请求,uri是访问路由,params是get请求的参数,如果没有默认为空
|
||||
def get(self, uri, params=''):
|
||||
# 拼凑访问地址
|
||||
url = self.url_root + uri + params
|
||||
# 通过get请求访问对应地址
|
||||
res = requests.get(url)
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
return res
|
||||
# 封装你自己的post方法,uri是访问路由,params是post请求需要传递的参数,如果没有参数这里为空
|
||||
def post(self, uri, params=''):
|
||||
# 拼凑访问地址
|
||||
url = self.url_root + uri
|
||||
if len(params) > 0:
|
||||
# 如果有参数,那么通过post方式访问对应的url,并将参数赋值给requests.post默认参数data
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
res = requests.post(url, data=params)
|
||||
else:
|
||||
# 如果无参数,访问方式如下
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
res = requests.post(url)
|
||||
return res
|
||||
|
||||
```
|
||||
|
||||
接下来,用你自己的Common类,修改第一个接口的单接口测试脚本,就可以得到下面的代码了。
|
||||
|
||||
```
|
||||
# Python代码中引入requests库,引入后才可以在你的代码中使用对应的类以及成员函数
|
||||
from common import Common
|
||||
# 首页的路由
|
||||
uri = '/'
|
||||
# 实例化自己的Common
|
||||
comm = Common()
|
||||
#调用你自己在Common封装的get方法 ,返回结果存到了response_index中
|
||||
response_index = comm.get(uri)
|
||||
# 存储返回的response_index对象的text属性存储了访问主页的response信息,通过下面打印出来
|
||||
print('Response内容:' + response_index.text)
|
||||
|
||||
```
|
||||
|
||||
从这段代码中你可以看到,与前面对应的单接口测试脚本相比,代码的行数有明显的减少,这也能减少你很多的工作量,与此同时,如果你有任何关于HTTP协议的操作,都可以在Common类中进行修改和完善。
|
||||
|
||||
如果使用你自己刚刚建立的公共类(在我们内部有时候喜欢把它叫做轮子,这是源于一句俚语“不用重复造轮子”,因为Common类就是重复被各个检测代码使用的“轮子”)修改一下第二个接口的单接口测试脚本,代码就会变成下面这个样子:
|
||||
|
||||
```
|
||||
#登录页路由
|
||||
uri = '/login'
|
||||
# username变量存储用户名参数
|
||||
username = 'criss'
|
||||
# password变量存储密码参数
|
||||
password = 'criss'
|
||||
# 拼凑body的参数
|
||||
payload = 'username=' + username + '&password=' + password
|
||||
comm = Common()
|
||||
response_login = comm.post(uri,params=payload)
|
||||
print('Response内容:' + response_login.text)
|
||||
|
||||
```
|
||||
|
||||
当你有一些更加复杂的脚本时,你会发现两次代码的变化会变得更明显,也更易读。
|
||||
|
||||
这就是那些曾经让你羡慕不已的框架诞生的过程,通过分析和观察你可以看到,原始的第一个接口的单接口测试脚本和第二个接口的单接口测试脚本,它们存在相同的部分,通过将这些相同的部分合并和抽象,就增加了代码的可读性和可维护性,也减少了脚本的开发量。通过这个方法,你就可以打造出一个属于自己的测试框架。
|
||||
|
||||
## 用你的框架完成多接口测试
|
||||
|
||||
上面我们仅仅进行了一小步的封装,就取得了很大的进步,在你写出越来越多的脚本后,你还会发现新的重叠部分,这时如果你能不断改进,最终就会得到完全适合你的测试框架,而且其中每一个类、每一个函数你都会非常熟悉,这样,碰到任何一个难解的问题时,你都有能力通过修改你的框架来解决它,这样,这个框架实际上就变成了一个你在接口测试方面的工具箱了。
|
||||
|
||||
那么,怎么用我们刚刚一起搭建的测试框架,来完成多接口测试的业务逻辑测试呢?
|
||||
|
||||
不知道你是不是还记得在上节课中,我们讲到的Battle使用流程的测试用例,如果你没记起来,我先告诉你:“正确登录系统后,选择武器,与敌人决斗后杀死了敌人。”其他的,在本次课程结束后,你可以自己再去温习一下。
|
||||
|
||||
那么。使用我们一起封装的框架来完成上面的多接口测试后,就会得到下面的代码:
|
||||
|
||||
```
|
||||
# Python代码中引入requests库,引入后才可以在你的代码中使用对应的类以及成员函数
|
||||
from common import Common
|
||||
# 建立uri_index的变量,存储战场的首页路由
|
||||
uri_index = '/'
|
||||
# 实例化自己的Common
|
||||
comm = Common()
|
||||
#调用你自己在Common封装的get方法 ,返回结果存到了response_index中
|
||||
response_index = comm.get(uri_index)
|
||||
# 存储返回的response_index对象的text属性存储了访问主页的response信息,通过下面打印出来
|
||||
print('Response内容:' + response_index.text)
|
||||
# uri_login存储战场的登录
|
||||
uri_login = '/login'
|
||||
# username变量存储用户名参数
|
||||
username = 'criss'
|
||||
# password变量存储密码参数
|
||||
password = 'criss'
|
||||
# 拼凑body的参数
|
||||
payload = 'username=' + username + '&password=' + password
|
||||
comm = Common()
|
||||
response_login = comm.post(uri_login,params=payload)
|
||||
print('Response内容:' + response_login.text)
|
||||
# uri_selectEq存储战场的选择武器
|
||||
uri_selectEq = '/selectEq'
|
||||
# 武器编号变量存储用户名参数
|
||||
equipmentid = '10003'
|
||||
# 拼凑body的参数
|
||||
payload = 'equipmentid=' + equipmentid
|
||||
comm = Common()
|
||||
response_selectEq = comm.post(uri_selectEq,params=payload)
|
||||
print('Response内容:' + response_selectEq.text)
|
||||
# uri_kill存储战场的选择武器
|
||||
uri_kill = '/kill'
|
||||
# 武器编号变量存储用户名参数
|
||||
enemyid = '20001'
|
||||
# 拼凑body的参数
|
||||
payload = 'enemyid=' + enemyid+"&equipmentid="+equipmentid
|
||||
comm = Common()
|
||||
response_kill = comm.post(uri_kill,params=payload)
|
||||
print('Response内容:' + response_kill.text)
|
||||
|
||||
```
|
||||
|
||||
上面的代码有点长,但你先不要有抵触的心理,每一个代码行的注释我都写得很清楚。然而我并不是想让你知道,上面那么多类似蝌蚪文的代码都是干什么的,我是想让你看看上面的代码中,是否有可以用前面“抽象和封装重复代码的方法”进行优化的地方。
|
||||
|
||||
你可以看到,上面的代码大量重复了你自己写的通用类的调用,这个其实是可以合成一个的;同时,你再观察一下我们一起写的Common类,你会发现有一个self.url_root = ‘[http://127.0.0.1:12356](http://127.0.0.1:12356)’,如果这里这样写,你的Common就只能用来测试我们这个小系统了,除非你每次都去修改框架。
|
||||
|
||||
但是,任何一个框架的维护者,都不希望框架和具体逻辑强相关,因此这也是一个优化点,那么将上面的内容都修改后,代码就会变成下面这个样子:
|
||||
|
||||
```
|
||||
# Python代码中引入requests库,引入后才可以在你的代码中使用对应的类以及成员函数
|
||||
from common import Common
|
||||
# 建立uri_index的变量,存储战场的首页路由
|
||||
uri_index = '/'
|
||||
# 实例化自己的Common
|
||||
comm = Common('http://127.0.0.1:12356')
|
||||
#调用你自己在Common封装的get方法 ,返回结果存到了response_index中
|
||||
response_index = comm.get(uri_index)
|
||||
# 存储返回的response_index对象的text属性存储了访问主页的response信息,通过下面打印出来
|
||||
print('Response内容:' + response_index.text)
|
||||
# uri_login存储战场的登录
|
||||
uri_login = '/login'
|
||||
# username变量存储用户名参数
|
||||
username = 'criss'
|
||||
# password变量存储密码参数
|
||||
password = 'criss'
|
||||
# 拼凑body的参数
|
||||
payload = 'username=' + username + '&password=' + password
|
||||
response_login = comm.post(uri_login,params=payload)
|
||||
print('Response内容:' + response_login.text)
|
||||
# uri_selectEq存储战场的选择武器
|
||||
uri_selectEq = '/selectEq'
|
||||
# 武器编号变量存储用户名参数
|
||||
equipmentid = '10003'
|
||||
# 拼凑body的参数
|
||||
payload = 'equipmentid=' + equipmentid
|
||||
response_selectEq = comm.post(uri_selectEq,params=payload)
|
||||
print('Response内容:' + response_selectEq.text)
|
||||
# uri_kill存储战场的选择武器
|
||||
uri_kill = '/kill'
|
||||
# 武器编号变量存储用户名参数
|
||||
enemyid = '20001'
|
||||
# 拼凑body的参数
|
||||
payload = 'enemyid=' + enemyid+"&equipmentid="+equipmentid
|
||||
response_kill = comm.post(uri_kill,params=payload)
|
||||
print('Response内容:' + response_kill.text)
|
||||
是不是比上一个节省了很多代码,同时也看的更加的易读了,那么我们封住好的Common就变成了如下的样子:
|
||||
|
||||
# 定义一个common的类,它的父类是object
|
||||
|
||||
class Common(object):
|
||||
|
||||
# common的构造函数
|
||||
|
||||
def __init__(self,url_root):
|
||||
|
||||
# 被测系统的跟路由
|
||||
|
||||
self.url_root = url_root
|
||||
|
||||
|
||||
|
||||
# 封装你自己的get请求,uri是访问路由,params是get请求的参数,如果没有默认为空
|
||||
|
||||
def get(self, uri, params=''):
|
||||
|
||||
# 拼凑访问地址
|
||||
|
||||
url = self.url_root + uri + params
|
||||
|
||||
# 通过get请求访问对应地址
|
||||
|
||||
res = requests.get(url)
|
||||
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
|
||||
|
||||
|
||||
return res
|
||||
|
||||
# 封装你自己的post方法,uri是访问路由,params是post请求需要传递的参数,如果没有参数这里为空
|
||||
|
||||
def post(self, uri, params=''):
|
||||
|
||||
# 拼凑访问地址
|
||||
|
||||
url = self.url_root + uri
|
||||
|
||||
if len(params) > 0:
|
||||
|
||||
# 如果有参数,那么通过post方式访问对应的url,并将参数赋值给requests.post默认参数data
|
||||
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
|
||||
res = requests.post(url, data=params)
|
||||
|
||||
else:
|
||||
|
||||
# 如果无参数,访问方式如下
|
||||
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
|
||||
res = req
|
||||
|
||||
```
|
||||
|
||||
你可以看到,在上面这段代码中,我主要是让我们Common类的构造函数接受了一个变量,这个变量就是被测系统的根路由。这样是不是就比上一个代码段节省了很多代码,同时也更加易读了?那么我们封装好的Common就变成了下面这个样子:
|
||||
|
||||
```
|
||||
# 定义一个common的类,它的父类是object
|
||||
class Common(object):
|
||||
# common的构造函数
|
||||
def __init__(self,url_root):
|
||||
# 被测系统的跟路由
|
||||
self.url_root = url_root
|
||||
# 封装你自己的get请求,uri是访问路由,params是get请求的参数,如果没有默认为空
|
||||
def get(self, uri, params=''):
|
||||
# 拼凑访问地址
|
||||
url = self.url_root + uri + params
|
||||
# 通过get请求访问对应地址
|
||||
res = requests.get(url)
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
return res
|
||||
# 封装你自己的post方法,uri是访问路由,params是post请求需要传递的参数,如果没有参数这里为空
|
||||
def post(self, uri, params=''):
|
||||
# 拼凑访问地址
|
||||
url = self.url_root + uri
|
||||
if len(params) > 0:
|
||||
# 如果有参数,那么通过post方式访问对应的url,并将参数赋值给requests.post默认参数data
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
res = requests.post(url, data=params)
|
||||
else:
|
||||
# 如果无参数,访问方式如下
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
res = requests.post(url)
|
||||
return res
|
||||
|
||||
```
|
||||
|
||||
通过改造Common类的构造函数,这个类已经变成一个通用类了,无论是哪一个项目的接口测试,都可以使用它来完成HTTP协议的接口验证了。
|
||||
|
||||
我相信现在你已经掌握了测试框架的形成过程,就如下图所示,测试框架的形成是在撰写大量测试脚本的过程中不断抽象封装出来的,然后,再用这个不断完善的框架,改写原有的测试脚本。循环往复这个过程,你就会慢慢获得一个独一无二的、又完全适合你工作的接口测试框架。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/7e/d6066b2a101012f4fac20160d386a57e.jpg" alt="">
|
||||
|
||||
其实到这里,我们上面说的只能算是一个调试代码,还不能算是一个测试框架。上面这些代码所有的返回值都打印到控制台后,为了完成接口测试,你需要时时刻刻看着控制台,这还不能算是自动化,只能说是一个辅助小工具。
|
||||
|
||||
在这里,你应该让全部测试结果都存储到测试报告里面,同时通过一个测试驱动框架来完成各个模块的驱动,这也是为什么你在学习任何一种框架的时候,总会遇见类似Java的JUnit、Python的Unittest的原因,因此,上面的Common类还需要和Python的unittest一起使用,才算是一个完美的测试框架。
|
||||
|
||||
至于你自己的Common类怎么和测试驱动框架相结合,这部分内容就留给你在未来的接口测试工作中,自己去学习并完成了。
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们一起学习了一个测试框架的诞生过程。测试框架就是在你测试脚本中不断抽象和封装得来的。今天我们课程的内容充斥着各种代码,如果你的代码基础稍微比较薄弱,并没有完全记住上面的内容,那么我希望你记住从测试脚本到测试框架的转化过程:
|
||||
|
||||
1. 不断撰写测试脚本,所有的抽象和封装都是站在已有的测试脚本基础之上的;
|
||||
1. 多观察已经写好的测试脚本,找出其中的重叠部分,最后完成封装;
|
||||
1. 以上两步是一个不断循环又循序渐进的过程,你要在你的工作中始终保持思考和警惕,发现重复马上进行框架封装。
|
||||
|
||||
最后我想和你强调的是,测试框架的封装和抽象过程并不是一蹴而就的,它是靠一点一点的积累得来的,因此,你要通过自己的实践,慢慢积累和完善你的测试框架,而不要妄想一次就能有一个完善的测试框架。我相信,当你通过写脚本完成整个项目的接口测试后,你一定会得到一个完美的测试框架。
|
||||
|
||||
## 思考题
|
||||
|
||||
在我讲的最后一个多接口测试脚本,其实也并不是最完美的修改,你能提出更好的修改意见吗?如果它可以抽取到你的框架中,那么是完成一个什么样任务的类或者函数呢?
|
||||
|
||||
我是陈磊,欢迎你在留言区留言分享你的观点,如果这篇文章让你有新的启发,也欢迎你把文章分享给你的朋友,我们一起探讨和学习。
|
||||
126
极客时间专栏/接口测试入门课/综合技能篇/05 | 案例:测试框架如何才能支持RESTful风格的接口?.md
Normal file
126
极客时间专栏/接口测试入门课/综合技能篇/05 | 案例:测试框架如何才能支持RESTful风格的接口?.md
Normal file
@@ -0,0 +1,126 @@
|
||||
<audio id="audio" title="05 | 案例:测试框架如何才能支持RESTful风格的接口?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/0f/3a36390dd9c9b0d8a1ca43481f4bf50f.mp3"></audio>
|
||||
|
||||
你好,我是陈磊。
|
||||
|
||||
在前面的课程中,我们一起学习了如何把流程化的测试脚本,一步一步抽象成你自己的测试框架。无论你用的是什么编程语言,封装和抽象自己的测试框架都会让你的接口测试任务事半功倍。
|
||||
|
||||
我相信你在平时生活或工作中,一定会接触到各式各样的软件系统,而现在的软件系统和5年前相比,最大差别就在于结构不同。
|
||||
|
||||
在我读大学的时候,绝大部分系统还都是用一个Tomcat来搞定的;但现在的系统更加复杂,它们已经无法只用一个Web中间件独立对外提供服务,它们之间都也是通过相互调用来完成业务逻辑的,这里面既包含了服务端和服务端的调用,也包含了前端和服务端的调用,这就催生了RESTful风格的HTTP接口。
|
||||
|
||||
所以,这节课我就来和你讲讲,如何让你的测试框架完美支持RESTful风格的接口测试。这里我希望你能不断强化封装测试框架的三个流程,不断为自己的接口测试框架添砖加瓦。
|
||||
|
||||
不过,我不会将RESTful的规则一条一条念给你听,我想让你知道的重点是作为测试工程师,你要学会从测试工程师的角度观察RESTful接口,要学会怎么分析和验证这类接口,这也是今天我们今天这节课的主要内容。
|
||||
|
||||
## RESTful风格接口关我什么事?
|
||||
|
||||
看到这里,你是不是一脸困惑:**RESTful是一个接口的封装风格,和我们测试人员有什么关系呢?**
|
||||
|
||||
要想理解它和我们测试工程师的关系,你就要先知道RESTful风格的接口到底有什么好。
|
||||
|
||||
如果你用螺丝、钉子和板材等一系列原材料组装过家具,那么你肯定见到过各种千奇百怪的螺丝,比如一字的、十字的、三角形的、六角形的和海星形的等等,为了加固这些各式各样的螺丝,你就要准备各式各样的螺丝刀,因此,你的工具箱就会被不同规格和大小的螺丝刀填满。
|
||||
|
||||
不知道你是不是也和我一样,面对塞满螺丝刀的、乱七八糟的工具箱,心里非常急躁。但后来我在宜家看到一款螺丝刀,它只有一个刀柄,但给你提供了一整套各种形状、各种大小的螺丝刀刀头。
|
||||
|
||||
这样你在使用时,只要根据螺丝规格的不同,选择替换同形状的刀头就可以了;与此同时,它们放在工具箱里面又会显得很整齐,而不会七零八落。而且,如果你后续需要使用其它特殊形状的螺丝刀,你只要买和刀柄连接口一样的刀头就可以了,而不用再买一个完整的螺丝刀了。
|
||||
|
||||
如果你理解了上面这个场景,也就能很好地理解RESTful风格的接口了。**它主要就是一组设计原则和约束条件,本质上就是让消费者依据URI就可以找到资源,并通过简单的服务输入输出完成服务的交互。**
|
||||
|
||||
它所约束的每一个URI,都是独一无二的一个资源,通过HTTP的方法进行资源操作,实现表现层的状态转化。这就和螺丝刀刀头一样,待解决的问题就像螺丝,每一个接口只面向一种特定的资源,而不需要关心其他接口的处理方式,这样,你就能够一目了然地知道,该用哪种螺丝刀头拧哪种螺丝了,这就降低了接口开发的复杂度。
|
||||
|
||||
软件开发人员只要遵循RESTful的实践标准,按照一定的内部定义开发外接口,就会形成类似螺丝刀刀头一样轻便的接口,对外提供服务。现在的很多项目,无论是服务端和服务端的调用,还是前端和服务端的调用,都采用了这一种方式来设计接口。
|
||||
|
||||
对于我们测试工程师来说,RESTful风格的接口还是之前的访问模式,它同样是一种HTTP协议的接口,同样可以使用我们上节课一起封装的框架完成接口测试的任务。
|
||||
|
||||
但是,它和我们之前讲过的HTTP协议的接口测试还是有一些区别,这些区别导致了你现在的框架还需要做一些修改,这样才能让它支持RESTful风格的接口测试。
|
||||
|
||||
## 让你的框架可以测试一个RESTful风格接口
|
||||
|
||||
现在,你知道RESTful接口和你的接口测试有很大关系了,那么,RESTful接口的测试和原始的HTTP协议接口的测试,又有什么区别呢?
|
||||
|
||||
这里面有两部分需要你特别关注:**数据交换的承载方式和操作方式**。
|
||||
|
||||
我先说说数据交换的承载方式,RESTful风格的接口主要是以JSON格式来进行数据交换,如果你还记得我之前提过的“Battle”这个系统(你可以回到[03](https://time.geekbang.org/column/article/193912)中查看它),那么你一定在它的Readme.md中,看到了Request和Response对数据部分的一些定义,那就是JSON。虽然“战场”不能算是一个严格的RESTful接口的系统,但是,在数据交接的承载方式上,它模仿了RESTful的样子。
|
||||
|
||||
另外一个部分是操作方式,在“战场”系统中,我们用了HTTP协议的Get和Post,其实HTTP协议有很多方法,但是我们仅仅用了这两种,而RESTful的规定,使HTTP的很多方法都被利用到了,比如说,Get方法用来获取资源,Post方法用来新建资源(或者更新资源);再比如说,Put方法用来更新资源、Delete方法用来删除资源等等。
|
||||
|
||||
在明白RESTful风格的接口和普通的HTTP协议接口的区别后,我们现在来想一想,你自己的框架需要添加什么内容,才能支持RESTful风格的接口呢?**这里我总结了两个方法:借助外力和自己封装。**
|
||||
|
||||
### 借助外力
|
||||
|
||||
这里,我们RESTful的第一个数据交换的承载方式是JSON,我们的框架在之前的“战场”系统中就已经使用了它,虽然全部的操作都是参数拼凑的过程,但这已经满足了我们的需求。
|
||||
|
||||
这时如果你仍要拼凑很多复杂的数据,就需要使用JSON字符串和代码对象实体的转换,它有一个专业的叫法:**序列化和反序列化**。这个词语听着就很难理解,所以现在,我用一个生活中的小例子来告诉你,这个晦涩难懂的概念到底是什么意思。
|
||||
|
||||
如果你在商场看中了一款衣柜,但它很大,为了方便运输,就必须要先把它拆掉,运到家后再重新组装。你和商家协商好了,由他们为你把这个衣柜拆成可重组的零件运到家里,然后由你自己把这些零件重新组装成一个衣柜。
|
||||
|
||||
那么在这里,商家把衣柜拆成各个零件、然后打包的这个过程就是“序列化”,在代码中,就是将一些程序对象转换成JSON等格式的字符串的过程。接下来,你用这些零件再重新组装成一个衣柜,这个过程就是“反序列化”,在代码中,就是JSON等格式的字符串转换成程序的对象的过程。
|
||||
|
||||
为了能让你的框架可以快速完成序列化和反序列化,我建议你在代码中引入一个外部支持的库,就像Python有JSON库、Java有Fastjson库。这些公开的库其实都不需要做任何的修改,就可以拿来使用,所以,无论你使用哪种技术栈,这样的基础库都是存在的,你只需要在网上找一下,然后花几分钟看一下怎么使用,就可以拿到自己的框架里使用了。
|
||||
|
||||
### 自己封装
|
||||
|
||||
现在,我们已经可以借助开源库,解决数据交换的事情了,但是,RESTful风格接口和普通HTTP接口相比,还有一个明显的区别,那就是RESTful规定了HTTP的每一个方法都做固定的事情,可我们原来框架中的Common类却只支持Get和Post方法,因此,你需要在Common类中加入Delete和Put方法的支持。具体的操作你可以依据下面这个代码段来完成:
|
||||
|
||||
```
|
||||
def put(self,uri,params=None):
|
||||
'''
|
||||
封装你自己的put方法,uri是访问路由,params是put请求需要传递的参数,如果没有参数这里为空
|
||||
:param uri: 访问路由
|
||||
:param params: 传递参数,string类型,默认为None
|
||||
:return: 此次访问的response
|
||||
'''
|
||||
url = self.url_root+uri
|
||||
if params is not None:
|
||||
# 如果有参数,那么通过put方式访问对应的url,并将参数赋值给requests.put默认参数data
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
res = requests.put(url, data=params)
|
||||
else:
|
||||
|
||||
# 如果无参数,访问方式如下
|
||||
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
|
||||
res = requests.put(url)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def delete(self,uri,params=None):
|
||||
'''
|
||||
封装你自己的delete方法,uri是访问路由,params是delete请求需要传递的参数,如果没有参数这里为空
|
||||
:param uri: 访问路由
|
||||
:param params: 传递参数,string类型,默认为None
|
||||
:return: 此次访问的response
|
||||
'''
|
||||
url = self.url_root + uri
|
||||
if params is not None:
|
||||
# 如果有参数,那么通过delete方式访问对应的url,并将参数赋值给requests.delete默认参数data
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
res = requests.delete(url, data=params)
|
||||
else:
|
||||
# 如果无参数,访问方式如下
|
||||
# 返回request的Response结果,类型为requests的Response类型
|
||||
res = requests.delete(url)
|
||||
return res
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,你可以看到,我们为了实现HTTP协议的Put和Delete方法,自己封装了put()函数和delete()函数。其实,要实现RESTful风格的接口测试,你只要封装HTTP协议对应的Method方法就可以了,这样,你的框架就能完美的支持RESTful风格的接口了。完成了这个操作后,我们的Common类就既可以完成HTTP协议接口的测试,也可以完成RESTful接口的测试了。
|
||||
|
||||
## 总结
|
||||
|
||||
到这里,我们已经结束了今天的课程了。我们今天主要完成了RESTful风格接口的测试,对比之前的例子以及你自己的测试框架,针对框架中RESTful里缺失的部分,我为你提供了对应的解决方法。
|
||||
|
||||
在文中我讲了很多内容,但是完成RESTful风格接口测试,主要是通过两步操作,来为你的测试框架添加对应接口的测试能力的:
|
||||
|
||||
1. 借助外力。目前网上已经有很多成熟的、各式各样的支持库,你要尽量拿来为己所用,而不要从零建设,这样,既弥补了我们开发能力不强的短板,也能提高我们的研发效率。
|
||||
1. 自己封装。你要注意的是,自己封装和借助外力并不互相冲突,你要借助外力,然后将它封装到你自己的框架中,这是一个借力打力的好方法。
|
||||
|
||||
随着我们的课程的不断深入以及内容的不断丰富,我相信,你最终会获得一个完全适合你自己,又可以解决实际工作任务的测试框架,这也是你自己的接口测试武器仓库,里面有解决各种接口测试问题的方法。它会是一个私有仓库,里面每一个武器都是为你自己量身定制的,因此,每一件武器你用起来都会更得心应手。
|
||||
|
||||
## 思考题
|
||||
|
||||
我今天讲了RESTful接口测试,并为你的私有测试框架添加了各式各样的新武器,那么,你能用你现在的新武器,解决一个你负责的RESTful的接口测试吗?在今天的框架中,随着你实际工作的使用,你又有了什么样的新设计呢?
|
||||
|
||||
我是陈磊,欢迎你在留言区留言分享你的观点,如果这篇文章让你有新的启发,也欢迎你把文章分享给你的朋友,我们一起探讨和学习。
|
||||
167
极客时间专栏/接口测试入门课/综合技能篇/06 | 接口测试平台:工具和框架不可以兼容?.md
Normal file
167
极客时间专栏/接口测试入门课/综合技能篇/06 | 接口测试平台:工具和框架不可以兼容?.md
Normal file
@@ -0,0 +1,167 @@
|
||||
<audio id="audio" title="06 | 接口测试平台:工具和框架不可以兼容?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/77/351b551ef09295f90729b9151d760e77.mp3"></audio>
|
||||
|
||||
你好,我是陈磊。很高兴在接口测试课程中再次遇见你。
|
||||
|
||||
到目前为止,我们的课程重点介绍了完成测试任务的两种接口测试手段,第一种是使用如Postman这样的工具,第二种是打造属于你自己的测试框架。上节课我们还一起学习了RESTful风格的接口,并针对它的特点完善了我们自己的测试框架。
|
||||
|
||||
这节课我就教你如何用工具和框架的组合搭建接口测试平台,让你能更快速地完成测试任务。
|
||||
|
||||
## 工具的便捷性与框架的灵活性可以兼得
|
||||
|
||||
说到这儿,你一定有一个困惑,在前面我先讲了Postman这款非常好用的HTTP测试工具,后来又讲了怎么自己动手封装接口测试框架,它们各有特点,比如工具有便捷性,框架有灵活性,这无疑是两条都可以通向罗马的路,是两种都可以完成接口测试工作的方法,那学会一个不就可以了,为什么两个都要学会呢?
|
||||
|
||||
而且工具和框架,这两件事看起来互不相干,甚至有些互相排斥,那么这两种接口测试技术手段能相互支持,能融合到一起吗?下面我就来回答这个问题。
|
||||
|
||||
其实,工具和框架,这两条通向罗马的路可以并成一条快速通道,让你大踏步进军罗马。所以我既建议你要掌握一款好用的工具比如Postman,也建议你用自己的技术沉淀出自己的框架,如果你能正确地混合使用它们,实质上就可以搭建起一个接口测试平台,帮你更快速地完成测试任务。
|
||||
|
||||
在脚本的设计过程中,这样做有两个好处。
|
||||
|
||||
一是能充分发挥Postman界面化的优势,快速完成大量的脚本撰写工作;二是通过你自己的框架完成测试脚本的执行,所有的过程代码都会存储到你自己的代码仓,这样,既可以留下测试的过程资产,也便于版本控制,这也为持续集成、持续交付等平台提供了无人值守的、按需驱动测试的途径。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/06/d2658558e998a79c9bded28fb129cd06.jpg" alt="">
|
||||
|
||||
此外,这样做也可以提升团队的工作效率。
|
||||
|
||||
在我的团队中,有一些小伙伴很不喜欢写代码,相反,他们更喜欢使用工具,工具用得也非常溜,我相信在你的团队中也会存在这样的情况。但是,仅仅依靠工具,只能一个人完成一件事情,这并不方便团队内部的团队合作、交接和技术积累。
|
||||
|
||||
但是,我们又没有办法让所有人一下子都喜欢上写代码,那么该如何降低代码编写门槛呢?通过工具和框架搭建接口测试平台,其实就是一个很好的解决方案。这样,你既可以让你的团队有技术积累,又能给团队中一些编码能力比较薄弱的小伙伴学习时间,最重要的一点是,这不会影响整个工作的进度。
|
||||
|
||||
## 工具的便捷性可得
|
||||
|
||||
不知道在学完前面的课程后,你是不是还用过自己的Postman,当你再次打开Postman的时候会发现,你之前用它来完成的测试脚本是被保存下来了的,就如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/f0/f08eff06a9649715d683ac72793878f0.png" alt="">
|
||||
|
||||
可以看到,我上次使用Postman测试“战场”系统的脚本还是存在的,如果你忘记了,可以在课后看一看你用来做测试的Postman,再分别看看那四个请求。
|
||||
|
||||
到这里,我想请你先闭上眼睛回想一下,你之前使用Postman做接口测试的整个流程,是不是清晰可见?你也可以同时回忆一下,你用自己封装的Common类,编写“战场”系统测试脚本的过程。你会发现,和工具的使用过程相比,在这里你不太容易回想出自己每一步都做了什么。
|
||||
|
||||
这也是UI操作和代码操作的区别之一,UI操作更加直观,可以在你的脑海里留下更深刻的印象;而代码操作给人留下的印象就比较模糊,但是,通过用代码写脚本来完成接口测试,比较便于维护、团队合作以及留存。
|
||||
|
||||
讲到这你肯定会问:“你把用Postman类工具完成接口测试,以及自我封装测试框架这两种方法各打五十大板,那它们到底哪个好?”其实我的目的并不是想让你分出个好坏,好坏之分都是相对的,每个人的习惯和喜好都不相同,但是我们却可以把它们的优点都利用好,把这两种技术的优势都发挥出来。
|
||||
|
||||
我们利用Postman设计接口测试直观、快速的优势,将它变成接口测试脚本的初始脚本的编写工具,其实Postman也可以配置Chrome插件录制请求,这些在Postman官方已经有很详细的介绍,所以我就不在这里详细讲解了,如果你感兴趣,课后可以自行学习。
|
||||
|
||||
我们以之前的测试脚本为例,选择第一个单接口接口测试的脚本,在右侧点击Code按钮。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/f3/c800540c1ca21ef7d362bacc88bbc8f3.png" alt="">
|
||||
|
||||
在弹出框中,你可以选择各式各样技术栈的测试脚本,在这里,我们还是用在之前例子中所选取的Python,我们框架的依赖库是Requests,这样你就可以看到显示出的代码了,就如下图所示。看到这些代码,你是不是已经开始觉得,通过这样的处理来编写脚本更加容易。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/95/2174ecbad6cefba4d166f12a1f21aa95.png" alt="">
|
||||
|
||||
由此可见,和写代码相比,使用Postman来设计接口测试要更容易使用,对于代码基础比较薄弱的测试工程师来说,这种方法也更容易掌握。
|
||||
|
||||
## 框架的灵活性亦可得
|
||||
|
||||
在刚刚高兴的心情慢慢冷静下来以后,你是不是在心里默默地埋怨我?既然有这么简单的方法,为什么还一直让你学习一门编码技术,还建议你如果什么都不会,可以学习一下Python?这是因为工具生成的代码可读性特别差,也并不适合我们将它作为团队的技术积累留存。
|
||||
|
||||
现在,我们一起看一看由工具生成的代码。先来看看第一个接口首页单接口对应的代码:
|
||||
|
||||
```
|
||||
import requests
|
||||
|
||||
|
||||
url = "http://127.0.0.1:12356"
|
||||
|
||||
|
||||
headers = {
|
||||
'cache-control': "no-cache",
|
||||
'Postman-Token': "8c6247bb-744a-43d3-b27d-9e51af923c5d"
|
||||
}
|
||||
|
||||
|
||||
response = requests.request("GET", url, headers=headers)
|
||||
|
||||
|
||||
print(response.text)
|
||||
|
||||
```
|
||||
|
||||
上面的这个代码你是不是似曾相识?这就和我们第一次写的第一个接口的单接口测试代码一样,是一个流水账一样的脚本,这些代码如果原模原样地存到你的代码仓中,对你再次使用它没什么好处。那么在这基础上,我们可以将它修改成自己框架的脚本,就如下面这段代码所示:
|
||||
|
||||
```
|
||||
# 引入你的框架
|
||||
from common import Common
|
||||
#访问uri
|
||||
uri_index = "/"
|
||||
#调用你的Common类
|
||||
comm = Common('http://127.0.0.1:12356')
|
||||
# 完成方法
|
||||
response_login = comm.get(uri_index)
|
||||
# 打印response结果
|
||||
print('Response内容:' + response_login.text)
|
||||
|
||||
```
|
||||
|
||||
这个代码你是不是很亲切?Common类可是我们的老朋友了。那么接下来,我们再看看第二个接口登录的单接口测试脚本,你可以用相同的方法,找到它的Python代码,为了方便有些不是很方便打开自己Postman的同学,我把对应的代码放到了下面:
|
||||
|
||||
```
|
||||
import requests
|
||||
|
||||
|
||||
url = "http://127.0.0.1:12356/login"
|
||||
|
||||
|
||||
payload = "username=criss&password=criss"
|
||||
|
||||
|
||||
headers = {
|
||||
|
||||
|
||||
'cache-control': "no-cache",
|
||||
|
||||
|
||||
'Postman-Token': "fdc805e1-4406-4191-ae44-ab002e475e03"
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
response = requests.request("POST", url, data=payload, headers=headers)
|
||||
|
||||
|
||||
print(response.text)
|
||||
|
||||
```
|
||||
|
||||
如果你还记得我在测试代码及框架那一节课(也就是[04](https://time.geekbang.org/column/article/195483))中讲的内容,就会发现,它和那里最开始部分的代码实现几乎一致,这和我们自己手动写的代码最大区别就是,它少了很多注释,而多出一些访问头信息,也就是上面代码的headers。
|
||||
|
||||
headers在我们“战场”系统的测试中并不是必须传递的参数,但是Postman这种工具会将其添加默认值传递给服务器。这是由这个工具添加的,你在写脚本时,如果它是非必填的,你可以忽略它。但是,工具这么做是为了匹配所有的情况,所以它会做一些和我们这次测试不相干的工作。
|
||||
|
||||
难道说Postman这么好的功能,对我们来说就没有一点好处吗?其实我们在上面代码的基础上,将其修改成引入我们自己框架的测试代码,完成修改后,再推送到接口测试项目的代码仓中,就如下面这个代码所示:
|
||||
|
||||
```
|
||||
from common import Common
|
||||
uri = "/login"
|
||||
payload = "username=criss&password=criss"
|
||||
comm = Common('http://127.0.0.1:12356')
|
||||
response_login = comm.post(uri_login,params=payload)
|
||||
print('Response内容:' + response_login.text)
|
||||
|
||||
```
|
||||
|
||||
这也无疑加快了我们测试脚本的编写,在上面这个过程中,我们也很容易再次发现需要封装到框架中的公共方法,这样循环下来,就加快了我们测试脚本的积累速度,同时我们也就可以有更多时间用在框架的维护上了。
|
||||
|
||||
通过代码的改写和封装,我们就可以将工具生成的代码完美结合到我们的框架中了,当我们需要修改已经存储在代码仓库的脚本时,我们只需pull 代码仓的代码,就可以看到易读、易维护的测试脚本了。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天的课程到这里就结束了,现在你闭上眼睛回顾一下,如果你的头脑中就只有用Postman快速编写脚本,自己框架留存执行的话,只能说明你今天学习得很认真,但是并没接受我想告诉你的主旨思想。
|
||||
|
||||
我今天以Postman工具和你自己的框架相结合的例子,告诉你如何建立一个你自己的测试平台,你可以通过三步完成工具加框架的组合方式:
|
||||
|
||||
1. 借助Postman这类工具的易学、易操作的特点,将它变成你测试脚本中快速创建的脚本撰写工具;
|
||||
1. 利用工具提供的导出代码功能,将其导出成我们流程化的测试代码;
|
||||
1. 通过我们自己的框架,改写我们通过工具导出的脚本。
|
||||
|
||||
最后,你的测试脚本可以存入代码仓中为持续集成平台提供持续验证,这就完成了一套简单又灵活的接口测试平台的建设。
|
||||
|
||||
实际上,在本节课中,我更希望帮你建立一种解决问题思路,测试工程师的技术普遍会稍微弱于开发工程师,你要善于利用各种技术手段来帮助自己解决问题。
|
||||
|
||||
无论你团队中的小伙伴是用Postman生成测试脚本,再通过修改集成自己的框架,还是直接通过框架写测试脚本,它们都殊途同归,都是以最终统一的方式推送到了代码仓库中。这样,就不会让代码能力变成阻塞最终工作的一个关键节点,同时,这对于使用Postman编写脚本的小伙伴来说,他们也会越来越熟悉自己的框架,逐渐提升自己的技术能力,并加强自己的代码能力。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的课程中,我们把“战场”系统的测试脚本又拿出来测试了一次,不知道你是不是有点厌烦了,今天我仅仅给你演示了第一个和第二个单接口测试脚本,它们从工具到框架的演变过程,那么,你可以将后面的两个单接口测试脚本自己完成,并在留言区回复给我吗?如果你已经开始将这个方法应用到你的工作中了,那么我也希望你能将自己在使用过程中的心得体会分享给我。
|
||||
|
||||
我是陈磊,欢迎你在留言区留言分享你的观点,如果这篇文章让你有新的启发,也欢迎你把文章分享给你的朋友,我们一起来探讨和学习。
|
||||
Reference in New Issue
Block a user