代码改变世界

测开新手学自动化:分享几点搭建自动化测试框架经验

2021-03-31 09:44  狂师  阅读(289)  评论(0编辑  收藏  举报

一、开头说两点

传统软件测试行业是以手工测试为主,也就是所谓的点点点,加上国内软件公司不注重测试,受制于大环境影响等也就给了大众一种测试人员虽然身处互联网行业,却是毫无技术可言的工种。

话锋一转,到了如今,不得不说一声:大人,时代变了,最直观的表现莫过于招聘要求的提高,越来越要求测试人员拥有七十二变的能力。而在这其中,自动化测试能力是现在手工测试迈向更高技术岗位的必经之路。

大家好,我是黎潘,我又来了,作为一名行业新手,我也是兴致满满,选择了当下较为火热,且入门简单的Python语言作为我迈向自动化测试工程师的重要帮手。所以以下讨论的皆是与python相关的如何实现自动化的总结,当然肯定不止这一门语言可以实现,最好与实际项目需求和个人能力相结合,选择最适合自己的自动化测试之路。

二、初识自动化测试

广义上来讲,自动化包括一切通过工具(程序)的方式来代替或辅助手工测试的行为都可以看作是自动化。狭义上来讲,通过工具记录或编写脚本的方式模拟手工测试的过程,通过回放或运行脚本来执行测试用例,从而代替人工对系统的功能进行验证。通俗易懂点就是一切能代替手工来执行测试用例,提高效率,不断回归的测试方法,在我眼里都能算是自动化测试。

2. 为什么要做自动化测试

2.1 减少手工测试占比

自动化测试可以替代大量的手工机械重复性操作,测试工程师可以把更多的时间花在更全面的用例设计新性功能的测试上。

2.2 提升回归效率

自动化测试可以大幅提升回归测试的效率,测试人员不用花费大量时间去校验原有功能的正确性,最大的优点是非常适合敏捷开发过程中,也就是加入到CI/CD中。

2.3 持续测试系统的稳定

自动化测试可以高效实现某些手工测试无法完成或者代价巨大的测试类型。比如关键核心业务需要24小时持续运行的稳定性测试。

2.4 增加竞争力

随着测试行业的发展,测试人们的发展方向越来越广,技术方向越来越多样化,更多的测试人倾向于往高技术攀爬。而拥有自动化测试的能力在以后很有可能是我们选择工作的敲门砖了。虽然不少人都对这种变化感到惶恐不安,但是更多的人选择站在狂风处,迎接挑战,增加自身的竞争力,拥抱明天。

3. 什么项目适合自动化测试

3.1 需求稳定,不频繁变更

测试脚本的稳定性决定了自动化测试的维护成本。如果软件需求变动过于频繁,测试人员需要根据变动的需求来更新测试用例以及相关的测试脚本,而脚本的维护本身就是一个代码开发的过程,需要修改,调试,必要的时候还要修改自动化框架,如果花费的成本高于其节省的成本,那么自动化测试是失败的。

我们可以优先对项目中核心模块,相对稳定的模块进行自动化,而变动较大的仍是用手工测试。

3.2 研发和维护周期长

由于自动化测试需求的确定,自动化测试框架的设计,测试脚本的编写与调试均需要相当长的时间来完成。这样的过程本身就是一个测试软件地开发过程,需要较长的时间来完成。如果项目周期比较短,没有足够的时间去支持这样一个过程,那么自动化测试便毫无意义。

3.3 项目资源足够

自动化测试从需求范围的确定,到自动化测试框架的设计,以及脚本的编写与调试,均需要相当长的时间来完成。这样的过程本身就是一个测试软件的开发过程。因此有足够的人力,物力非常重要。

三、搭建自己的接口测试框架

3.1 构建接口测试思维

当前互联网产品最大的特点就是,上线周期通常是以"天"甚至是以"小时"为单位,而传统软件产品的周期多以"月",甚至以"年"为单位。因此,如何在保证产品质量下,有效缩短测试回归时间成了重中之重。

两个突破口:

  • 引入测试的并发执行,即从以往的串行执行测试用例,采用分布式的方法并行执行。
  • 从测试策略上找到突破口,从传统软件产品的金字塔测试策略往菱形测试策略转变。以接口测试为主,GUI测试为辅,单元测试则根据公司实际情况进行。

四点建议:

  • 以中间层的API测试为重点做全面的测试
  • 轻量级的GUI测试,只覆盖最核心直接影响主营业务流程的E2E场景
  • 最上层的GUI测试通常利用探索式测试思维,以人工测试的方式发现尽可能多的潜在问题
  • 单元测试只对那些相对稳定并且核心的服务和模块开展全面的单元测试,而应用层或者上层业务只会做少量的

3.2 搭建自己的接口测试框架

3.2.1 为何要搭建自己的测试框架

  • 开发自己的框架更能结合自身工作中的痛点,难点来做一个针对性的解决,使其扩展性更高,后期也能接入CI/CD。
  • 利用现有工具来进行接口测试,随着项目的规模变大,维护成本将会增大,不利于管控。
  • 工具本身具有一定的局限性,如支持的协议比较单一。
  • 不用纠结技术选型,根据自身的技术实力和技术功底来选择,而不要以开发工程师的技术栈来选择。

3.2.2 定义专属框架目录结构

  • test_case:存放测试用例
  • test_data:存放测试数据
  • report:存放测试报告
  • common:存放公共方法
  • lib:存放第三方库
  • config:存放环境配置信息
  • main:框架主入口
  • fixture:类似unittest中的setUp/tearDown的存在,但功能远比他们强大

3.2.3 构建框架流程

在框架构建过程中,由于篇符有限,本文只涉及其中部分环节。

1、在common公共模块、封装定义框架专属的http请求能力

# !/usr/bin/python3
# -*- coding: utf-8 -*-
# @Author: pan-li
import requests


class HttpRequests(object):

    def __init__(self, url):
        self.url = url
        self.req = requests.session()
        # 自定义请求头,根据自身所在公司项目需求
        self.headers = {'Content-Type': 'application/json', 'User-Agent': 'Node midway-v2x Version/1.28.1'}
	
    # 封装get请求
    def get(self, url='', params='', data='', headers=None, cookies=None):
        response = self.req.get(url=url, params=params, data=data, headers=headers, cookies=cookies)
        return response
	
    # post请求
    def post(self, url='', params='', data='', headers=None, cookies=None):
        response = self.req.post(url=url, params=params, data=data, headers=headers, cookies=cookies)
        return response
	
    # put请求
    def put(self, url='', params='', data='', headers=None, cookies=None):
        response = self.req.put(url=url, params=params, data=data, headers=headers, cookies=cookies)
        return response

    # delete请求
    def delete(self, url='', params='', data='', headers=None, cookies=None):
        response = self.req.delete(url=url, params=params, data=data, headers=headers, cookies=cookies)
        return response

2、抽离URL生成url_conf.py在config文件中

import enum


class URLConf(enum.Enum):
    TEST_URL = 'http://10.12.7.20:8443/v2x-omp/api/'

3、编写接口测试用例在test_case文件中,第一版测试用例,安装pytest,pip install -U pytest

import os
import sys
import pytest
import json
from common.http_requests import *
from config.url_conf import URLConf
project_root = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
sys.path.append(project_root)

class TestV2x:

    @classmethod
    def setup_class(cls) -> None:
        cls.url = URLConf.TEST_URL.value
        cls.http = HttpRequests(cls.url)

    def setup(self) -> None:
        self.headers = {'Content-Type': 'application/json', 'User-Agent': 'Node midway-v2x Version/1.28.1'}
        self.http = HttpRequests(self.url)

    def tearDown(self):
        pass

    @staticmethod
    def get_token():
        headers = {'Content-Type': 'application/json', 'User-Agent': 'Node midway-v2x Version/1.28.1'}
        response = TestV2x.http.post(url=URLConf.TEST_URL.value, data='{"cmd":"signin","params":{"userName":"smarttest","password":"72be4b7f62832c516b85fb26de59df53"}}', headers=headers)
        token = response.json()['detail']['token']
        return token

    def test_001_queryArea(self):
        """查询区域"""
        playload = {"cmd": "queryArea", "csrfToken": TestV2x.get_token(), "params": {"cityId": "320200"}}
        response = TestV2x.http.post(self.url, data=json.dumps(playload), headers=self.headers)
        resultNote = response.json().get('resultNote')
        assert resultNote, 'Success'

    def test_002_queryYearlyCheckCount(self):
        """查询年检总数"""
        playload = {"cmd": "queryYearlyCheckCount", "Token": TestV2x.get_token(), "params": {}}
        response = TestV2x.http.post(self.url, data=json.dumps(playload), headers=self.headers)
        resultNote = response.json().get('resultNote')
        assert resultNote, 'SUCCESS'

    def test_003_queryTrafficEvent(self):
        """查询交通事件"""
        playload = {"cmd": "queryTrafficEvent", "Token": TestV2x.get_token(), "params": {}}
        response = TestV2x.http.post(self.url, data=json.dumps(playload), headers=self.headers)
        resultNote = response.json().get('resultNote')
        assert resultNote, 'Success'

    def test_004_queryRsuCount(self):
        """查询rsu总数"""
        playload = {"cmd": "queryRsuCount", "Token": TestV2x.get_token(), "params": {}}
        response = TestV2x.http.post(self.url, data=json.dumps(playload), headers=self.headers)
        resultNote = response.json().get('resultNote')
        assert resultNote, '查询路测设备数量成功!'

    def test_005_queryDeviceDetail(self):
        """查询设备详情"""
        playload = {"cmd": "queryDeviceDetail", "params": {"deviceId": '0086860703231572'}, "Token": TestV2x.get_token()}
        response = TestV2x.http.post(self.url, data=json.dumps(playload), headers=self.headers)
        resultNote = response.json().get('resultNote')
        assert resultNote, '查询终端信息成功!'


if __name__ == '__main__':
    pytest.main()

4、显然前面的测试用例也是流水账似的,还有很大的优化空间,现在就来一步一步进行。

5、优化一:利用feature特性优化前置和后置条件,fixture目录下的v2x_fixture.py文件

import pytest
from common.http_requests import HttpRequests
from config.url_conf import URLConf


@pytest.fixture(scope='function', autouse=True)
def http():
    url = URLConf.TEST_URL.value
    http = HttpRequests(url)
    return http


@pytest.fixture(scope='function', autouse=True)
def get_token(http):
    headers = {'Content-Type': 'application/json', 'User-Agent': 'Node midway-v2x Version/1.28.1'}
    response = http.post(url=URLConf.TEST_URL.value,
                         data='{"cmd":"signin","params":{"userName":"smarttest","password":"72be4b7f62832c516b85fb26de59df53"}}',
                         headers=headers)
    token = response.json()['detail']['token']
    return token

上述在引入feature之后,简化了http请求的调用,重新定义http()来进行调用。之前每次接口的调用都要附带token参数,现在把获取token的方法提取出来,单独封装,加上feature的装饰,他会作用与每一个方法,用起来更加方便。此处的token是依赖登陆接口之后返回的值,可根据自身项目的需求封装。

6、优化二: 为测试用例添加数据驱动模式

# 以第五个测试用例单独为例
@pytest.mark.parametrize('deviceid', ['0086860703231572', '0086337601270714', '0086822412608154'])
    def test_005_queryDeviceDetail(self, http, get_token, deviceid):
        """查询设备详情"""
        playload = {"cmd": "queryDeviceDetail", "params": {"deviceId": deviceid}, "Token": get_token}
        response = http.post(url=URLConf.TEST_URL.value, data=json.dumps(playload), headers=URLConf.HEADERS.value)
        resultNote = response.json()
        assert resultNote.get('resultNote'), '查询终端信息成功!'
        logger.info('查询终端信息成功!')
"""直接利用pytest.mark.parametrize()装饰器,第一个参数为参数名,后边数组为测试数据,用例当中同样添加形参deviceid"""

在 pytest 中,数据驱动是经由 pytest 自带的 pytest.mark.parametrize() 来实现的。 pytest.mark.parametrize 是 pytest 的内置装饰器,它允许你在 function 或者 class 上定义多组参 数和 fixture 来实现数据驱动。

**@pytest.mark.parametrize() ** 装饰器接收两个参数:

  • 第一个参数以字符串的形式存在,它代表能被被测试函数所能接受的参数,如果被测试函数有多个参数, 则以逗号分
  • 第二个参数用于保存测试数据。如果只有一组数据,以列表的形式存在,如果有多组数据,以列表嵌套元 组的形式存在

7、优化三: 为测试用例添加标签,此时用到pytest.ini配置文件,放在项目任意位置都能生效,有以下作用

  • 为你的测试框架定制用例查找规则
  • 为你的测试框架注册标签名称
  • 指定查找用例起始目录
[pytest]
python_files = test_*  *_test test*
python_classes = Test* test*
python_functions = test_* test*

markers =
    smoke: marks tests as smoke
    test : marks tests as test
    log : marks tests as log
# 使用时只需要在测试用例上使用@pytest.mark.smoke即可
# 执行时pytest -m [标记名]

8、优化四: 配置pytest.ini文件集成日志收集和实时控制台打印功能

[pytest]
log_cli = 1
log_cli_level = DEBUG
log_cli_date_format = %Y-%m-%d-%H-%M-%S
log_cli_format = %(asctime)s - %(filename)s - %(name)s - %(module)s - %(funcName)s - %(lineno)d - %(levelname)s - %(message)s
log_file = ..\\report\\run.log
log_file_level = DEBUG
log_file_date_format = %Y-%m-%d-%H-%M-%S
log_file_format = %(asctime)s - %(filename)s -%(name)s - %(module)s - %(funcName)s - %(lineno)d - %(levelname)s - %(message)s

关于字段的详解可以在终端输入pytest --help 查看

9、优化五: 定制测试框架测试报告,属于第三方应用放在lib目录中

这里我们使用目前市面上使用人数较多的一款开源测试报告框架Allure,它支持绝大多数测试框架

安装方法:

使用方法:

  • 执行pytest命令,并指定allure报告目录: pytest -v -s test_v2x_api_02.py --alluredir=./allure_reports
  • 在线生成allure报告:allure serve allure_reports
  • 生成本地allure报告:allure generate allure_reports

当然这只是在控制台直接命令执行,还不够方便,如果我们想在其他环境运行就又得配置环境变量,那么我们如何把它集成到我们的框架中呢

在共同方法中生成allure工具类,以便分辨运行环境是windows还是mac

import os
import sys
import platform


path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'lib')
allure_path = os.path.join(path, 'allure', 'bin')
sys.path.append(allure_path)


