接口测试用例
模拟不同的参数去覆盖更多的代码逻辑,尽量把接口的每一种返回结果都测到。
什么项目可以做自动化测试?
项目稳定、需求变更少,研发和维护周期长,有足够的的资源,更好做一点。
为什么要做自动化?
为了测试工作服务。不能为了自动化而自动化。
你需要量化你的自动化测试效果。举个简单的例子,做完自动化测试,一周能发现一个问题不?一个月能发现一个问题不?节省了多少工时?提升了多少效率?
本次实战,是
基于Python的unittest库做接口自动化测试
。
import unittest
unittest的方法必须以test开头
if __name__ == '__main__':
suite=unittest.TestSuite()
suite.addTest(AddDepartmentTest("test_add_department_1"))
suite.addTest(AddDepartmentTest("test_add_department_2"))
runner=unittest.TextTestResult()
test_result=runner.run(suite)
TestCase中
setUp:在执行测试用例之前,初始化环境
run:执行测试用例
tearDown:在执行测试用例之后,还原环境。
setup和teardown在每条用例执行前后都会执行。
如果要在一个类所有用例执行前后执行某些操作,可以用setUpClass、tearDownClass
TestSuite整理测试用例,可以把一个模块的测试用例放在suite里
TestLoader作用是加载testcase到testsuite里,用以后续执行。—–适用于收集符合规则的用例统一执行,调度测试用例的执行。
TextTestRunner执行测试用例(run),将测试结果保存到TextTestResult里。
suite适用于一个模块的用例,loader适用于收集符合某个规则的用例。
import unittest
# 创建测试集合
suite = unittest.TestSuite()
# 识别所有Department结尾的py文件为测试用例
tests = unittest.defaultTestLoader.discover('..\\testcase', pattern='*Department.py')
# 运行测试用例
suite.addTest(tests)
runner = unittest.TextTestRunner()
test_result = runner.run(suite)
用例
# !/usr/bin/python
# -*- coding:utf-8 -*-+
import unittest
import json
import requests
class AddDepartment(unittest.TestCase):
def setUp(self):
print("{0} 执行前,清除数据库".format(self._testMethodName))
def tearDown(self):
print("{0} 执行后,清除数据库".format(self._testMethodName))
def test_add_department_001(self):
"""新增T01学院"""
result = requests.post(url='http://127.0.0.1:8099/api/departments/',
headers={"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"},
data=json.dumps({"data":[{"dep_id":"T01","dep_name":"Test学院","master_name":"Test-Master","slogan":"Here is Slogan"}]})
)
# 查看请求的结果
print(result.status_code,result.text)
def test_add_department_002(self):
"""重复新增T01学院"""
result = requests.post(url='http://127.0.0.1:8099/api/departments/',
headers={"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"},
data=json.dumps({"data":[{"dep_id":"T01","dep_name":"Test学院","master_name":"Test-Master","slogan":"Here is Slogan"}]})
)
# 查看请求的结果
print(result.status_code,result.text)
if __name__ == '__main__':
# 构造测试
suite = unittest.TestSuite()
suite.addTest(AddDepartment("test_add_department_001"))
suite.addTest(AddDepartment("test_add_department_002"))
# 本地测试用,可以在控制台看到日志
runner = unittest.TextTestRunner()
test_result = runner.run(suite)
这个用例存在的问题
01 请求未封装
URL、Headers、请求体,全在用例层。当用例过多时,代码可阅读性低,且很难维护。
02 需要补全清除数据库的方法
03 第二条用例依赖第一条用例
即,第一条用例必须执行成功。
此外,第二条用例,不能初始化数据库(这意味着数据丢失)。
04 用例集缺乏统一管理和调度
现在的用例集在类文件执行,很麻烦。当查询、修改、删除模块都增加后,需要有一个地方统一控制这些模块的运行。
封装配置
创建一个ProjectConfig.py文件,用于保存该自动化测试项目的配置数据,比如版本号、请求的URL、接口项目地址等,如果配置数据发生变化,可以统一修改。
# !/usr/bin/python
# -*- coding:utf-8 -*-
class ProjectConfig(object):
VERSION = "v1.0"
# 替换为你本地的url地址
URL = "http://127.0.0.1:8099/api/departments/"
# 替换为你本地的接口项目路径(注意不是自动化项目路径)
PROJECT_DIR = "C:\\Users\\010702\\PycharmProjects\\easytest\\接口环境\\"
# 自动化测试项目目录
TEST_DIR = "C:\\Users\\010702\\PycharmProjects\\easytest\\"
ETConfig = ProjectConfig()
创建配置文件,用于保存配置数据如版本号,请求的url,接口项目地址等,建议你在做公司项目的自动化测试时,将配置保存在ini、yaml等文件中,并设置.gitignore。也可以部署配置中心服务,以便在不同机器上灵活运行自动化项目。
封装请求
让代码更简洁,更具有可读性,并且更好维护。
将headers、data封装起来,从用例层分离,可以增加代码阅读性和维护性。
创建common文件夹,并新建一个py文件HttpReq。
# !/usr/bin/python
# -*- coding:utf-8 -*-
import requests
class HttpReq(object):
def __init__(self):
self.headers = {"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"
}
# GET 请求
def get(self, url='', data='', cookies=None):
response = requests.get(url=url, data=data, headers=self.headers, cookies=cookies)
return response
# POST 请求
def post(self, url='', data='', cookies=None):
response = requests.post(url=url, data=data, headers=self.headers, cookies=cookies)
return response
# PUT 请求
def put(self, url='', params='', data='', cookies=None):
response = requests.put(url=url, params=params, data=data, headers=self.headers, cookies=cookies)
return response
# DELETE 请求
def delete(self, url='', data='', cookies=None):
response = requests.delete(url=url, data=data, headers=self.headers, cookies=cookies)
return response
ETReq = HttpReq()
数据库清理
解决第二个问题:补全数据库方法
在common目录下,再新建一个db_funcs.py文件,用于构造数据库相关的方法。
# !/usr/bin/python
# -*- coding:utf-8 -*-
import sqlite3
from config.ProjectConfig import ETConfig
def execute_db(sql):
"""
连接接口项目sqlite数据库,并执行sql语句
:param sql: sql语句
:return:
"""
# 打开数据库连接
conn = sqlite3.connect("{0}\\studentManagementSystem\\db.sqlite3".format(ETConfig.PROJECT_DIR))
# 新建游标
cursor = conn.cursor()
# 执行sql
cursor.execute(sql)
# 获取执行结果
result = cursor.fetchall()
# 关闭游标、提交连接、关闭连接
cursor.close()
conn.commit()
conn.close()
return result
def init_db():
"""
初始化数据库,删除掉departments的所有数据
:return:
"""
execute_db("delete from departments;")
if __name__ == '__main__':
init_db()
优化用例
# !/usr/bin/python
# -*- coding:utf-8 -*-+
import unittest
import json
from config.ProjectConfig import ETConfig
from day05.easytest.common.db_funcs import init_db
from day05.easytest.common.HttpReq import ETReq
class AddDepartment(unittest.TestCase):
def setUp(self):
init_db()
def tearDown(self):
init_db()
def test_add_department_001(self):
"""新增T01学院"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps({"data":[{"dep_id":"T01","dep_name":"Test学院","master_name":"Test-Master","slogan":"Here is Slogan"}]}))
# 查看请求的结果
print(result.status_code,result.text)
def test_add_department_002(self):
"""重复新增T01学院"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps({"data":[{"dep_id":"T01","dep_name":"Test学院","master_name":"Test-Master","slogan":"Here is Slogan"}]}))
# 查看请求的结果
print(result.status_code,result.text)
if __name__ == '__main__':
# 构造测试
suite = unittest.TestSuite()
suite.addTest(AddDepartment("test_add_department_001"))
suite.addTest(AddDepartment("test_add_department_002"))
# 本地测试用,可以在控制台看到日志
runner = unittest.TextTestRunner()
test_result = runner.run(suite)
断言
用来判断响应结果是否和预期结果一致。
def test_add_department_001(self):
"""新增T01学院"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps({"data":[{"dep_id":"T01","dep_name":"Test学院","master_name":"Test-Master","slogan":"Here is Slogan"}]}))
# 判断请求结果
result = json.loads(result.text)
# self.assertEqual(result['already_exist']['count'], 0) # 断言会失败
self.assertEqual(result['already_exist']['count'], 1) # 断言会失败
接口断言,本质上很简单,不过在真实场景下,你得解析复杂的数据结构,得根据业务场景细分断言种类,如接口断言、数据库断言等等,这些略有难度,但上手后,你依旧能很快掌握。
跳过skip
unittest是python的基础库,能满足我们做自动化测试的大部分场景,但,仍旧有很多场景无法满足我们的需求。我们要做的是理解unittest的运行机制,做封装,或者选择其他框架,一切以多快好省的达到测试目的为准则。
unittest默认的有四种跳过方式(以装饰器实现)。
如果默认的四种无法实现需求,就需要自己进行封装
在common文件夹下,新建一个wrapers.py文件,用来封装跳过方法。
# !/usr/bin/python
# -*- coding:utf-8 -*-
import unittest
from functools import wraps
def skip_related_case(related_case_name=''):
"""
AB关联用例
如果A用例跳过执行、执行报错、断言失败,则B用例不执行
:param related_case_name: 关联用例A的名字
:return:
"""
def wraper_func(func):
@wraps(func)
def inner_func(*args, **kwargs):
fail_cases = str([fail[0] for fail in args[0]._outcome.result.failures])
error_cases = str([error[0] for error in args[0]._outcome.result.errors])
skip_cases = str([error[0] for error in args[0]._outcome.result.skipped])
if (related_case_name in fail_cases):
reson = "{}断言失败,跳过执行{}用例".format(related_case_name, func.__name__)
test = unittest.skipIf(True, reson)(func(*args, **kwargs))
elif (related_case_name in error_cases):
reson = "{}执行报错,跳过执行{}用例".format(related_case_name, func.__name__)
test = unittest.skipIf(True, reson)(func(*args, **kwargs))
elif (related_case_name in skip_cases):
reson = "{}跳过未执行,跳过执行{}用例".format(related_case_name, func.__name__)
test = unittest.skipIf(True, reson)(func(*args, **kwargs))
else:
test = func
return test(*args, **kwargs)
return inner_func
return wraper_func
回到用例类,尝试给test_add_department_002装饰上我们刚刚创建的skip_related_case方法。
@skip_related_case('test_add_department_001')
def test_add_department_002(self):
"""重复新增T01学院"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps({"data":[{"dep_id":"T01","dep_name":"Test学院","master_name":"Test-Master","slogan":"Here is Slogan"}]}))
# 判断请求结果
result = json.loads(result.text)
self.assertEqual(result['already_exist']['count'], 1)
日志
因为接口自动化测试的特殊性,写完自动化脚本后,我们一般会将脚本放在服务器上执行。执行成功还好,但执行失败,或者执行异常,该怎么办呢?
举个实际的例子,在AddDepartment新增模块中,以下三步都可能导致执行异常:
-
初始化数据库可能失败
-
发送post请求可能失败
-
解析响应可能失败
-
我们不知道执行哪一条用例失败,也不知道哪一步失败,显得特被动。
因此,根据需要记录合理的日志,显得很有必要。
-
在common文件夹下创建一个logger.py文件。
import logging
import os
from logging import handlers
from config.ProjectConfig import ETConfig
def logger():
os.makedirs("{}logs".format(ETConfig.TEST_DIR), exist_ok=True)
log = logging.getLogger("{}logs/et.log".format(ETConfig.TEST_DIR))
format_str = logging.Formatter('%(asctime)s [%(module)s] %(levelname)s [%(lineno)d] %(message)s', '%Y-%m-%d %H:%M:%S')
# 按天录入日志,最多保存7天的日志
handler = handlers.TimedRotatingFileHandler(filename=("{}logs/et.log".format(ETConfig.TEST_DIR)), when='D', backupCount=7, encoding='utf-8')
log.addHandler(handler)
log.setLevel(logging.INFO)
handler.setFormatter(format_str)
return log
write_log = logger()
修改
用例层
为方便统一管理和维护,我们在wrapers.py下新建了一个write_case_log的装饰方法。
日志也可以用来记录用例的运行情况。
def write_case_log():
"""
记录用例运行日志
:return:
"""
def wraper_func(func):
@wraps(func)
def inner_func(*args, **kwargs):
write_log.info('{}开始执行'.format(func.__name__))
func(*args, **kwargs)
write_log.info('{}执行中'.format(args))
write_log.info('{}结束执行'.format(func.__name__))
return inner_func
return wraper_func
然后给用例套个头:
日志记录很容易实现,但又不容易做好。总的思路是在合适的位置加合适的日志,遇到问题能快速定位。
数据驱动
数据驱动,相同的测试用例,每次使用不同的测试数据。将测试数据与测试用例分开,可以降低测试用例维护成本。
将测试数据与测试用例分开,可以降低测试用例的维护成本,特别是在接口测试中,你可以将所有信息,如输入、输出、预期结果,都以适当的形式记录为数据集,执行测试时,只需要修改数据集,而无需修改测试用例。
unittest不支持数据驱动,一般需要借助第三方ddt库实现。
新建文件,以字典形式存放测试数据。
在testcase目录下,新建一个data目录,再创建DepartmentData.py文件,用于存放测试数据。
为统一维护,我们将新增学院用例中的数据全部抽离出来,形成ADD_DATA数据集。
# !/usr/bin/python
# -*- coding:utf-8 -*-
# res_key未实现JSON分级分类解析,你可以自己尝试实现
ADD_DATA = {
"test_add_department_001": {
"req_data": {"data": [{"dep_id": "T01", "dep_name": "Test学院", "master_name": "Test-Master", "slogan": "Here is Slogan"}]},
"res_key": "already_exist",
"res_value": 0},
"test_add_department_002": {
"req_data": {"data": [{"dep_id": "T01", "dep_name": "Test学院", "master_name": "Test-Master", "slogan": "Here is Slogan"}]},
"res_key": "already_exist",
"res_value": 1},
"test_add_department_003": {
"req_data": {"data": [{"dep_id": "", "dep_name": "dep_id为空学院", "master_name": "dep_id为空Master", "slogan": "Here is dep_id为空"}]},
"res_key": "dep_id",
"res_value": "该字段不能为空。"},
"test_add_department_004": {
"req_data": {"data": [{"dep_id": "T02", "dep_name": "", "master_name": "dep_name为空Master", "slogan": "Here is dep_name为空"}]},
"res_key": "dep_name",
"res_value": "该字段不能为空。"},
"test_add_department_005": {
"req_data": {"data": [{"dep_id": "T02", "dep_name": "T02学院", "master_name": "", "slogan": "Here is master_name为空"}]},
"res_key": "master_name",
"res_value": "该字段不能为空。"},
"test_add_department_006": {
"req_data": {"data": [{"dep_id": "T02", "dep_name": "T02学院", "master_name": "T02Master", "slogan": ""}]},
"res_key": "already_exist",
"res_value": 0},
}
修改用例:
-
装饰ddt的data和unpack(注意类前面需要@ddt)
-
增加入参
-
修改请求数据
-
修改响应判断
tips:ddt可以加载列表,字典、元组等python数据格式,也可以通过file_data加载json/txt/yaml等数据文件。
from day09.easytest.testcase.data.DepartmentData import ADD_DATA
from ddt import ddt, data, unpack
...
@data(ADD_DATA['test_add_department_001'])
@unpack
@write_case_log()
def test_add_department_001(self, req_data, res_key, res_value):
"""新增T01学院"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps(req_data))
result = json.loads(result.text)
self.assertEqual(result[res_key]['count'], res_value)
...
@data(ADD_DATA['test_add_department_001'], ADD_DATA['test_add_department_002'],ADD_DATA['test_add_department_006'],ADD_DATA['test_add_department_007'],ADD_DATA['test_add_department_008'],ADD_DATA['test_add_department_009'],ADD_DATA['test_add_department_0010'])
# @data(ADD_DATA)
@unpack
@write_case_log()
def test_add_department_1(self,req_data,res_key,res_value,status_code,des):
print(self._testMethodName+des)
r=ETReq.post(url=self.url,data=json.dumps(req_data))
print("响应文本为:"+r.text)
response=json.loads(r.text)
self.assertEqual(response[res_key]['count'],res_value)
self.assertEqual(r.status_code,status_code)
print("{}执行成功".format(self._testMethodName))
完整代码
# !/usr/bin/python
# -*- coding:utf-8 -*-+
from config.ProjectConfig import ETConfig
from day09.easytest.common.db_funcs import init_db
from day09.easytest.common.HttpReq import ETReq
from day09.easytest.common.wrapers import skip_related_case,write_case_log
from day09.easytest.testcase.data.DepartmentData import ADD_DATA
from ddt import ddt, data, unpack
import unittest
import json
@ddt
class AddDepartment(unittest.TestCase):
@classmethod
def setUpClass(cls):
init_db()
@data(ADD_DATA['test_add_department_001'])
@unpack
@write_case_log()
def test_add_department_001(self, req_data, res_key, res_value):
"""新增T01学院"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps(req_data))
result = json.loads(result.text)
self.assertEqual(result[res_key]['count'], res_value) # res_key未实现JSON分级分类解析,你可以自己尝试实现
@data(ADD_DATA['test_add_department_002'])
@unpack
@write_case_log()
@skip_related_case('test_add_department_001')
def test_add_department_002(self, req_data, res_key, res_value):
"""重复新增T01学院"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps(req_data))
# 判断请求结果
result = json.loads(result.text)
self.assertEqual(result[res_key]['count'], res_value)
@data(ADD_DATA['test_add_department_003'], ADD_DATA['test_add_department_004'], ADD_DATA['test_add_department_005'])
@unpack
@write_case_log()
def test_add_department_003(self, req_data, res_key, res_value):
"""为空校验-dep_id/dep_name/master_name为空校验"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps(req_data))
# 判断请求结果
result = json.loads(result.text)
self.assertEqual(result[res_key][0], res_value)
@data(ADD_DATA['test_add_department_006'])
@unpack
@write_case_log()
def test_add_department_006(self, req_data, res_key, res_value):
"""为空校验-slogan为空校验"""
result = ETReq.post(url=ETConfig.URL,
data=json.dumps(req_data))
result = json.loads(result.text)
self.assertEqual(result[res_key]['count'], res_value)
if __name__ == '__main__':
# 构造测试
suite = unittest.TestSuite()
suite.addTest(AddDepartment("test_add_department_001"))
suite.addTest(AddDepartment("test_add_department_002"))
suite.addTest(AddDepartment("test_add_department_003"))
suite.addTest(AddDepartment("test_add_department_006"))
# 本地测试用,可以在控制台看到日志
runner = unittest.TextTestRunner()
test_result = runner.run(suite)
做数据驱动后,你可以将重心关注在数据层面,比如,如何快速生成数据、如何覆盖更多校验场景、如何减少冗杂数据、如何模拟线上真实数据等问题上。
测试报告
统计展示的方法很多,如接口计数、网页展示、邮件报告等。本项目叫easytest,我们便实现最简单的一种——通过邮件展示自动化测试的结果。
1、获取邮箱授权码,如可以用QQ邮箱
将邮箱账号和授权码放到ProjectConfig.py文件内。
unittest没有统计、展示结果的功能,故需引入第三方库——HTMLTestRunner。
将main文件夹下的run_case方法修改为RunCase类,做以下两步修改:
1、运行测试用例,并将测试结果汇总为html文件
2、读取html文件,以邮件的形式发送测试报告
# !/usr/bin/python
# -*- coding:utf-8 -*-
from day10.easytest.common.HTMLTestRunnerCNs import HTMLTestRunner
from day10.easytest.common.logger import write_log
from config.ProjectConfig import ETConfig
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smtplib
import platform
import unittest
import os.path
import time
class RunCase(object):
def __init__(self):
# 邮箱信息
self.smtpserver = ETConfig.EMAIL_CONFIG['EMAIL_SERVER']
self.user = ETConfig.EMAIL_CONFIG['EMAIL_USER']
self.password = ETConfig.EMAIL_CONFIG['EMAIL_PWD']
self.sender = ETConfig.EMAIL_CONFIG['EMAIL_SENDER']
self.receiver = ETConfig.EMAIL_CONFIG['EMAIL_RECEIVER']
self.cc = ETConfig.EMAIL_CONFIG['EMAIL_CC']
# html报告路径
self.report_dir = "{}report".format(ETConfig.TEST_DIR)
def run_case(self):
# 如果report文件夹不存在,创建文件夹
if "report" not in os.listdir(ETConfig.TEST_DIR):
os.mkdir(self.report_dir)
# 运行测试用例并写入html文件
with open('{}\\et_result.html'.format(self.report_dir), 'wb') as fp:
# 运行./路径下的TEST.py文件,视自己的情况修改路径
try:
write_log.info("RunCase执行用例--开始")
suite = unittest.TestSuite()
tests = unittest.defaultTestLoader.discover('..\\testcase', pattern='*Department.py')
suite.addTest(tests)
runner = HTMLTestRunner(stream=fp, title=u'自动化测试报告', description=u'运行环境:{}'.format(platform.platform()), tester="测试奇谭")
runner.run(suite)
write_log.info("RunCase执行用例--结束")
except Exception as e:
write_log.error("RunCase执行用例,生成报告失败:{}".format(e))
def send_mail(self):
"""
发送邮件
:return:
"""
# 打开报告文件
with open('{}\\et_result.html'.format(self.report_dir), 'rb') as f:
mail_body = str(f.read(), encoding="utf-8")
msg = MIMEMultipart('mixed')
msg_html = MIMEText(mail_body, 'html', 'utf-8')
msg_html["Content-Disposition"] = 'attachment; filename="TestReport.html"'
msg.attach(msg_html)
msg_html1 = MIMEText(mail_body, 'html', 'utf-8')
msg.attach(msg_html1)
msg['Subject'] = u'【easytest】自动化测试报告 {}'.format(time.strftime("%Y-%m-%d", time.localtime()))
msg['From'] = u'AutoTest <%s>' % self.sender
msg['To'] = self.receiver
msg['Cc'] = self.cc
try:
smtp = smtplib.SMTP()
smtp.connect(self.smtpserver)
smtp.login(self.user, self.password)
smtp.sendmail(self.sender, self.receiver, msg.as_string())
smtp.quit()
write_log.info("发送邮件成功!")
except Exception as e:
write_log.error("发送邮件失败:{}".format(e))
if __name__ == '__main__':
test = RunCase()
test.run_case()
test.send_mail()