RESTful API是互联网时代最流行的通信架构,其结构清晰,传输数据高效,因此越来越多地应用于Web服务。REST是2000年Roy Thomas Fielding在他的博士论文中提出的,即Representational State Transfer的缩写,意思是表现层状态转化。
“表现层”其实指的是“资源”(resource)的“表现层”。所谓资源,就是网络上的一个实体,可以是一段文本,一张图片,一段视频,或一种服务。
客户端为了获取资源或操作资源,需要通过HTTP发送请求。目前有4种请求方式,分别为POST、GET、PUT和DELETE。其中,GET用于资源获取操作,POST用于新建资源,PUT用于更新资源,DELETE用于删除资源。
常见的RESTful架构如图所示。用户从客户端发起不同类型的HTTP请求,API服务器请求并获取数据库资源,然后继续完成其他业务操作。由于数据库不是重点,因此在图中被隐去。
了解了RESTful API的架构后,再介绍一下RESTful的设计风格。RESTful的设计风格主要体现在请求的网址上。那么符合RESTful设计风格的请求网址是什么样的呢?举一个简单的例子:原始网址为/get_user?id=3,改为符合RESTful设计风格的URL,结果为/user/3,改后的结构和语义更加清晰。
为了进一步讲解RESTful API的设计和实现方法,针对用户登录后的文章管理模块进行测试,然后再搭建一个自制的自动化框架。
针对文章管理模块设计一个完整的API非常简单,初步的构想如下表所示。
这里的文章API设计仅作为设计思路展示,后续具体实现时可以在此基础上进行调整。
本项目是用Flask搭建的RESTful API项目,只有一个启动脚本文件,命名为app.py,代码如下:
/rest_api_test/app.py:
from flask import Flask, jsonify, abort, request
app = Flask(__name__)
articles = [
{
'id': 1,
'title': u'三重门',
'author': u'韩寒',
'price': 20
},
{
'id': 2,
'title': u'黄金时代',
'author': u'王小波',
'price': 35
}
]
@app.route('/my_app/api/v1/articles', methods=['GET'])
def get_articles():
# 获取所有文章的数据
return jsonify({'articles': articles})
@app.route('/my_app/api/v1/articles/', methods=['GET'])
def get_article_by_id(id):
# 通过文章ID获取某篇文章
for article in articles:
if article['id'] == id:
return jsonify({'article': article})
abort(404)
@app.route('/my_app/api/v1/articles/', methods=['POST'])
def create_articles():
# 创建文章
if not request.form or not 'title' in request.form:
abort(400)
article = {
'id': articles[-1]['id'] + 1,
'title': request.form['title'],
'author': request.form['author'],
'price': request.form['price'],
}
articles.append(article)
return jsonify({'book': article}), 201
@app.route('/my_app/api/v1/articles/', methods=['PUT'])
def update_article_by_id(id):
# 根据文章ID进行更新
for article in articles:
if article['id']==id:
article["title"] = request.form['title']
article["author"] = request.form['author']
article["price"] = request.form['price']
return jsonify({'articles': articles})
abort(400)
@app.route('/my_app/api/v1/articles/', methods=['DELETE'])
def delete_article(id):
# 删除指定的文章
for article in articles:
if article['id'] == id:
articles.remove(article)
return jsonify({'result': True})
abort(404)
return jsonify({'result': True})
if __name__ == '__main__':
app.run()
这里采用本地化变量存储,在上面的代码中使用数组articles存储文章数据。
执行python app.py命令,输出结果如下:
* Serving Flask app "app" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
分析上面的代码,总结文章接口设计,如下表所示。
总体来说,表中对文章管理的各个功能都有所涉及,URL的设计也应该遵守RESTful API的实际原则,体现资源的名词性(即资源尽量使用名词来表示,如文章就是article)和可读性,通过不同的HTTP方法来区分不同的操作请求,通过“版本号/资源/参数”来定义标准化的URL,使结构清晰。
其中,要特别讲到的是PUT和DELETE请求方式。PUT用于更新数据中的一部分数据,因此在更新接口中采用了PUT请求方式。它与通常使用的POST请求方式不同,更强调修改的含义而不是新增操作,语义性更强。
DELETE是HTTP 1.1之后新增的HTTP请求类型,专门用于删除请求。这些不同的请求类型可以通过编写脚本程序或者利用自动化工具来实现。当然Linux下的cURL命令也可以模拟上述所有类型的请求。
这里主要处理的就是这些接口,接下来需要针对不同的接口编写测试用例。
为了后续对测试结果和相关数据进行采集,需要先做一些配置的代码编写工作,例如对需要记录的文章数据进行持久化存储,对请求接口的记录和返回结果进行存储等,这样可以方便后续分析和研究。
首先编写一个MySQL操作类,代码如下:
/libs/SmartMySQL.py:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import mysql.connector
import time, re
from mysql.connector import errorcode
class SmartMySQL:
"""Smart python class connects to MySQL. """
# db配置,如账号、密码和数据库名
_dbconfig = None
_cursor = None
_connect = None
# MySQL错误码
_error_code = ''
# 30s后的超时设置
TIMEOUT_DEADLINE = 30
TIMEOUT_THREAD = 10 # threadhold of one connect
TIMEOUT_TOTAL = 0 # total time the connects have waste
# 初始化
def __init__(self, dbconfig):
try:
self._dbconfig = dbconfig
self.check_dbconfig(dbconfig)
self._connect = mysql.connector.connect(user=self._dbconfig
['user'], password=self._dbconfig['password'],
database=self._dbconfig['db'])
except mysql.connector.Error as e:
print(e.msg)
if e.errno == errorcode.ER_BAD_DB_ERROR:
print("Database dosen't exist, check it or create it")
# 重试
if self.TIMEOUT_TOTAL < self.TIMEOUT_DEADLINE:
interval = 0
self.TIMEOUT_TOTAL += (interval + self.TIMEOUT_THREAD)
time.sleep(interval)
self.__init__(dbconfig)
raise Exception(e.errno)
self._cursor = self._connect.cursor
print("init success and connect it")
# 检查数据库配置是否正确
def check_dbconfig(self, dbconfig):
flag = True
if type(dbconfig) is not dict:
print('dbconfig is not dict')
flag = False
else:
for key in ['host', 'port', 'user', 'password', 'db']:
if key not in dbconfig:
print("dbconfig error: do not have %s" % key)
flag = False
if 'charset' not in dbconfig:
self._dbconfig['charset'] = 'utf8'
if not flag:
raise Exception('Dbconfig Error')
return flag
# 执行SQL
def query(self, sql, ret_type='all'):
try:
self._cursor.execute("SET NAMES utf8")
self._cursor.execute(sql)
if ret_type == 'all':
return self.rows2array(self._cursor.fetchall())
elif ret_type == 'one':
return self._cursor.fetchone()
elif ret_type == 'count':
return self._cursor.rowcount
except mysql.connector.Error as e:
print(e.msg)
return False
def dml(self, sql):
'''update or delete or insert'''
try:
self._cursor.execute("SET NAMES utf8")
self._cursor.execute(sql)
self._connect.commit()
type = self.dml_type(sql)
if type == 'insert':
return self._connect.insert_id()
else:
return True
except mysql.connector.Error as e:
print(e.msg)
return False
def dml_type(self, sql):
re_dml = re.compile('^(?P\w+)\s+', re.I)
m = re_dml.match(sql)
if m:
if m.group("dml").lower() == 'delete':
return 'delete'
elif m.group("dml").lower() == 'update':
return 'update'
elif m.group("dml").lower() == 'insert':
return 'insert'
print(
"%s --- Warning: '%s' is not dml." % (time.strftime('%Y-%m-%d
%H:%M:%S', time.localtime(time.time())), sql))
return False
# 将结果转换为数组
def rows2array(self, data):
'''transfer tuple to array.'''
result = []
for da in data:
if type(da) is not dict:
raise Exception('Format Error: data is not a dict.')
result.append(da)
return result
# close it
def __del__(self):
'''free source.'''
try:
self._cursor.close()
self._connect.close()
except:
pass
def close(self):
self.__del__()
上面的代码中,SmartMySQL类对MySQL的相关操作进行了封装,这样可以更方便地使用MySQL。
下面编写测试脚本来测试SmartMySQL类,代码如下:
/test/test_mysql.py:
#!/usr/bin/env python
import sys
sys.path.append('../libs/')
from SmartMySQL import SmartMySQL
config = {
'host': '127.0.0.1',
'port': 3306,
'user': ‘xxx’, # 改为你自己的用户名
'password': 'xxx', # 改为你自己的密码
'db': 'test_go' # 改为你自己要用的数据库
}
mysql_obj = SmartMySQL(config)
执行脚本,输出结果如下,表示连接数据库成功。
python test_mysql.py
init success and connect it
案例需要新创建一个数据库,命名为for_python_test。因为对数据库的相关配置会反复用到该数据库,所以需要在一个单独的文件中编写连接MySQL的相关配置,代码如下:
/test/test_mysql.py:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 获取数据库的相关配置
def get_config():
return {
'host': '127.0.0.1',
'port': 3306,
'user': 'root',
'password': '1234567qaz,TW',
'db': 'for_python_test'
}
然后编写初始化表的脚本,具体代码如下:
/database_seeds/create_tables.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import sys
sys.path.append('../libs/')
from SmartMySQL import SmartMySQL
sys.path.append('../config/')
from dbconfig import get_config
create_article_sql = '''
CREATE TABLE `api_articles` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
COMMENT '文章名',
`author` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
COMMENT '作者名',
`price` int(11) NOT NULL DEFAULT '0' COMMENT '价格',
`created` int(11) DEFAULT NULL COMMENT '记录创建时间',
`modified` int(11) DEFAULT NULL COMMENT '记录修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
'''
# 获取配置信息
db_config = get_config()
# print(" the db config is \r\n")
# print(db_config)
# exit(0)
create_request_log_sql = '''
CREATE TABLE `request_logs` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`api_path` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
COMMENT '请求的接口地址',
`http_method` varchar(6) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT
NULL COMMENT '请求方式,GET、POST、PUT、DELETE',
`params` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
COMMENT '参数,以JSON字符串形式存储',
`response` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL
COMMENT '返回结果文本',
`assert_result` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin
NOT NULL COMMENT '断言判断结果',
`created` int(11) DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
'''
mysql_obj = SmartMySQL(db_config)
mysql_obj.query(create_article_sql)
mysql_obj.query(create_request_log_sql)
执行该脚本即可生成api_articles表和request_logs表,其结构分别如下表所示。
api_articles表结构设计:
request_logs表结构设计:
其中,request_logs表的设计要结合实际工作需要,这里以记录传入参数和结果为主,可以自行设计具体的表结构。关于表的设计原则有很多,如第一范式和第三范式等,日志类信息由于比较规范,因此适合使用MySQL这种传统的关系型数据库进行有效存储,后期的筛选和汇总非常便捷。
除此之外还可以引入log日志工具类。Python官方推荐logging模块用于引入log日志工具类。使用日志工具类可以进行日志记录工作。由于logging模块的配置过于灵活和烦琐,因此可以考虑将logging模块封装成一个工具类先行配置,这样可以减少代码的冗余,而且方便调用。
具体代码如下:
/libs/my_logger.py
#! /usr/bin/env python
# coding=utf-8
import logging
'''
基于logging封装操作类
author: freephp
date: 2020
'''
class My_logger:
_logger = None
'''
初始化函数,主要用于设置命令行和文件日志的报错级别及参数
'''
def __init__(self, path, console_level=logging.DEBUG, file_level=
logging.DEBUG):
self._logger = logging.getLogger(path)
self._logger.setLevel(logging.DEBUG)
fmt = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s',
'%Y-%m-%d %H:%M:%S')
# 设置命令行日志
sh = logging.StreamHandler()
sh.setLevel(console_level)
sh.setFormatter(fmt)
# 设置文件日志
fh = logging.FileHandler(path, encoding='utf-8')
fh.setFormatter(fmt)
fh.setLevel(file_level)
self._logger.addHandler(sh)
self._logger.addHandler(fh)
# 当级别为debug时的记录调用
def debug(self, message):
self._logger.debug(message)
# 当级别为info时的记录调用
def info(self, message):
self._logger.info(message)
# 当级别为warning(警告)时的记录调用
def warning(self, message):
self._logger.warning(message)
# 当级别为error时的记录调用
def error(self, message):
self._logger.error(message)
# 当级别为critical(严重错误)时的记录调用,类似于PHP中的Fata Error
def critical(self, message):
self.logger.critical(message)
# if __name__ == '__main__':
# logger = My_logger('./catlog1.txt')
# logger.warning("FBI warning it\r\n")
上述脚本中,最后几行被注释的代码就是在展示用法,如果将注释去掉并执行该脚本,可以在同级目录下生成catlog1.txt文件,内容如下:
[2020-05-02 11:53:29] [WARNING] FBI warning it
由此可见,可以通过自定义设置得到想要的日志信息和内容,非常方便。其实所谓配置编写,更多的是“磨刀不误砍柴工”的前期准备工作,看似麻烦,但是一劳永逸,提升了后续工作的效率,也进一步提高了自己的编程能力。
为了编写各种工具类及为数据库配置做准备,项目的目录也会发生了一系列变化。
至此,配置的编写工作基本完成,下一步是测试数据的准备工作。
针对文章的增、删、改、查接口,可以编写一些用例的数据。例如,测试文章内容,具体代码如下:
]# -*-coding:utf-8-*-
# 用于添加的文章数据
insert_data = [
{
'title': u'PHP全书',
'author': u'freephp',
'price': 45
},
{
'title': u'Python自动化测试',
'author': u'freephp',
'price': 34
},
{
'title': u'UNIX编程',
'author': u'鸟哥',
'price': 97
}
]
# 用于修改的文章数据
update_data = [
{
'id': 3,
'title': u'PHP全书',
'author': u'高老师',
'price': 55
},
{
'id': 4,
'title': u'Python自动化测试',
'author': u'freephp',
'price': 40
},
{
'id': 5,
'title': u'UNIX编程',
'author': u'freephp',
'price': 87
}
]
根据删除接口的参数要求,只需要传递ID即可,相关脚本代码如下:
delete_ids = [1, 2]
后续可以利用这些数据对RESTful API进行逐一测试
首先编写新增文章接口的测试用例,还是使用unittest来做单元测试和断言工作,具体代码如下:
#! /usr/bin/env python
# coding=utf-8
import unittest
import requests
import json
class Api_Test(unittest.TestCase):
_insert_data = None
_insert_url = None
# 用setUp()来代替__init__,setUp()会在每一个用例执行前被自动执行
def setUp(self) -> None:
self._insert_data = [
{
'title': u'PHP全书',
'author': u'freephp',
'price': 45
},
{
'title': u'Python自动化测试',
'author': u'freephp',
'price': 34
},
{
'title': u'UNIX编程',
'author': u'鸟哥',
'price': 97
}
]
self._insert_url = "http://127.0.0.1:5000/my_app/api/v1/articles"
def test_insert(self):
response = requests.post(self._insert_url, self._insert_data[0])
# print(response.text)
res_data = json.loads(response.text)
self.assertEqual(res_data['book']['price'], '45')
self.assertEqual(res_data['book']['author'], 'freephp')
执行结果如下:
/xxxk/venv/bin/python /Applications/PyCharm.app/Contents/helpers/pycharm/
_jb_unittest_runner.py --target insert_request.Api_Test.test_insert
Launching unittests with arguments python -m unittest insert_request.Api_
Test.test_insert in /Users/tony/www/autoTestBook/9/9.5
Ran 1 test in 0.020s
OK
如果把日志类和请求入库的相关代码加上,那么完整的代码如下:
/insert_request.py:
#! /usr/bin/env python
# coding=utf-8
import unittest
import requests
import json
import sys
import time
sys.path.append('./libs/')
from SmartMySQL import SmartMySQL
from my_logger import My_logger
sys.path.append('./config/')
from dbconfig import get_config
class Api_Test(unittest.TestCase):
_insert_data = None
_insert_url = None
# 用setUp()来代替__init__,setUp()会在每一个用例执行前被自动执行
def setUp(self) -> None:
self._insert_data = [
{
'title': u'PHP全书',
'author': u'freephp',
'price': 45
},
{
'title': u'Python自动化测试',
'author': u'freephp',
'price': 34
},
{
'title': u'UNIX编程',
'author': u'鸟哥',
'price': 97
}
]
self._insert_url = "http://127.0.0.1:5000/my_app/api/v1/articles"
def test_insert(self):
config = get_config()
mysql_obj = SmartMySQL(config)
api_path = self._insert_url
http_method = 'POST'
my_logger = My_logger('./logs/test_insert.log')
for row_data in self._insert_data:
json_data_str = json.dumps(row_data, ensure_ascii=False)
response_str = 'ok'
created = int(time.time())
assert_result = "见报告"
insert_sql = "INSERT request_logs (api_path, http_method, params,
response, assert_result, created) VALUES("
response = requests.post(self._insert_url, row_data)
if response.status_code != 200:
response_str = str(response.content)
my_logger.error("The http code is %s" % str(response.status_
code))
res_data = json.loads(response.text)
self.assertEqual(res_data['book']['price'], '45')
self.assertEqual(res_data['book']['author'], 'freephp')
insert_sql += "'" + api_path + "', '" + str(
http_method) + "','" + json_data_str + "','" + response_str + "',
'" + assert_result + "'," + str(
created) + ")"
print(insert_sql)
res = mysql_obj.dml(insert_sql)
# print("somthing: \r\n")
# print(res)
# print("over=======")
# exit(0)
def main():
suite = unittest.TestLoader().loadTestsFromTestCase(Api_Test)
test_result = unittest.TextTestRunner(verbosity=2).run(suite)
print('All case number')
print(test_result.testsRun)
print('Failed case number')
print(len(test_result.failures))
print('Failed case and reason')
print(test_result.failures)
for case, reason in test_result.failures:
print(case.id())
print(reason)
if __name__ == '__main__':
main()
其实,新增接口中的MySQL入库操作并非通用型做法,如果只是想简单地记录一下,也可以考虑写入文件的方式,这样更加简单、直接。
修改文章内容的接口类似于新增文章接口,只是多传递了主键ID用于修改指定的数据内容,具体代码如下:
/update_request.py
#! /usr/bin/env python
# coding=utf-8
import unittest
import requests
import json
import sys
import time
sys.path.append('./libs/')
from SmartMySQL import SmartMySQL
from my_logger import My_logger
sys.path.append('./config/')
from dbconfig import get_config
class Api_Test2(unittest.TestCase):
_insert_data = None
_insert_url = None
# 用setUp()来代替__init__,setUp()会在每一个用例执行前被自动执行
def setUp(self) -> None:
self._update_data = {
'id' : 5,
'title': u'PHP全书',
'author': u'freephp',
'price': 46
}
self._update_url = "http://127.0.0.1:5000/my_app/api/v1/articles"
def test_update(self):
response = requests.put(self._update_url + '/' + str(self._update_
data['id']), self._update_data)
print(response.text)
res_data = json.loads(response.text)
self.assertEqual(res_data['result'], "ok")
执行该单元测试用例,输出结果如下:
Launching unittests with arguments python -m unittest update_request.Api_
Test2.test_update in /Users/tony/www/autoTestBook/9/9.5
Ran 1 test in 0.013s
OK
{"result":"ok"}
这里为了展示关键逻辑,没有添加如my_logger的日志类调用和MySQL入库操作(引入部分的代码是有的,方便后面使用),可以根据实际需要进行增加。最重要的是要理解解决问题的思路和逻辑,而不是具体的代码实现方式,因为代码的实现方式是多种多样的。
删除接口和修改接口类似,也需要传递要被删除的文章ID,并且只需要传递这个参数即可,代码如下:
/delete_request.py:
#! /usr/bin/env python
# coding=utf-8
import unittest
import requests
import json
import sys
import time
sys.path.append('./libs/')
from SmartMySQL import SmartMySQL
from my_logger import My_logger
sys.path.append('./config/')
from dbconfig import get_config
class Api_Test3(unittest.TestCase):
_insert_data = None
_insert_url = None
# 用setUp()来代替__init__,setUp()会在每一个用例执行前被自动执行
def setUp(self) -> None:
self._delete_data = [4, 5]
self._update_url = "http://127.0.0.1:5000/my_app/api/v1/articles"
def test_delete(self):
for id in self._delete_data:
response = requests.delete(self._update_url + '/' + str(id))
print(response.text)
res_data = json.loads(response.text)
self.assertEqual(res_data['result'], True)
传递需要被删除的文章ID,可以使用数组批量循环删除,在每次循环内进行逻辑判断和断言即可。
查询文章接口的调用最简单,无须添加任何多余参数的传递,只需要发起一个GET请求,重点是持久化存储和断言的执行逻辑,具体代码如下:
/search_request.py
#! /usr/bin/env python
# coding=utf-8
import unittest
import requests
import json
import sys
import time
sys.path.append('./libs/')
from SmartMySQL import SmartMySQL
from my_logger import My_logger
sys.path.append('./config/')
from dbconfig import get_config
class Api_Test4(unittest.TestCase):
_search_url = None
def setUp(self) -> None:
self._search_url = "http://127.0.0.1:5000/my_app/api/v1/articles"
def test_get(self):
response = requests.get(self._search_url)
db_config = get_config()
mysql_obj = SmartMySQL(db_config)
print(response.text)
json_data = json.loads(response.text)
articles = json_data["article"]
self.assertCountEqual(len(articles), 5)
for article in articles:
# 组装SQL
Pass
在将获取的数据插入数据库时,可以考虑逐条插入,即逐条组装对应的insert sql,也可以考虑一次性批量插入,只用组装一个完整的SQL语句即可。
当然这需要考虑数据的长度,MySQL通常可以支持一次性插入500条左右的数据。下面是单条插入和多条插入的SQL组装代码,在数据量较大的情况下优先考虑使用批量插入,性能更好。
代码如下:
# 单条插入的SQL组装
def make_insert_sql(row_data):
# 占位符拼接更加符合人性化操作
sql = "INSERT api_articles (title, author, price, created) VALUES('%s',
'%s', %s, %s)" % (row_data['title'], row_data['author'], row_data['price'],
int(time.time()))
return sql
# 批量插入的SQL组装
def make_batch_insert_sql(data):
sql = "INSERT api_articles (title, author, price, created) VALUES"
for row_data in data:
sql += "('%s', '%s', %s, %s)," % (row_data['title'], row_data['author'], row_data['price'], int(time.time()))
sql = sql[0:len(sql) - 1] # 去掉最后一个多余的逗号字符
return sql
主要介绍如何使用Tavern工具实现RESTful API的自动化测试,这个工具非常轻量而且是基于命令行的,非常值得学习。
1. Tavern简介
Tavern是一款使用Python编写的用于自动化测试的命令行工具,并且基于YAML语法进行了灵活、简单的配置。它上手容易,可以适用于不同复杂程度的测试任务。Tavern支持RESTful API的测试,同时也支持基于MQTT的测试。
稍微解释一下MQTT。MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是一种基于发布/订阅(publish/subscribe)模式的“轻量级”通信协议,该协议构建于TCP/IP之上,由IBM于1999年发布。MQTT的最大优点在于可以用极少的代码和有限的带宽,为连接远程设备提供实时、可靠的消息服务。作为一种低开销、低带宽占用的即时通信协议,其在物联网、小型设备和移动应用等方面得到了较广泛的应用。
使用Tavern的最佳方式是搭配pytest,所以首先必须要安装Tavern和pytest包。安装Tavern的方法很简单,命令如下:
pip install tavern
可以通过编写.tavern.yaml文件来定义测试用例,然后使用pytest运行测试用例。这意味着测试人员可以访问所有的pytest生态系统,并可以执行各种操作。
例如,定期对测试服务器运行测试用例,报告失败信息或生成HTML报告。
2. Tavern的基本用法
首先创建一个.tavern.yaml配置文件,例如test_minimal.tavern.yaml,内容如下:
---
# Every test file has one or more tests...
test_name: Get some fake data from the JSON placeholder API
# ...and each test has one or more stages (e.g. an HTTP request)
stages:
- name: Make sure we have the right ID
# Define the request to be made...
request:
url: https://jsonplaceholder.typicode.com/posts/1
method: GET
# ...and the expected response code and body
response:
status_code: 200
json:
id: 1
这个配置文件可以是任何名称。如果想要将pytest与Tavern一起使用,则仅拾取名为test _ *.tavern.yaml的文件。
执行pytest test_minimal.tavern.yaml -v命令,输出信息如下:
========================= test session starts =========================
platform linux -- Python 3.5.2, pytest-3.4.2, py-1.5.2, pluggy-0.6.0 -
/home/taverntester/.virtualenvs/tavernexample/bin/python3
cachedir: .pytest_cache
rootdir: /home/taverntester/myproject, inifile:
plugins: tavern-0.7.2
collected 1 item
test_minimal.tavern.yaml::Get some fake data from the JSON placeholder API
PASSED [100%]
======================= 1 passed in 0.14 seconds ======================
强烈建议将Tavern与pytest结合使用,因为它不仅有大量的工具可以控制测试用例的发现和执行,而且还有大量的插件可以改善开发者的开发体验。如果由于某种原因不能使用pytest,则可以使用tavern-ci命令行界面。执行命令后输出信息如下:
tavern-ci --stdout test_minimal.tavern.yaml
2020-04-08 16:17:10,152 [INFO]: (tavern.core:55) Running test : Get some
fake data from the JSON placeholder API
2020-04-08 16:17:10,153 [INFO]: (tavern.core:69) Running stage : Make sure
we have the right ID
2020-04-08 16:17:10,239 [INFO]: (tavern.core:73) Response: '' ({
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio
reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et
cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt
rem eveniet architecto"
})
2020-04-08 16:17:10,239 [INFO]: (tavern.printer:9) PASSED: Make sure we have the right ID [200]
之所以不使用Postman等工具进行测试,是因为Tavern可以真正做到自动化地测试API。根据官网文档解释:Postman和Insomnia都是出色的工具,涵盖了RESTful API的各种用例。实际上一般会将Tavern与Postman一起使用。
Tavern的优势在于轻量级,和pytest结合紧密,并且容易编写出可读性强的代码。通过配置,不用编程也可以实现自动化测试
3. 使用Tavern测试文章的所有接口
通过上面的例子,要测试文章接口,只需要编写test_article.tavern.yaml文件即可,具体代码如下:
test_name: 获取所有文章接口
stages:
- name: test get articles api
request:
url: http://127.0.0.1:5000/my_app/api/v1/articles
method: GET
response:
status_code: 200
body:
articles: []
---
test_name: 测试新增接口
stages:
- name: test add api
request:
url: http://127.0.0.1:5000/my_app/api/v1/articles
method: POST
data:
title: Vue从入门到精通
author: freephp
price: 40
response:
status_code: 200
body:
article: {}
---
test_name: 测试修改接口
stages:
- name: test login api
request:
url: http://127.0.0.1:5000/my_app/api/v1/articles
method: PUT
data:
id: 4
title: Vue从入门到精通
author: freephp
price: 40
response:
status_code: 200
body:
result: True
test_name: 测试修改接口
stages:
- name: test login api
request:
url: http://127.0.0.1:5000/my_app/api/v1/articles
method: DELETE
data:
id: 4
response:
status_code: 200
body:
result: True
执行配置文件即可完成测试工作。pytest -v test_article.tavern.yaml会很清晰地验证每个测试点,其中test_article.tavern.yaml即为上面的配置文件。
RESTful API作为最常见的接口服务形式,是测试工作中非常重要的工具。使用人工方式进行测试费时、费力,而使用自动化测试工具能高效地完成测试工作,并且可以将测试结果可视化和持久化,为后续的工作做好准备。
首先进行需求分析。概要设计包括以下三大模块:
一个完整的自动化测试框架结构如图所示:
项目的设计可以做得非常清爽、简单。从最基础的部分开始编写代码,根据需求和项目的变化进一步增强基础功能,从而满足更复杂的测试场景和应用。
测试模块和测试报告都非常重要,一个用于测试用例的编写,另一个用于收集测试结果。因此一个完整的自动化测试必须对所有的用例进行代码检测,并对结果进行可视化呈现。日志必须添加在每一个关键流程和逻辑点附近,甚至有一些日志需要进行持久化入库,为后续更加严格和灵活的分析提供第一手数据资料。
测试用例模块、自动化执行控制器、测试报告生成模块和日志系统等模块之间不是相互孤立的,而是相辅相成的。
针对这些模块,这里初始化了一个新项目并命名为autotest。
其中:common文件夹集中编写工具类,如可复用的请求类、数据库操作类和邮件发送类等;data文件夹主要放一些配置文件,如数据库的相关配置;logs文件夹存放写入的日志信息;reports文件夹存放测试报告;test_case文件夹存放编写好的测试用例程序。
配置文件非常简单,代码如下:
[DATABASE]
host = 127.0.0.1
username = root
password = root
port = 3306
database = test_test1
[HTTP]
# 接口的URL
baseurl = http://xx.xxxx.xx
port = 8080
timeout = 1.0
其中,关于请求相关的工具类,具体代码如下:
/autotest/common/request_tool.py:
#! /usr/bin/env python
# coding=utf-8
__author__ = "Free PHP"
from selenium import webdriver
import time, os
class Request_Tool(object):
__project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def __init__(self, driver):
self.driver = webdriver.Firefox()
self.driver.maximize_window()
def open_url(self, url):
self.driver.get(url)
self.driver.implicitly_wait(30)
def find_element(self, element_type, value):
if element_type == 'id':
el = self.driver.find_element_by_id(value)
if element_type == 'name':
el = self.driver.find_element_by_name(value)
if element_type == 'class_name':
el = self.driver.find_element_by_class_name(value)
if element_type == 'tag_name':
el = self.driver.find_elements_by_tag_name(value)
if element_type == 'link':
el = self.driver.find_element_by_link_text(value)
if element_type == 'css':
el = self.driver.find_element_by_css_selector(value)
if element_type == 'partial_link':
el = self.driver.find_element_by_partial_link_text(value)
if element_type == 'xpath':
el = self.driver.find_element_by_xpath(value)
if el:
return el
else:
return None
# 利用Selenium的单击事件
def click(self, element_type, value):
self.find_element(element_type, value).click()
# 利用Selenium输入
def input_data(self, element_type, value, data):
self.find_element(element_type, value).send_keys(data)
# 获取截图
def get_screenshot(self, id):
for filename in os.listdir(os.path.dirname(os.getcwd())):
if filename == 'picture':
break
else:
os.mkdir(os.path.dirname(os.getcwd()) + '/picture/')
photo = self.driver.get_screenshot_as_file(self.__project_dir +
'/picture/' + str(id) + str('_') + time.strftime("%Y-%m-%d-%H-%M-%S") +
'.png')
return photo
def delete_self(self):
time.sleep(2)
self.driver.close()
self.driver.quit()
日志类主要用于采集日志信息,这里对其进行了封装,可以复用前面的封装类。
具体代码如下:
/autotest/common/my_logger.py:
#! /usr/bin/env python
# coding=utf-8
import logging
'''
基于logging封装操作类
author: freephp
date: 2020
'''
class My_logger:
_logger = None
'''
初始化函数,主要用于设置命令行和文件日志的报错级别和参数
'''
def __init__(self, path, console_level=logging.DEBUG, file_level=
logging.DEBUG):
self._logger = logging.getLogger(path)
self._logger.setLevel(logging.DEBUG)
fmt = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s',
'%Y-%m-%d %H:%M:%S')
# 设置命令行日志
sh = logging.StreamHandler()
sh.setLevel(console_level)
sh.setFormatter(fmt)
# 设置文件日志
fh = logging.FileHandler(path, encoding='utf-8')
fh.setFormatter(fmt)
fh.setLevel(file_level)
self._logger.addHandler(sh)
self._logger.addHandler(fh)
# 当级别为debug时的记录调用
def debug(self, message):
self._logger.debug(message)
# 当级别为info时的记录调用
def info(self, message):
self._logger.info(message)
# 当级别为warning(警告)时的记录调用
def warning(self, message):
self._logger.warning(message)
# 当级别为error时的记录调用
def error(self, message):
self._logger.error(message)
# 当级别为critical(严重错误)时的记录调用,类似于PHP中的Fata Error
def critical(self, message):
self.logger.critical(message)
#
# if __name__ == '__main__':
# logger = My_logger('./catlog1.txt')
# logger.warning("FBI warning it\r\n")
对于读取配置文件,也可以将其封装成一个配置类。当然,也可以选择不进行封装,而是让需要这些配置文件的类直接读文件。但是封装能提供一个统一对外暴露的类,不让其他类直接操作配置文件,可以在复杂的系统中达到解耦的作用,这样的设计更符合设计原则,也更有利于日后的扩展。
既然进行自研框架的编写,那么就应该尽可能地对类进行封装,让功能模块化,让操作对象化,让效率更高。
下面将读取配置文件封装成一个配置类,代码如下:
/autotest/common/read_config.py:
# *_*coding:utf-8 *_*
__author__ = "freephp"
import os,codecs
import configparser
prodir = os.path.dirname(os.path.abspath(__file__))
conf_prodir = os.path.join(prodir,'conf.ini')
class Read_Config():
def __init__(self):
with open(conf_prodir) as fd:
data = fd.read()
#清空文件信息
if data[:3] ==codecs.BOM_UTF8:
data = data[3:]
file = codecs.open(conf_prodir,'w')
file.write(data)
file.close()
self.cf = configparser.ConfigParser()
self.cf.read(conf_prodir)
def get_http(self,name):
value = self.cf.get("HTTP",name)
return value
def get_db(self,name):
return self.cf.get("DATABASE",name)
然后结合log类,编写MySQL操作类,可以参考之前已经封装好的SmartMySQL,在此基础上再加上相关日志记录即可,这就是封装的好处,不用每次都“重复造轮子”。
具体代码如下:
/autotest/common/mysql_db.py:
#!/usr/bin/env python
# *_*coding:utf-8 *_*
__author__ = "freephp"
from read_config import Read_Config
from my_logger import My_logger
readconf_obj = Read_Config()
host = readconf_obj.get_db("host")
username = readconf_obj.get_db("username")
password = readconf_obj.get_db("password")
port = readconf_obj.get_db("port")
database = readconf_obj.get_db("database")
dbconfig = {
'host': str(host),
'user': username,
'password': password,
'port': int(port),
'db': database
}
import mysql.connector
import time, re
from mysql.connector import errorcode
class SmartMySQL:
"""Smart python class connects to MySQL. """
# db配置,如账号、密码和数据库名
_dbconfig = None
_cursor = None
_connect = None
# error_code from MySQL
_error_code = ''
# quit connect if beyond 30 sec
TIMEOUT_DEADLINE = 30
TIMEOUT_THREAD = 10 # threadhold of one connect
TIMEOUT_TOTAL = 0 # total time the connects have waste
# 初始化
def __init__(self, dbconfig):
try:
self._dbconfig = dbconfig
self.check_dbconfig(dbconfig)
self._connect = mysql.connector.connect(user=self._dbconfig
['user'], password=self._dbconfig['password'],
database=self._dbconfig['db'])
self.my_logger = My_logger('../logs/mysql-' + time.strftime
("%Y-%m-%d-%H-%M-%S") + '.log')
except mysql.connector.Error as e:
print(e.msg)
if e.errno == errorcode.ER_BAD_DB_ERROR:
print("Database dosen't exist, check it or create it")
self.my_logger.error("Database dosen't exist, check it or
create it")
# 重试
if self.TIMEOUT_TOTAL < self.TIMEOUT_DEADLINE:
interval = 0
self.TIMEOUT_TOTAL += (interval + self.TIMEOUT_THREAD)
time.sleep(interval)
self.__init__(dbconfig)
raise Exception(e.errno)
self._cursor = self._connect.cursor()
print("init success and connect it")
# 检查数据库的配置是否正确
def check_dbconfig(self, dbconfig):
flag = True
if type(dbconfig) is not dict:
print('dbconfig is not dict')
flag = False
else:
for key in ['host', 'port', 'user', 'password', 'db']:
if key not in dbconfig:
print("dbconfig error: do not have %s" % key)
flag = False
if 'charset' not in dbconfig:
self._dbconfig['charset'] = 'utf8'
if not flag:
raise Exception('Dbconfig Error')
return flag
# 执行SQL
def query(self, sql, ret_type='all'):
try:
self._cursor.execute("SET NAMES utf8")
self._cursor.execute(sql)
if ret_type == 'all':
return self.rows2array(self._cursor.fetchall())
elif ret_type == 'one':
return self._cursor.fetchone()
elif ret_type == 'count':
return self._cursor.rowcount
except mysql.connector.Error as e:
print(e.msg)
self.my_logger.error(e.msg)
return False
def dml(self, sql):
'''update or delete or insert'''
try:
self._cursor.execute("SET NAMES utf8")
self._cursor.execute(sql)
self._connect.commit()
type = self.dml_type(sql)
if type == 'insert':
return self._cursor.getlastrowid()
else:
return True
except mysql.connector.Error as e:
self.my_logger.error(e.msg)
print(e.msg)
return False
def dml_type(self, sql):
re_dml = re.compile('^(?P\w+)\s+', re.I)
m = re_dml.match(sql)
if m:
if m.group("dml").lower() == 'delete':
return 'delete'
elif m.group("dml").lower() == 'update':
return 'update'
elif m.group("dml").lower() == 'insert':
return 'insert'
print(
"%s --- Warning: '%s' is not dml." % (time.strftime('%Y-%m-%d
%H:%M:%S', time.localtime(time.time())), sql))
return False
# 将结果转换为数组
def rows2array(self, data):
'''transfer tuple to array.'''
result = []
for da in data:
if type(da) is not dict:
raise Exception('Format Error: data is not a dict.')
result.append(da)
return result
# 关闭资源
def __del__(self):
'''free source.'''
try:
self._cursor.close()
self._connect.close()
except:
pass
def close(self):
self.__del__()
和之前介绍的一样,在数据库操作的关键点中都增加了my_logger的日志点埋点,在问题出现的时候,这些日志可以方便地进行调试和定位问题。封装工具类虽然烦琐,但是使用起来很方便,后续有新的需求时还可以在这些工具类的基础上进行迭代,可以使用继承方式编写出自己的新工具类,或者在原有工具类的基础上扩展新的业务方法。当然,不同的场景有不同的使用方式,一般情况下的建议是“组合大于继承,继承要慎重”。
最后编写自动化运行测试用例脚本,需要引入HTMLTestRunner模块,然后顺利执行完所有用例并产生相应的HTML输出结果报告。建议每一个用例的执行都生成一个对应的独立报告文件,这样更方便查看和分析,也更加高效。
具体代码如下:
/autotest/common/test_runner.py:
/autotest/common/test_runner.py:
#! /usr/bin/env python
# coding=utf-8
__author__ = "Free PHP"
import time,HTMLTestRunner
import unittest
from common.config import *
project_dir = os.path.abspath(os.path.join(os.path.dirname(__file__),os.
pardir))
class TestRunner(object):
''' 执行测试用例 '''
def __init__(self, cases="../",title="Auto Test Report",description=
"Test case execution"):
self.cases = cases
y self.title = title
self.des = description
def run(self):
for filename in os.listdir(project_dir):
if filename == "report":
break
else:
os.mkdir(project_dir+'/report')
# fp = open(project_dir+"/report/" + "report.html", 'wb')
now = time.strftime("%Y-%m-%d_%H_%M_%S")
# fp = open(project_dir+"/report/"+"result.html", 'wb')
fp = open(project_dir+"/report/"+ now +"result.html", 'wb')
tests = unittest.defaultTestLoader.discover(self.cases,pattern=
'test*.py',top_level_dir=None)
runner = HTMLTestRunner.HTMLTestRunner(stream=fp, title=self.title,
description=self.des)
runner.run(tests)
fp.close()
有时候运行测试用例不是目的,目的是收集用例的结果,然后进一步发现问题,从而解决问题。
Selenium只是项目中对元素定位的封装类之一,对于复杂的项目,需要根据不同的需求进行调整,以符合实际情况。
当然,也可以考虑结合unittest.TestCase单元测试来编写测试用例代码,可以利用unittest模块自带的报告生成功能生成测试结果报告。Selenium只是一种最常见且有效的解决方法,针对页面上的元素可以通过多种方式(如ID、Name和Xpath等)去定位元素,并通过单击、输出和删除等操作完成需要人工进行的操作。脚本让自动化测试变得非常简单,并且可以真正做到自动化。
1. 基本操作封装
Selenium基本上都是通过操作页面元素完成既定需求,无论是电商网站还是CMS(内容管理系统),或者某种管理系统,都需要通过登录账号后进行一系列操作。
下面举一个使用Selenium登录百度网盘并进行自动化操作的案例。
具体代码如下:
# -*- coding: utf-8 -*-
from __future__ import absolute_import
import os
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver import ActionChains
import time
if __name__ == '__main__':
orgin_url = ['https://pan.baidu.com/']
driver = webdriver.Firefox()
driver.get(orgin_url[0])
time.sleep(5)
elem_static = driver.find_element_by_id("TANGRAM__PSP_4__footerULoginBtn")
elem_static.click()
time.sleep(0.5)
elem_username = driver.find_element_by_id("TANGRAM__PSP_4__userName")
elem_username.clear()
elem_username.send_keys(u"XXXXXXXXXX") #输入账号
elem_userpas = driver.find_element_by_id("TANGRAM__PSP_4__password")
elem_userpas.clear()
elem_userpas.send_keys(u"XXXXXXXXX") #密码
elem_submit = driver.find_element_by_id("TANGRAM__PSP_4__submit")
elem_submit.click()
time.sleep(10)
driver.close()
执行该脚本,输出结果如下:
Traceback (most recent call last):
File "/Users/tony/www/autoTestBook/10/10.2/test_baidu_wp.py", line 12,
in
driver = webdriver.Chrome()
File "/Users/tony/www/autoTestBook/venv/lib/python3.7/site-packages/
selenium/webdriver/chrome/webdriver.py", line 81, in __init__
desired_capabilities=desired_capabilities)
File "/Users/tony/www/autoTestBook/venv/lib/python3.7/site-packages/
selenium/webdriver/remote/webdriver.py", line 157, in __init__
self.start_session(capabilities, browser_profile)
File "/Users/tony/www/autoTestBook/venv/lib/python3.7/site-packages/
selenium/webdriver/remote/webdriver.py", line 252, in start_session
response = self.execute(Command.NEW_SESSION, parameters)
File "/Users/tony/www/autoTestBook/venv/lib/python3.7/site-packages/
selenium/webdriver/remote/webdriver.py", line 321, in execute
self.error_handler.check_response(response)
File "/Users/tony/www/autoTestBook/venv/lib/python3.7/site-packages/
selenium/webdriver/remote/errorhandler.py", line 242, in check_response
raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.SessionNotCreatedException: Message: session
not created: This version of ChromeDriver only supports Chrome version 78
Process finished with exit code 1
由输出结果可知,执行脚本中出现了错误,其中关键错误提示如下:
selenium.common.exceptions.SessionNotCreatedException: Message: session
not created: This version of ChromeDriver only supports Chrome version 78
意思是这个Chrome驱动的版本只支持Chrome浏览器的版本为78。所以问题就是驱动和Chrome浏览器之间的版本不匹配,无法让驱动唤起Chrome浏览器。
解决这种问题的关键是下载对应本机安装的Chrome版本的ChromeDriver,首先查看Chorme浏览器的版本,方法如下:
方法1:单击“帮助”菜单,然后选择“关于Chrome”命令。当前版本是83.0.4103.61,64位的官方构建版本,并且是当前的最新版本。
方法2:在浏览器的地址栏中输入如下URL:
chrome://version/
按Enter键后展示页面。该方面展示的信息非常多,除了Chrome版本外,还包括对应的软件版本,如JavaScript(V8引擎版本)、Flash版本,以及命令行和配置文件的位置等,在此只需要关注浏览器的版本即可。
由于自动化更新程序使得Chrome版本较高,为83.0.4103.61,而之前报错的提示文案使用的是ChromeDriver,它只支持78版本,所以需要重新下载适合83.0. 4103.61版本的ChromeDriver。
有两个下载地址可供选择,一个是Chrome官方提供的下载地址,另一个是淘宝团队提供的下载地址。对于国内的开发者,建议使用淘宝团队提供的下载地址(npm.taobao.org/mirrors/chromedriver),下载速度更快。
如果使用的是Windows系统,那么可以选择chromedriver_ win32.zip进行下载。使用Linux系统的用户请选择第一个压缩包下载。
另外,对于使用Windows系统的用户来说,如果使用的是Windows 10的64位系统,可以自动兼容32位的驱动,不必一定要找64位的安装包。当然,如果官方提供了64位的安装包,还是使用64位的更好。
下载完成之后解压即可,然后改写代码:
driver = webdriver.Chrome(executable_path='/Users/tony/Documents/chromedriver')
即增加executeable_path参数,指定使用的驱动路径为刚才解压的文件位置,然后再重新执行脚本就能正常调用Chrome浏览器。
之后可能会遇到第一个问题,就是登录时让发送验证短信,可以使用代码请求来获取短信并自动完成登录,参考代码如下:
# 获取验证码地址
qrcode_url = 'https://passport.baidu.com/v2/api/getqrcode'
qr_params = {
'lp': 'pc',
'qrloginfrom': 'pc',
'gid': '6F11F8D-EDD5-4A78-8B51-42D86D2DA7F4',
'callback': 'tangram_guid_1561697778375',
'apiver': 'v3',
'tt': get_cur_timestamp(),
'tpl': 'mn',
'_': get_cur_timestamp()
}
qrcode_r = requests.get(qrcode_url, headers=headers, params=qr_params,
cookies=init_cookies, verify=False)
# 从返回信息中解析出signcode
signcode = re.search(r'[\w]{32}', qrcode_r.text).group()
qrimg_url = 'https://passport.baidu.com/v2/api/qrcode?sign=%s&lp=
pc&qrloginfrom=pc' % signcode
# 将验证码存入图片
with open('qrcode.jpg', 'wb') as f:
qr_r = requests.get(qrimg_url, headers=headers, cookies=login_cookies,
verify=False)
f.write(qr_r.content)
百度网盘的退出也很简单,定位到对应的退出按钮元素上,然后模拟单击事件即可。实现代码如下:
ele = driver.find_element_by_xpath(‘//*[@id="dynamicLayout_0"]/div/div/
dl/dd[2]/span/span[1]/i‘)
ActionChains(driver).move_to_element(ele).perform()
sub_ele = driver.find_element_by_link_text(u‘退出‘)
sub_ele.click()
ele_out = driver.find_element_by_id(‘_disk_id_4‘)
ele_out.click()
可以在登录后继续到相应的文件夹下上传本地文件,完成上传文件的功能。代码如下:
#######把百度网盘对应的文件夹对应的URL打开########
driver.get("http://pan.baidu.com/disk/home?errno=0&errmsg=AuthXXXXXXX")
#对于这种input型上传方式,直接xpath+
send_keys()
driver.find_element_by_xpath("//*[@id=\"h5Input0\"]").send_keys(paths.pop())
如果是批量上传文件,还需要封装一个用于迭代文件目录的迭代器。
具体实现代码如下:
def files_traverse(path):
# os.walk()函数会遍历本文件,以及子文件中的所有文件夹和文件
# parent是文件所在路径,dirnames是文件夹迭代器,filenames是文件迭代器
global driver
for parent,dirnames,filenames in os.walk(path):
# 3个参数:分别返回1.该目录路径 2.所有文件夹的名称(不含路径) 3.所有文件的
名称(不含路径)
for filename in filenames:
# filename输出文件夹,以及子文件夹中的所有文件信息
paths.append(parent+"\\"+filename)
print("File name is:"+parent+"\\"+filename) #输出文件路径信息
print("****************************************")
# 调用
files_traverse(path)
除了批量上传之外,还可以进行批量下载,原理与批量上传类似。
定位到需要下载的文件元素,勾选复选框,然后单击批量下载按钮即可。对于检查下载文件数量是否一致,这里可以提供一种思路,代码如下:
count2 = count1
# count1是下载前文件夹中的文件数量,count2为文件夹中实时的文件数量
while count1 == count2:
for file_name in os.listdir(dir):
strs = file_name.split('.')
if strs[-1] == "xls":
try:
# 判断file_name是否存在于list file_names中,不存在,则抛出异常
file_names.index(file_name)
except Exception:
file_names.append(file_name)
count2 = len(file_names)
2. 发送通知邮件
有时候还需要将处理的结果以邮件形式发送给相关责任人,那么就需要用到邮件发送功能了。
首先要学习如何发送邮件,Python的smtplib模块提供了一种很方便的方式发送电子邮件。
其对SMTP进行了一定的封装,SMTP的参数如下:
下面提供一个简单的案例来展示如何发送邮件,具体代码如下:
/send_email1.py:
#coding=utf-8
import smtplib
from email.mime.text import MIMEText
from email.header import Header
# 发送邮箱
sender = '[email protected]'
# 接收邮箱
receiver = '[email protected]'
# 发送邮件主题
subject = 'python email test'
# 发送邮箱服务器
smtpserver = 'smtp.163.com'
# 发送邮箱用户名/密码
username = '[email protected]'
password = ‘xxxxxxxx’
# 中文需设置为UTF-8编码格式,单字节字符不需要设置
msg = MIMEText('你好!','text','utf-8')
msg['Subject'] = Header(subject, 'utf-8')
smtp = smtplib.SMTP()
smtp.connect('smtp.163.com')
smtp.login(username, password)
smtp.sendmail(sender, receiver, msg.as_string())
smtp.quit()
smtp.connect()函数用于连接邮件服务器;smtp.login()函数用于设置发送邮箱的用户名和密码;smtp.sendmail()函数用于设置发送邮箱、接收邮箱,以及需要发送的内容;smtp.quit()函数用于关闭发送邮件服务。
下面逐一来讲解使用过程。首先需要通过引入邮件模块等相关依赖,代码如下:
import smtplib
from email.mime.text import MIMEText
from email.header import Header
还可以发送HTML内容的邮件,具体代码如下:
#coding=utf-8
import smtplib
from email.mime.text import MIMEText
from email.header import Header
# 邮件信息配置
sender = '[email protected]'
receiver = '[email protected]'
subject = 'python email test'
smtpserver = 'smtp.163.com'
username = '[email protected]'
password = '123456'
# HTML形式的文件内容
msg = MIMEText('Hello, Python!
','html','utf-8')
msg['Subject'] = subject
smtp = smtplib.SMTP()
smtp.connect('smtp.163.com')
smtp.login(username, password)
smtp.sendmail(sender, receiver, msg.as_string())
smtp.quit()
其中的关键点是用MIMEText类把需要传递的HTML内容作为参数传入即可。其他发送过程的代码和前面类似,这里不再赘述。
然后将读取测试报告和发送邮件结合起来,代码如下:
#coding=utf-8
import unittest
import HTMLTestRunner
import os ,time,datetime
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
# 定义发送邮件
def sentmail(file_new):
# 发信邮箱
mail_from='[email protected]'
博客园---虫师
http://fnng.cnblogs.com 181
# 收信邮箱
mail_to='[email protected]'
# 定义正文
f = open(file_new, 'rb')
mail_body = f.read()
f.close()
msg=MIMEText(mail_body,_subtype='html',_charset='utf-8')
# 定义标题
msg['Subject']=u"私有云测试报告"
# 定义发送时间(不定义的话,有的邮件客户端可能会不显示发送时间)
msg['date']=time.strftime('%a, %d %b %Y %H:%M:%S %z')
smtp=smtplib.SMTP()
# 连接 SMTP 服务器,此处用的是126的 SMTP 服务器
smtp.connect('smtp.163..com')
# 用户名和密码
smtp.login('[email protected]',’xxxx’)
smtp.sendmail(mail_from,mail_to,msg.as_string())
smtp.quit()
print 'email has send out !'
# 查找测试报告,调用发邮件功能
def sendreport():
result_dir = 'D:\\selenium_python\\report'
lists=os.listdir(result_dir)
lists.sort(key=lambda fn: os.path.getmtime(result_dir+"\\"+fn) if not
os.path.isdir(result_dir+"\\"+fn) else 0)
print (u'最新测试生成的报告: '+lists[-1])
# 找到最新生成的文件
file_new = os.path.join(result_dir,lists[-1])
print file_new
# 调用发邮件模块
sentmail(file_new) ……
if __name__ == "__main__":
# 执行测试用例
runner.run(alltestnames)
# 执行发邮件
sendreport()
整个例子的实现步骤如下:
以上步骤中,最重要的步骤是定位元素。
Lettuce是Python中一款非常简单的基于Cucumber框架的BDD(行为驱动开发)工具,在Python项目自动化测试中可以执行纯自然语言的文本,让开发和测试工作变得更加简单,具有更高的可读性。
BDD是一个新颖的测试驱动开发(TDD)概念,在了解它之前,需要对TDD有一定的了解。
TDD的执行原理如图所示:
1. TDD和BDD简介
TDD和常规的先写逻辑实现代码的开发流程不同,它是在编写实际业务代码之前先编写测试用例(当然,因为没有业务代码,测试是无法通过的),然后根据测试结果来编写业务代码。这种看似有些神奇的做法是Ruby的开发者提出的。
对于没有丰富编程经验的朋友来说,可以通过NodeJS或者Ruby学习TDD编程。例如NodeJS,可以安装mocha模块,然后编写代码如下:
const assert = require("assert");
describe('Array', function() {
describe('#indexOf()', () => {
it('should return -1 when the value is not present', () =>{
assert.equal(-1, [1,2,3].indexOf(5));
assert.equal(-1, [1,2,3].indexOf(0));
});
});
从这段简单的代码可以看出,TDD方式是通过语义化的模块编写测试用例,然后增加断言来判断是否符合预期。通过测试来检验编写的功能代码是否合格,这种方式有点像反推功能点。
TDD开发的优点如下:
没有完美的技术方案,TDD开发也存在缺点:
TDD开发的关键点如下:
除此之外还可以考虑加入一些人工记录的方式。例如,每当通过一个测试用例,就在本子上划去该项即可,这样可以增加测试工程师的成就感。如果发现有漏掉的测试用例,就将其加到人工记录的列表中。
总的来说,TDD就是先编写测试用例,然后根据测试用例编写符合要求的业务代码。
BDD开发模式可以让开发者、测试人员及非技术人员之间紧密协作,允许使用自然语言来描述测试用例,这样可以让用户或非技术人员先编写需求描述,然后再安排开发人员编写测试用例来满足这些需求,这样可以减少沟通成本,提高开发效率和准确性。
2. 使用pytest-bdd进行测试
pytest-bdd是BDD的一种具体实现工具,允许使用自然语言描述测试用例的要求,可以以自动化驱动方式来测试用例,并且使用非常方便。
在实际工作中,pytest-bdd可以将单元测试和功能测试统一为一种测试,减轻持续集成服务器配置的负担,并允许重用测试设置。
pytest-bdd的安装方式也十分简单,命令如下:
pip install pytest-bdd
pytest-bdd依赖于pytest,并且在版本上有要求,目前,pytest要求的最低版本是4.3。
假设要对一个博客网站进行测试,需要编写一个配置特性文件,文件内容如下:
Feature: Blog
A site where you can publish your articles.
Scenario: Publishing the article
Given I'm an author user
And I have an article
When I go to the article page
And I press the publish button
Then I should not see the error message
And the article should be published # Note: will query the database
注意,每个功能文件中仅允许设置一个功能。编写对应的测试文件,命名为test_publish_article.py,文件内容如下:
from pytest_bdd import scenario, given, when, then
@scenario('publish_article.feature', 'Publishing the article')
def test_publish():
pass
@given("I'm an author user")
def author_user(auth, author):
auth['user'] = author.user
@given('I have an article')
def article(author):
return create_test_article(author=author)
@when('I go to the article page')
def go_to_article(article, browser):
browser.visit(urljoin(browser.url, '/manage/articles/{0}/'.format
(article.id)))
@when('I press the publish button')
def publish_article(browser):
browser.find_by_css('button[name=publish]').first.click()
@then('I should not see the error message')
def no_error_message(browser):
with pytest.raises(ElementDoesNotExist):
browser.find_by_css('.message.error').first
@then('the article should be published')
def article_is_published(article):
article.refresh() # Refresh the object in the SQLAlchemy session
assert article.is_published
下面继续编写场景装饰器,这是一个注解,可以传入如下参数:
场景装饰器装饰函数的行为就像普通的测试函数一样,都是在测试函数中进行预处理,它们将在所有场景步骤之后执行,可以将它们视为常规的pytest测试功能。例如,在此描述场景需求后,调用其他函数并声明执行语句,代码如下:
from pytest_bdd import scenario, given, when, then
@scenario('publish_article.feature', 'Publishing the article')
def test_publish(browser):
assert article.title in browser.html
有时为了可读性更好,必须用不同的名称声明相同的装置或步骤。为了对多个步骤名称使用相同的步骤功能,只需多次装饰测试函数即可。例如:
@given('I have an article')
@given('there\'s an article')
def article(author):
return create_test_article(author=author)
值得注意的是,给定的步骤别名是独立的,将在提及时执行。例如,如果将资源与某个所有者相关联,则可以使用相同的别名。管理员用户不能是文章的作者,但文章应具有默认作者。下面添加默认配置项,具体代码如下:
Scenario: I'm the author
Given I'm an author
And I have an article
Scenario: I'm the admin
Given I'm the admin
And there's an article
如果需要在每种情况下都执行一次给定的步骤,以模块为作用范围,则可以传递可选的scope参数:
@given('there is an article', scope='session')
def article(author):
return create_test_article(author=author)
在此示例中,有两种场景对article()函数进行装饰,但article()函数只会执行一次。注意,对于其他函数类型,将范围设置为大于“函数”(默认值)是没有意义的,因为它们表示动作(在步骤执行前)和断言(在步骤执行后)。除了上面代码中展示的given()函数之外,还有用于解析的函数,如parse()、cfparse()和re()等,感兴趣的可以通过官网提供的文档进一步学习。
使用pytest-selenium执行用例时需要指定浏览器,方法是在test_educa.py所在目录的命令行中执行以下命令:
pytest test_publish_article.py --driver Chrome
3. Lettuce初体验
Lettuce是Python针对Cucumber进行再次封装的开源工具,不仅功能强大,入门也容易,是开发测试的上佳选择。Lettuce封装了对常规任务的描述,做到了开箱即用,让复杂的任务被拆分和简化。它的宗旨是用最简单的逻辑来实现测试,让开发者更专注于业务价值。
通过Lettuce,测试人员可以从最外部开始构建软件,然后进行更深入的研究,直到达到统一测试为止。
Lettuce的安装也非常简单,如果想安装最新、最稳定的版本,可以通过如下命令:
pip install lettuce
如果想安装最新的特性版本,那么可以复制GitHub上的项目并手动安装,完整的命令如下:
user@machine:~/Downloads$ git clone git://github.com/gabrielfalcao/lettuce.git
user@machine:~/Downloads$ cd lettuce
user@machine:~/Downloads/lettuce$ sudo python setup.py install
Lettuce的使用和pytest-tdd非常类似。首先介绍一个新的概念——features,可以理解为功能或者特性。根据官网文档描述:Lettuce用于测试项目的行为,因此行为被分解为一系列的功能。
列举功能后,还需要创建描述这些功能的方案。因此,场景是功能的组成部分,也是功能的前置条件,在什么样的场景下就存在什么样的功能。
下面举一个简单的例子:
Feature: Add people to address book
In order to organize phone numbers of friends
As a wise person
I want to add a people to my address book
Scenario: Add a person with name and phone number
Given I fill the field "name" with "John"
And fill the field "phone" with "2233-4455"
When I save the data
Then I see that my contact book has the persons:
| name | phone |
| John | 2233-4455 |
Scenario: Avoiding a invalid phone number
Given I fill the field "name" with "John"
And fill the field "phone" with "000"
When I save the data
Then I get the error: "000 is a invalid phone number"
可以看出这个描述性文件分为3个部分。
(1)Feature名称部分,内容如下:
Feature: Add people to contract book
(2)Feature头部信息,用于描述功能的目的文字,内容如下:
In order to organize phone numbers of friends
As a wise person
I want to add a people to my address book
(3)Scenario场景,用于描述场景文字,内容如下:
Scenario: Add a person with name and phone
Given I fill the field "name" with "John"
And fill the field "phone" with "2233-4455"
When I save the data
Then I see that my contact book has the persons:
| name | phone|
| John | 2233-4455 |
Scenario: Avoiding a invalid phone number
Given I fill the field "name" with "John"
And fill the field "phone" with "000"
When I save the data
Then I get the error: "000 is a invalid phone number"
场景根据复杂度不同可以分为以下两种:
假设我们需要多次填写相同的表格,每次都使用不同的数据集,方案如下:
Feature: Apply all my friends to attend a conference
In order to apply all my friends to the next PyCon_
As a lazy person
I want to fill the same form many times
Scenario Outline: Apply my friends
Go to the conference website
Access the link "I will attend"
Fill the field "name" with ""
Fill the field "email" with ""
Fill the field "birthday" with ""
Click on "confirm attendance" button
Examples:
| friend_name | friend_email | friend_birthdate |
| Mary | [email protected] | 1988/02/10 |
| Lincoln| [email protected] | 1987/09/10 |
| Marcus | [email protected]| 1990/10/05 |
简而言之,上述方案等效于编写下面的大量代码。
Feature: Apply all my friends to attend a conference
In order to apply all my friends to the next PyCon_
As a lazy person
I want to fill the same form many times
Scenario: Apply Mary
Go to the conference website
Access the link "I will attend"
Fill the field "name" with "Mary"
Fill the field "email" with "[email protected]"
Fill the field "birthday" with "1988/02/10"
Click on "confirm attendance" button
Scenario: Apply Lincoln
Go to the conference website
Access the link "I will attend"
Fill the field "name" with "Lincoln"
Fill the field "email" with "[email protected]"
Fill the field "birthday" with "1987/09/10"
Click on "confirm attendance" button
Scenario: Apply Marcus
Go to the conference website
Access the link "I will attend"
Fill the field "name" with "Marcus"
Fill the field "email" with "[email protected]"
Fill the field "birthday" with "1990/10/05"
Click on "confirm attendance" button
和场景一样,步骤也分为两种:简单步骤和表格步骤。
1)简单步骤
简单步骤实际上很简单,它们与场景中的步骤定义相关。
Lettuce将场景中的每一行视为一个简单的步骤。唯一的例外是,如果该行的第一个非空白字符是竖线“|”,则Lettuce把该步骤视为表格步骤。
例如,一个简单的步骤可能如下:
Given I go to the conference website
2)表格步骤
与概述方案类似,表格步骤非常有用,可以避免重复文本。表格步骤对于设置方案中的某些数据集或在方案结束时将一组数据与预期结果进行比较时特别有用。
举例如下:
Given I have the following contacts in my database
| name | phone |
| John | 2233-4455 |
| Smith | 9988-7766 |
4. 编写Lettuce程序
在了解了Features后,可以进一步学习如何编写Letttuce程序。
Lettuce的功能描述如图所示:
下面以官网的案例来讲解,要求计算出给定数字的阶乘。
根据需要,创建项目目录结构如下:
mymath
└── tests
└── features
├── setps.py
└── zero.feature
使用文字在zero.feature中描述阶乘的预期行为,具体如下:
Feature: Compute factorial
In order to play with Lettuce
As beginners
We'll implement factorial
Scenario: Factorial of 0
Given I have the number 0
When I compute its factorial
Then I see the number 1
zero.feature必须在features目录内,并且其扩展名必须是.feature,但是名称可以自由命名。
下面继续编写步骤脚本,定义场景下的步骤,编写包含描述性的代码,具体代码如下:
/mymath/tests/features/steps.py:
#-*-coding:utf-8-*-
from lettuce import *
@step('I have the number (\d+)')
def have_the_number(step, number):
world.number = int(number)
@step('I compute its factorial')
def compute_its_factorial(step):
world.number = factorial(world.number)
@step('I see the number (\d+)')
def check_number(step, expected):
expected = int(expected)
assert world.number == expected, \
"Got %d" % world.number
def factorial(number):
return -1
steps.py必须位于features目录内,但名称不必为steps.py,它可以是任何扩展名为.py的Python文件。Lettuce将在功能目录中递归查找Python文件。
其实完全可以在其他文件中定义阶乘,由于这是第一个示例,后面将在steps.py中实现,直接使用Lettuce。
Lettuce不支持Python 3,因为程序中使用了大量的Python 2的语法,如print的老用法,直接执行的话会报错。修改底层代码不仅麻烦还存在一定风险,因此建议运行的时候使用Python 2.7。
完整的案例代码如下(其中使用steps注解完成步骤的定义):
from lettuce import world, steps
@steps
class FactorialSteps(object):
"""Methods in exclude or starting with _ will not be considered as step"""
exclude = ['set_number', 'get_number']
def __init__(self, environs):
self.environs = environs
def set_number(self, value):
self.environs.number = int(value)
def get_number(self):
return self.environs.number
def _assert_number_is(self, expected, msg="Got %d"):
number = self.get_number()
assert number == expected, msg % number
def have_the_number(self, step, number):
'''I have the number (\d+)'''
self.set_number(number)
def i_compute_its_factorial(self, step):
number = self.get_number()
self.set_number(factorial(number))
def check_number(self, step, expected):
'''I see the number (\d+)'''
self._assert_number_is(int(expected))
# Important!
# Steps are added only when you instanciate the "@steps" decorated class
# Internally decorator "@steps" build a closure with __init__
FactorialSteps(world)
def factorial(number):
number = int(number)
if (number == 0) or (number == 1):
return 1
else:
return number*factorial(number-1)
5. Aloe的使用
由于Lettuce不支持Python 3,使用Python 3的用户可以考虑使用Aloe来代替。Aloe是基于Nose同时又基于Gherkin的Python行为驱动开发工具,其安装可以使用pip命令:
pip install aloe
下面编写一个用例,feature文件内容如下:
/features/caculator.feature:
Feature: Add up numbers
As a mathematically challenged user
I want to add numbers
So that I know the total
Scenario: Add two numbers
Given I have entered 50 into the calculator
And I have entered 30 into the calculator
When I press add
Then the result should be 80 on the screen
执行以上脚本,执行命令如下:
aloe features/calculator.feature
E
输出结果如下:
ERROR: Failure: OSError (No such file /Users/tony/www/autoTestBook/10/
10.3/10.3.6/features/features/calculator.feature)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/
site-packages/nose/failure.py", line 42, in runTest
raise self.exc_class(self.exc_val)
OSError: No such file /Users/tony/www/autoTestBook/10/10.3/10.3.6/features/
features/calculator.feature
----------------------------------------------------------------------
Ran 1 test in 0.001s
从错误信息中可以看出没有定义caculator.py文件,因此需要编写该文件,代码如下:
def add(*numbers):
return 0
继续编写__init__.py文件,代码如下:
/features/__init__.py:
from calculator import add
from aloe import before, step, world
@before.each_example
def clear(*args):
"""Reset the calculator state before each scenario."""
world.numbers = []
world.result = 0
@step(r'I have entered (\d+) into the calculator')
def enter_number(self, number):
world.numbers.append(float(number))
@step(r'I press add')
def press_add(self):
world.result = add(*world.numbers)
@step(r'The result should be (\d+) on the screen')
def assert_result(self, result):
assert world.result == float(result)
重新执行脚本,输出信息如下:
aloe features/calculator.feature
E
======================================================================
ERROR: Failure: OSError (No such file /Users/tony/www/autoTestBook/10/
10.3/10.3.6/features/features/calculator.feature)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/
site-packages/nose/failure.py", line 42, in runTest
raise self.exc_class(self.exc_val)
OSError: No such file /Users/tony/www/autoTestBook/10/10.3/10.3.6/features/
features/calculator.feature
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)
可以看到依然有报错信息。修改calculator.py中的代码如下:
# add function
def add(*numbers):
return sum(numbers)
重新执行脚本,执行命令如下:
aloe features/calculator.feature
.
输出结果如下:
Ran 1 test in 0.001s
OK
实际上Aloe是Lettuce的一个分支,因此可以看出二者的用法完全一致。建议Python 3的用户都使用这个模块来解决版本问题。
关于更多Aloe的使用方法,可以前往官网学习,这里只是将其作为Lettuce的替代方案进行了简要讲解。
学习了Selenium的基础用法和相关实操,实际上Selenium还有更高级的用法,如Grid框架的测试,其中跨浏览器测试尤为重要,Grid框架也可以用于分布式测试。
1. Selenium Server的安装
为了实现跨浏览器测试,需要安装一个新的Selenium Server,名为selenium standalone Server。下载网址是Downloads | Selenium,需要根据使用的Python版本选择对应版本的安装包。
下载最新的稳定版本,可以选择这个版本进行下载,这是一个jar包,需要本地有Java环境。关于Java环境的搭建,网络上已经有很多详尽的介绍,可以参考进行配置、安装。
Java版本是10.0.1版,使用java --version命令可以查看详细信息,输出结果如下:
java 10.0.1 2018-04-17
Java(TM) SE Runtime Environment 18.3 (build 10.0.1+10)
Java HotSpot(TM) 64-Bit Server VM 18.3 (build 10.0.1+10, mixed mode):
这里介绍一下Selenium Grid。Selenium Grid是一个智能代理服务器,允许Selenium将命令分发给远程Web浏览器实例去并发执行。其目的是提供一种在多台计算机上并行运行测试的简便方法。使用Selenium Grid,可以通过一台中心服务器将JSON格式的测试命令传递给多个已注册Grid节点的中心服务器。
有点像MySQL集群里的主从数据库,当然这里的主节点一般不会改变,不能自动通过权重节点重新选择。关于中心节点和从节点的运行逻辑比较复杂,感兴趣的可以通过官网进一步了解,这里只要有个概念即可。Selenium Grid允许在多台计算机上并行运行测试任务(请记住是并行),并集中管理不同的浏览器版本和浏览器配置。因此Selenium Grid可以运行多个浏览器的不同版本,同时使用不同的初始化配置,最终达到并行测试的效果。
Selenium Grid的使用方式非常简单,切换到selenium server目录下,执行java-jar selenium-server-standalone-xxx.jar命令即可执行该jar包。对Java完全不了解的测试人员,可以补习一下最基础的Java运行知识。
具体执行命令如下:
java -jar selenium-server-standalone-3.141.59.jar
如果输出如下信息即为正常运行:
18:25:01.239 INFO [GridLauncherV3.parse] - Selenium server version:
3.141.59, revision: e82be7d358
18:25:01.335 INFO [GridLauncherV3.lambda$buildLaunchers$3] - Launching a
standalone Selenium Server on port 4444
2020-05-27 18:25:01.428:INFO::main: Logging initialized @605ms to org.
seleniumhq.jetty9.util.log.StdErrLog
18:25:01.785 INFO [WebDriverServlet.] - Initialising WebDriverServlet
18:25:01.922 INFO [SeleniumServer.boot] - Selenium Server is up and running
on port 4444
使用Grid网格状服务远程执行测试与直接调用Selenium服务器执行测试的效果是一样的,但是二者的环境启动方式不一样,前者需要同时启动一个hub(主节点)和至少一个node(从节点),执行命令略有不同。
使用Grid的执行命令如下:
java -jar selenium-server-standalone-3.141.59.jar -port 4444 -role hub
输出结果如下:
18:37:30.071 INFO [GridLauncherV3.parse] - Selenium server version: 3.141.
59, revision: e82be7d358
18:37:30.152 INFO [GridLauncherV3.lambda$buildLaunchers$5] - Launching
Selenium Grid hub on port 4444
2020-05-27 18:37:30.618:INFO::main: Logging initialized @853ms to org.
seleniumhq.jetty9.util.log.StdErrLog
18:37:30.784 INFO [Hub.start] - Selenium Grid hub is up and running
18:37:30.786 INFO [Hub.start] - Nodes should register to http://10.20.0.
193:4444/grid/register/
18:37:30.787 INFO [Hub.start] - Clients should connect to http://10.20.
0.193:4444/wd/hub
以上信息说明已经正常运行了一个hub服务进程,也就是一个Grid服务器,可以访问http://localhost:4444/grid/console控制台页面查看日志信息。
启动代理节点的方式和启动主服务类似,参数调整如下:
java -jar selenium-server-standalone-3.141.59.jar -port 5555 -role node
输出信息如下:
19:44:08.383 INFO [GridLauncherV3.parse] - Selenium server version: 3.
141.59, revision: e82be7d358
19:44:08.478 INFO [GridLauncherV3.lambda$buildLaunchers$7] - Launching a
Selenium Grid node on port 5555
2020-05-27 19:44:08.595:INFO::main: Logging initialized @546ms to org.
seleniumhq.jetty9.util.log.StdErrLog
19:44:08.871 INFO [WebDriverServlet.] - Initialising WebDriverServlet
19:44:08.961 INFO [SeleniumServer.boot] - Selenium Server is up and running
on port 5555
19:44:08.962 INFO [GridLauncherV3.lambda$buildLaunchers$7] - Selenium Grid
node is up and ready to register to the hub
19:44:09.005 INFO [SelfRegisteringRemote$1.run] - Starting auto registration
thread. Will try to register every 5000 ms.
19:44:09.342 INFO [SelfRegisteringRemote.registerToHub] - Registering the
node to the hub: http://localhost:4444/grid/register
19:44:09.389 INFO [SelfRegisteringRemote.registerToHub] - The node is
registered to the hub and ready to use
与此同时,主服务也有响应,输出信息如下:
18:37:30.786 INFO [Hub.start] - Nodes should register to http://10.20.0.
193:4444/grid/register/
18:37:30.787 INFO [Hub.start] - Clients should connect to http://10.20.
0.193:4444/wd/hub
19:44:09.389 INFO [DefaultGridRegistry.add] - Registered a node http://192.
168.255.6:5555
由此说明客户端(node)已经正常和主服务(hub)进行通信了。
通过Remote()可以设置参数,从而调用不同的浏览器,代码如下:
from selenium.webdriver import Remote
import time
driver = Remote(command_executor='http://localhost:4444/wd/hub',desired_
capabilities=
{'platfrom':'ANY','browserName':'firefox','version':'',
'javascriptEnabled':True})
driver.get('http://baidu.com')
driver.find_element_by_id('kw').send_keys('remote')
driver.find_element_by_id('su').click()
time.sleep(3)
driver.quit()
可以把配置写成列表存储起来,然后通过循环读取相关配置项,从而使不同的节点在不同的浏览器中都可以运行。
FireFox = {'platform':'ANY', 'browserName':'firefox', 'version':'',
'javascriptEnabled':True, 'marionette':False }
Chrome = {'platform':'ANY', 'browserName':'chrome', 'version':'',
'javascriptEnabled':True }
Opera= {'platform':'ANY', 'browserName':'opera', 'version':'',
'javascriptEnabled':True }
Iphone= {'platform':'MAC', 'browserName':'iPhone', 'version':'',
'javascriptEnabled':True }
Android = {'platform':'ANDROID', 'browserName':'android', 'version':'',
'javascriptEnabled':True }
进一步编写代码如下:
#coding=utf-8
from selenium.webdriver import Remote
import time
lists = {'http://localhost:4444/wd/hub':'chrome','http://localhost:5555/
wd/hub':'firefox'}
for host,browser in lists.items():
print (host,browser)
driver = Remote(command_executor=host,desired_capabilities={'platform':
'ANY','browserName': browser,'version': '','javascriptEnabled': True})
driver.get('http://www.baidu.com')
driver.find_element_by_id('kw').send_keys('remote')
driver.find_element_by_id('su').click()
time.sleep(3)
driver.quit()
这种运行方式还可以是将本机作为hub,远程作为node,两者之间网络畅通,大概步骤如下:
(1)启动本地hub主机,查看主机IP:
java -jar selenium-server-standalone-2.48.2.jar -role hub
(2)启动远程主机,查看IP:
java -jar selenium-server-standalone-2.48.2.jar -role node -port 5555 -hub http://hup主机的ip:4444/grid/register
多线程版本的代码如下:
from selenium.webdriver import Remote
from threading import Thread
import time
lists = {'http://localhost:4444/wd/hub':'chrome','http://localhost:5555/
wd/hub':'firefox'}
def WebTest(host,browser):
driver = Remote(command_executor=host,
desired_capabilities={'platform': 'ANY', 'browserName':
browser, 'version': '',
'javascriptEnabled': True})
driver.get('http://www.baidu.com')
driver.find_element_by_id('kw').send_keys('remote')
driver.find_element_by_id('su').click()
time.sleep(3)
driver.quit()
if __name__ == '__main__':
threads=[]
#创建线程
for host, browser in lists.items():
print(host, browser)
t = Thread(target=WebTest,args=(host,browser))
threads.append(t)
#启动线程
for thr in threads:
thr.start()
print(time.strftime('%Y%m%d%H%M%S'))
2. Selenium数据驱动测试
使用Python的数据驱动模式(ddt)库,结合unittest库创建百度搜索的测试。首先安装数据驱动模式库,安装命令如下:
pip install ddt
然后编写一个百度搜索结果的自动化测试程序,也使用单元测试来编写,代码如下:
import unittest,time
from selenium import webdriver
from ddt import ddt,data,unpack
@ddt
class WebTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.driver = webdriver.Firefox()
cls.driver.implicitly_wait(3)
cls.driver.get("http://baidu.com")
@classmethod
def tearDownClass(cls):
cls.driver.quit()
@data(("Python","Python_百度搜索"),("PHP","PHP_百度搜索"))
@unpack
# 搜索
def test_search_info(self,search_value, expected_result):
self.search = self.driver.find_element_by_xpath("//*[@id='kw']")
self.search.clear()
self.search.send_keys(search_value)
self.search.submit()
time.sleep(1.5)
self.result = self.driver.title
self.assertEqual(expected_result,self.result)
if __name__ == '__main__':
unittest.main(verbosity=2)
执行该脚本,命令如下:
python3.7 test_baidu_search.py
test_search_info_1___Python____Python_百度搜索__ (__main__.WebTest) ...
FAIL
test_search_info_2___PHP____PHP_百度搜索__ (__main__.WebTest) ... FAIL
输出结果如下:
FAIL: test_search_info_1___Python____Python_百度搜索__ (__main__.WebTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/
site-packages/ddt.py", line 182, in wrapper
return func(self, *args, **kwargs)
File "test_baidu_search.py", line 29, in test_search_info
self.assertEqual(expected_result,self.result)
AssertionError: 'Python_百度搜索' != '百度一下,你就知道'
- Python_百度搜索
+ 百度一下,你就知道
======================================================================
FAIL: test_search_info_2___PHP____PHP_百度搜索__ (__main__.WebTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/
site-packages/ddt.py", line 182, in wrapper
return func(self, *args, **kwargs)
File "test_baidu_search.py", line 29, in test_search_info
self.assertEqual(expected_result,self.result)
AssertionError: 'PHP_百度搜索' != '百度一下,你就知道'
- PHP_百度搜索
+ 百度一下,你就知道
----------------------------------------------------------------------
Ran 2 tests in 18.142s
FAILED (failures=2)
关于测试数据,还可以从读取文件中获取。例如,CSV文件用来存储需要测试的数据,可以使用@data装饰符解析外部的CSV(testdata.csv)文件作为测试数据。这里先封装一个获取数据的函数,代码如下:
def get_data(filename):
# 创建一个空的列表存储列数据
rows = []
# 打开CSV文件
data_file = open(filename, "r",encoding='utf-8')
# 读取文件内容
reader = csv.reader(data_file)
# 跳过头部
next(reader, None)
# 将数据添加到list中
for row in reader:
rows.append(row)
return rows
下面创建一个CSV文件作为测试数据的来源文件,文件内容如下:
/testdata.csv:
Search,Result
韩寒,韩寒_百度搜索
Jett,Jett_百度搜索
编写完整的测试代码,还是基于unittest.TestCase来做单元测试,通过读取CSV文件中的数据进行测试,具体实现代码如下:
/test_data.py:
# -*- coding: utf-8 -*-
import csv,unittest,time
from selenium import webdriver
from ddt import ddt,data,unpack
def get_data(filename):
# 创建一个空的列表用来存储列数据
rows = []
# 打开CSV文件
data_file = open(filename, "r",encoding='utf-8')
# 读取文件内容
reader = csv.reader(data_file)
# 跳过头部
next(reader, None)
# 在list中添加数据
for row in reader:
rows.append(row)
return rows
@ddt
class MyTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.driver = webdriver.Firefox()
cls.driver.implicitly_wait(3)
cls.driver.get("http://baidu.com")
@classmethod
def tearDownClass(cls):
cls.driver.quit()
@data(*get_data("testdata.csv"))
@unpack
# 搜索
def test_search_info(self,search_value, expected_result):
self.search = self.driver.find_element_by_xpath("//*[@id='kw']")
self.search.clear()
self.search.send_keys(search_value)
self.search.submit()
time.sleep(1.5)
self.result = self.driver.title
self.assertEqual(expected_result,self.result)
if __name__ == '__main__':
unittest.main(verbosity=2)
执行该脚本,命令如下:
python test_data.py
输出结果如下:
/Users/tony/www/autoTestBook/venv/bin/python /Applications/PyCharm.app/
Contents/helpers/pycharm/_jb_unittest_runner.py --target test_data.MyTest
Launching unittests with arguments python -m unittest test_data.MyTest in
/Users/tony/www/autoTestBook/10/10.4/10.4.2
Ran 2 tests in 9.742s
OK
实际工作中,更多时候是通过读取Excel文件来获取测试数据。Python也可以处理Excel文件,需要用到xlrd库,安装命令如下:
pip install xlrd
目前,xlrd库的最新版本为1.2.0,可以先创建Excel文件testdata.xlsx,如图所示。
编写完整的测试代码,还是基于unittest.TestCase,通过读取Excel文件中的数据进行单元测试,具体实现代码如下:
/test_execl_data.py:
#-*-coding:utf-8-*-
import xlrd,unittest,time
from selenium import webdriver
from ddt import ddt,data,unpack
import os, sys
def get_data(filename):
rows = []
data_file = xlrd.open_workbook(filename,encoding_override='utf-8')
sheet = data_file.sheet_by_index(0)
for row_idx in range(1,sheet.nrows):
rows.append(list(sheet.row_values(row_idx,0,sheet.ncols)))
print(rows)
return rows
@ddt
class WebTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.driver = webdriver.Firefox()
cls.driver.implicitly_wait(3)
cls.driver.get("http://baidu.com")
@classmethod
def tearDownClass(cls):
cls.driver.quit()
dir = os.path.dirname(os.path.abspath(__file__))
@data(*get_data("testdata.xlsx"))
@unpack
# 搜索
def test_search_info(self,search_value, expected_result):
self.search = self.driver.find_element_by_xpath("//*[@id='kw']")
self.search.clear()
self.search.send_keys(search_value)
self.search.submit()
time.sleep(1.5)
self.result = self.driver.title
self.assertEqual(expected_result, self.result)
if __name__ == '__main__':
unittest.main(verbosity=2)
对于测试数据来自于数据库的情况,测试方法也类似。重新编写get_data()函数,将数据获取方式改为从数据库获取即可,如SQL查询等操作。进一步思考后,还可以把每个不同的页面封装成类,这样方便后期调用和迭代。
例如,把登录操作封装成一个登录类,把详情页也封装成一个详情页类,也就是Page Object模式,创建一个对象来对应页面的一个应用。这种方式不是把所有逻辑封装到一个单元测试类中,而是完全的面向对象的形式,最后再编写测试方法,通过main()函数一次性调用即可。
例如,对之前的百度网盘项目进行改造,首先定义一个基础类,代码如下:
#-*-coding:utf-8-*-
#创建基础类
class BasePage(object):
#初始化
def __init__(self, driver):
self.base_url = 'https://pan.baidu.com/'
self.driver = driver
self.timeout = 30
def _open(self):
url = self.base_url
self.driver.get(url)
btn = self.driver.find_element_by_id('TANGRAM__PSP_4__footerULoginBtn')
btn.click()
def open(self):
self._open()
def find_element(self,*loc):
return self.driver.find_element(*loc)
然后再定义一个登录类,主要用来处理登录页面的操作,对各种步骤进行封装,使程序更加精细化,代码如下:
#创建LoginPage类
class LoginPage(BasePage):
username_location = (By.ID, "TANGRAM__PSP_4__userName")
password_location = (By.ID, "TANGRAM__PSP_4__password")
login_location = (By.ID, "TANGRAM__PSP_4__submit")
#输入用户名
def type_username(self,username):
self.find_element(*self.username_location).clear()
self.find_element(*self.username_location).send_keys(username)
#输入密码
def type_password(self,password):
self.find_element(*self.password_locaction).send_keys(password)
#单击登录
def type_login(self):
self.find_element(*self.login_loc).click()
之后再编写一个测试方法来测试登录功能,代码如下:
def test__login(driver, username, password):
"""测试用户名and密码是否可以登录"""
login_page = LoginPage(driver)
login_page.open()
login_page.type_username(username)
login_page.type_password(password)
login_page.type_login
最后调用main()函数,代码如下:
def main():
driver = webdriver.Edge()
username = 'sdsd' #账号
password = 'kemixxxx' #密码
test_user_login(driver, username, password)
sleep(3)
driver.quit()
if __name__ == '__main__':
main()
完整的代码如下,可以根据自己的实际需求进行修改。
/newBaidu/test_bp.py:
#-*-coding:utf-8-*-
#创建基础类
class BasePage(object):
#初始化
def __init__(self, driver):
self.base_url = 'https://pan.baidu.com/'
self.driver = driver
self.timeout = 30
def _open(self):
url = self.base_url
self.driver.get(url)
#切换到登录窗口的iframe
btn = self.driver.find_element_by_id('TANGRAM__PSP_4__footerULoginBtn')
btn.click()
def open(self):
self._open()
def find_element(self,*loc):
return self.driver.find_element(*loc)
#创建LoginPage类
class LoginPage(BasePage):
username_location = (By.ID, "TANGRAM__PSP_4__userName")
password_location = (By.ID, "TANGRAM__PSP_4__password")
login_location = (By.ID, "TANGRAM__PSP_4__submit")
#输入用户名
def type_username(self,username):
self.find_element(*self.username_location).clear()
self.find_element(*self.username_location).send_keys(username)
#输入密码
def type_password(self,password):
self.find_element(*self.password_locaction).send_keys(password)
#单击登录
def type_login(self):
self.find_element(*self.login_loc).click()
def test__login(driver, username, password):
"""测试用户名和密码是否可以登录"""
login_page = LoginPage(driver)
login_page.open()
login_page.type_username(username)
login_page.type_password(password)
login_page.type_login
def main():
driver = webdriver.Edge()
username = 'sdsd' #账号
password = 'kemixxxx' #密码
test_user_login(driver, username, password)
sleep(3)
driver.quit()
if __name__ == '__main__':
main()
由此可以看出,对这些步骤和方法进行封装是很有必要。
BasePage类对页面的基本操作进行封装;LoginPage类对登录页面的操作进行封装,如填充账号和密码,定位账号和密码的文本框进行输入,然后模拟登录按钮提交;test_login()函数将单个元素操作组成一个完整的动作,完成整个自动化登录操作;最后调用main()函数完成整个测试任务。
3. poium测试库
可能会提出一个问题:使用PageObject方式编写代码对封装能力和编程能力有比较高的要求,对于更偏向于测试的人员,是否有更简单的办法完成页面测试操作呢?
答案是有的。Python有专门封装好的PageObject的库poium。poium测试库的前身为selenium-page-objects测试库,这也是我国著名的测试布道者胡志恒老师维护的开源项目。
poium的安装方式非常简单,命令如下:
pip install poium
poium提供了JS API方式来定位元素,但是建议使用CSS语法来定位元素更好。poium可以将操作过的元素在自动运行过程中标记出来。
具体代码如下:
from poium import Page
class BaiduPage(Page):
# 元素定位只支持CSS语法
search_input ="#kw"
search_button ="#su"
def test_attribute(self):
"""
元素属性修改/获取/删除
:param browser: 浏览器驱动
"""
driver= webdriver.Chrome()
page =BaiduPage(browser)
page.get("https://www.baidu.com")
page.remove_attribute(page.search_input,"name")
page.set_attribute(page.search_input, "type", "password")
value =page.get_attribute(page.search_input, "type")
assert value =="password"
poium也支持移动端,胡志恒老师也给出了用例代码,通过定义App运行环境参数,来进一步测试移动端。
具体代码如下:
from appium import webdriver
from poium import Page,PageElement
class CalculatorPage(Page):
number_1 = PageElement(id_="com.android.calculator2:id/digit_1")
number_2 = PageElement(id_="com.android.calculator2:id/digit_2")
add = PageElement(id_="com.android.calculator2:id/op_add")
eq = PageElement(id_="com.android.calculator2:id/eq")
# 定义App运行环境
desired_caps = {
'deviceName': 'AndroidEmulator',
'automationName': 'appium',
'platformName': 'Android',
'platformVersion': '7.0',
'appPackage': 'com.android.calculator2',
'appActivity': '.Calculator',
}
driver =webdriver.Remote('http://localhost:4723/wd/hub', desired_caps)
page =CalculatorPage(driver)
page.number_1.click()
page.add.click()
page.number_2.click()
page.eq.click()
driver.quit()
在此感谢胡志恒老师的封装,让工程师们能更加灵活地去测试跨平台的应用,不只是PC端,移动端(Android+iOS)也能得到相关API的支持。
4. pyautoTest Web UI自动化项目
关于appium模块,以及基于appium构建的自动化项目pyautoTest,可以在GitHub上找到对应项目。
pyautoTest项目的特点如下:
pyautoTest项目的结构和之前自研的测试框架类似,如果掌握了前面所讲的内容,再对照看胡志恒老师的开源框架就会有一种殊途同归的感觉。
pyautoTest的安装方法如下:
(1)复制项目,因为只有master分支,所以不需要再选分支。
git clone https://github.com/defnngj/pyautoTest.git
(2)使用如下命令安装项目的依赖库:
pip install -r requirements.txt
(3)修改config.py文件,内容如下:
class RunConfig:
"""
运行测试配置
"""
# 配置浏览器驱动类型
driver_type = "chrome"
# 配置运行的 URL
url = "https://www.baidu.com"
# 失败“重跑”的次数
rerun = "3"
# 当达到最大失败数时停止执行
max_fail = "5"
# 运行测试用例的目录或文件
cases_path = "./test_dir/"
在cmd(Windows系统)或终端(Linux系统)执行如下命令:
python run_tests.py
此时会生成大量的输出信息,具体内容如下:
python run_tests.py
2020-05-31 17:01:23,621 - INFO - 回归模式,开始执行!
========================= test session starts =========================
platform darwin -- Python 3.7.4, pytest-5.2.1, py-1.8.1, pluggy-0.13.1 -
/Users/tony/www/autoTestBook/venv/bin/python
cachedir: .pytest_cache
metadata: {'Python': '3.7.4', 'Platform': 'Darwin-18.7.0-x86_64-i386-64bit',
'Packages': {'pytest': '5.2.1', 'py': '1.8.1', 'pluggy': '0.13.1'},
'Plugins': {'allure-pytest': '2.8.12', 'metadata': '1.9.0', 'tavern':
'1.0.0', 'assume': '2.2.1', 'ordering': '0.6', 'rerunfailures': '7.0',
'html': '2.1.0'}}
rootdir: /Users/tony/www/autoTestBook/10/10.4/10.4.4/pyautoTest
plugins: allure-pytest-2.8.12, metadata-1.9.0, tavern-1.0.0, assume-2.2.1,
ordering-0.6, rerunfailures-7.0, html-2.1.0
collected 4 items
test_dir/test_baidu.py::TestSearch::test_baidu_search_case
INTERNALERROR> Traceback (most recent call last):
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/_pytest/main.py", line 191, in wrap_session
INTERNALERROR>return self._inner_hookexec(hook, methods, kwargs)
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/pluggy/manager.py", line 87, in
INTERNALERROR>firstresult=hook.spec.opts.get("firstresult") if hook.
spec else False,
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/pluggy/callers.py", line 208, in _multicall
INTERNALERROR>return outcome.get_result()
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/pluggy/callers.py", line 80, in get_result
INTERNALERROR>raise ex[1].with_traceback(ex[2])
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/pluggy/callers.py", line 187, in _multicall
INTERNALERROR>res = hook_impl.function(*args)
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/_pytest/main.py", line 256, in pytest_runtestloop
INTERNALERROR>item.config.hook.pytest_runtest_protocol(item=item,
nextitem=nextitem)
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/pluggy/hooks.py", line 286, in __call__
INTERNALERROR>return self._hookexec(self, self.get_hookimpls(), kwargs)
INTERNALERROR> File "/Users/tony/www/autoTestBook/venv/lib/python3.7/
site-packages/pluggy/manager.py", line 93, in _hookexec
.....(省略大量输出......)
INTERNALERROR> File "/Users/tony/www/autoTestBook/10/10.4/10.4.4/pyautoTest/
conftest.py", line 100, in capture_screenshots
INTERNALERROR>driver.save_screenshot(image_dir)
INTERNALERROR> AttributeError: 'NoneType' object has no attribute 'save_
screenshot'
======================== no tests ran in 3.80s ========================
2020-05-31 17:01:29,379 - INFO - 运行结束,生成测试报告♥!
如果想用调试模式运行上面的代码,命令如下:
python run_tests.py -m debug
pyautoTest是一个值得学习的优秀的开源框架,可以和前面介绍的框架对比学习,改进自己的框架,达到借鉴、学习的目的。玉不琢,不成器。框架也是不断更新、优化的,遇到优秀的框架,我们可以深入底层进行学习,然后反补自己的框架,取众家之长,学习更多的知识
有的朋友可能会问:已经有了那么多开源框架,为何还要原创?所谓仁者见仁,智者见智,没有一个标准的答案。这就像已经有了自动挡汽车还是有人愿意开手动挡汽车一样。自研的框架有时更容易排查出问题,而且不会发生因为开源而引入的第三方的问题。
PageObject的编程方式值得我们认真学习,虽然可以使用胡志恒老师封装好的poium库,但是建议亲自封装一次,感受一下面向对象的编程魅力。
最后想说一下关于持续化学习的问题。
测试工具是不断变化的,每年或者每个月都有可能出现新的自动化测试框架,如果一直去追逐学习那些文档和常规用法,那么很可能会一直处于初学者的“怪圈”里。除了实战本身可以让我们串联起不同的知识点以及做到综合性应用外,更多的是需要以结构化思维去思考问题,并经常进行反思、总结。
例如,对于PageObject编程,要学会的是如何区分不同类的职责,如何划分封装的界限,基类就只做打开链接和初始化的工作,登录类就只做登录页面相关操作的封装,编程的时候必须遵守“单一职责原则”。对于测试人员来说,要在能编写自动化测试代码和简单框架搭建的基础上,进一步优化程序。学有余力的人员也可以多涉猎介绍编程规范和编程思想的书籍或资料,持续化地学习,由点及面去深入。
自动化测试并不是“银弹”,但它是我们用以解放“生产力”的一种途径。