class Report():
    @property
    def allure(self):
        if platform.system() == 'Windows':
            cmd = os.path.join(allure_path, 'allure.bat')
        else:
            cmd = os.path.join(allure_path, 'allure')
        return cmd

10、在main模块中,添加执行调度策略

import os
import threading
import pytest

from common.report import Report

project_root = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
report_dir = os.path.join(project_root, 'report')
result_dir = os.path.join(report_dir, 'allure_result')
allure_report = os.path.join(report_dir, 'allure_report')
report = Report()


def run_pytest():
    pytest.main(['-v', '-s', f'--alluredir={result_dir}'])


def general_report():
    cmd = "{} generate {} -o {} --clean".format(report.allure, result_dir, allure_report)
    print(os.popen(cmd).read())


if __name__ == '__main__':
    run = threading.Thread(target=run_pytest)
    gen = threading.Thread(target=general_report)
    run.start()  # 多线程先执行pytest命令生成测试报告
    run.join()
    gen.start()	# 报告生成后调用allure工具类生成本地报告

11、最后一版测试用例,整合前面的优化

import os
import sys
import json
from fixture.v2x_fixture import *
from config.url_conf import URLConf
project_root = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
sys.path.append(project_root)




class TestV2x:
    @pytest.mark.smoke  # 标签的使用
    def test_001_queryArea(self, http, get_token):
        """查询区域"""
        playload = {"cmd": "queryArea", "csrfToken": get_token, "params": {"cityId": "320200"}}
        response = http.post(url=URLConf.TEST_URL.value, data=json.dumps(playload), headers=URLConf.HEADERS.value)
        resultNote = response.json()
        assert resultNote.get('resultNote'), 'success'
        logger.info('查询区域成功')

    def test_002_queryYearlyCheckCount(self, http, get_token):
        """查询年检总数"""
        playload = {"cmd": "queryYearlyCheckCount", "Token": get_token, "params": {}}
        response = http.post(url=URLConf.TEST_URL.value, data=json.dumps(playload), headers=URLConf.HEADERS.value)
        resultNote = response.json()
        assert resultNote.get('resultNote'), 'SUCCESS'
        logger.info('查询年检成功')

    def test_003_queryTrafficEvent(self, http,get_token):
        """查询交通事件"""
        playload = {"cmd": "queryTrafficEvent", "Token": get_token, "params": {}}
        response = http.post(url=URLConf.TEST_URL.value, data=json.dumps(playload), headers=URLConf.HEADERS.value)
        resultNote = response.json()
        assert resultNote.get('resultNote'), 'Success'
        logger.info('查询交通事件成功')

    def test_004_queryRsuCount(self, http, get_token):
        """查询rsu总数"""
        playload = {"cmd": "queryRsuCount", "Token": get_token, "params": {}}
        response = http.post(url=URLConf.TEST_URL.value, data=json.dumps(playload), headers=URLConf.HEADERS.value)
        resultNote = response.json()
        assert resultNote.get('resultNote'), '查询路测设备数量成功!'
        # text = response.text
        # print(text)
        logger.info('查询路侧设备成功')
	
    # 简单的数据驱动
    @pytest.mark.parametrize('deviceid', ['0086860703231572', '0086337601270714', '0086822412608154'])
    def test_005_queryDeviceDetail(self, http, get_token, deviceid):
        """查询设备详情"""
        playload = {"cmd": "queryDeviceDetail", "params": {"deviceId": deviceid}, "Token": get_token}
        response = http.post(url=URLConf.TEST_URL.value, data=json.dumps(playload), headers=URLConf.HEADERS.value)
        resultNote = response.json()
        assert resultNote.get('resultNote'), '查询终端信息成功!'
        logger.info('查询终端信息成功!')


if __name__ == '__main__':
    # 打印更详细的信息
    pytest.main(['-s', '-v', ])

四、总结

关于这次自动化测试学习分享,涉及到的知识,只是冰山一角,参加狂师老师的全栈测开训练营收获非常大,还有很多的知识点没有使用到,也就是我们的测试框架依然还有很多优化的空间,后续我会继续,将细节补充到位,同时分享一些高阶的用法。

原文出自发表于:公众号:测试开发技术

技术改变世界! --狂诗绝剑