接口自动化测试实战-学生信息管理系统

  • Post author:
  • Post category:其他


接口测试用例

模拟不同的参数去覆盖更多的代码逻辑,尽量把接口的每一种返回结果都测到。


什么项目可以做自动化测试?


项目稳定、需求变更少,研发和维护周期长,有足够的的资源,更好做一点。


为什么要做自动化?


为了测试工作服务。不能为了自动化而自动化。

你需要量化你的自动化测试效果。举个简单的例子,做完自动化测试,一周能发现一个问题不?一个月能发现一个问题不?节省了多少工时?提升了多少效率?

本次实战,是

基于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},
}

修改用例:

  1. 装饰ddt的data和unpack(注意类前面需要@ddt)

  2. 增加入参

  3. 修改请求数据

  4. 修改响应判断

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()




版权声明:本文为seanyang_原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。