摘要
随着互联网的高速发展,软件技术日新月异,产品更新换代的加快等,始终都离不开一个最核心的要素就是保证产品的质量,测试人员则在其中担任着不可或缺的角色。测试人员的主要工作职责就是通过各种测试手段去发现软件潜在的漏洞,最终保证产品质量。但随着敏捷开发的盛行,适用于解决传统手工回归测试效率低的痛点的自动化测试技术也越来越受到测试人员的重视。本文所探讨的就是软件自动化测试框架的实现,首先是对需求进行分析,然后通过对比国内外成熟的自动化测试框架技术进行技术选型,最终确定使用基于Python语言的,结合Selenium Webdirver和Pytest的框架技术,本文阐述了该框架的架构设计、脚本的开发调试、环境的搭建部署、代码的运行监控、报告的生成等功能的实现过程。该框架目前运行良好,且切切实实的提高了软件自动化回归测试的效率。
关键词
自动化测试、Python、Selenium、PO设计模式
任何软件都需要通过严格的测试,确定其满足了产品需求,且可以正常运作了才能投入市场。所以软件测试的使命就是应尽可能的发现软件存在的漏洞,且能够适应快速迭代的需求,能在短时间内高效的,低成本的完成,软件测试技术也将面临不小的挑战。质量团队需要鉴于测试行业流行的行业标准,吸收国内外先进的测试技术,以寻求符合自己团队的测试方案。
传统的手工测试仍然是一种高质量的测试手段,其重要程度不容忽视,但相应的也存在一定的缺点,比如:有限的人力无法满足快速迭代时大量的回归测试需求、无法实现24小时随时随地测试等等。因此我们需要在功能测试的基础上,通过自动化的测试手段去弥补我们人工不能做到的测试执行工作。
自动化测试的实现我们可以选择国内外成熟的测试辅助工具,也可以自己开发,都各自有优缺点,需要根据当下的质量团队,和测试需求综合考量。
作为软件工程质量管理领域里面一个非常重要的节点-软件测试自动化,在国内已日趋走向成熟化,而Web UI自动化测试虽然作为分层测试(测试金字塔)中的最顶层,但我们却不能忽视其在整个软件生命周期中质量保证环节中所起到的重要作用。
在高速发展的互联网时代,通过Web端输出的软件产品仍占据了大半壁江山,我们将在此探讨针对Web UI自动化测试技术的设计与实现,Web UI自动化测试的原理即是:模拟人工在Web网站上的行为和操作,以发现潜在的产品漏洞。
自动化测试的优点有:
但自动化测试也存在缺点:
基于自动化测试的优缺点,我们可以分析得出自动化测试的适用场景如下:
综上所述:本次探讨的Web UI自动化测试技术,也是基于满足自动化测试的适用场景的一个或多个前提下进行的,毕竟满足自身需求的才是最好的。利用一切可以利用的人力、资源和技术,保障产品质量稳定,是本次Web UI自动化测试开发的意义所在。
当产品趋于稳定,进入优化迭代的时候,越来越多的不会频繁变动的功能需求也将带来大量重复的回归任务,此时我们就可以考虑引入自动化测试,而模拟人工在Web端的行为和操作,我们可以通过自动化测试工具来实现,或者测试团队完全自己开发一套Web UI自动化测试框架,或者对国内外成熟的自动化测试工具进行二次封装等,但无论通过何种方式,我们首先需要分析和确定我们这个工具,或者这套框架需要实现的功能需求是什么。
我们的Web UI自动化测试方案需要实现的需求如下:
一个好的自动化测试方案除了满足以上基本的功能需求外,还需要具备可靠的稳定性。如果一套开发完成且投入使用的自动化测试脚本在运行过程中频繁出错,需要花费额外的人力去排查问题和修复脚本,那这种自动化测试将失去它最初的意义了
自动化测试的需求确定好后,我们接下来就需要考虑技术选型的问题了,我们需要根据自身的需求,再综合对比现在国内外成熟的自动化测试技术,最终确定适合自己团队的自动化测试方案最优选。
UI自动化测试的技术方案通常分为控制(控制客户端)、执行(运行通过特定API编写的测试用例)、结果上报这几个主要组成部分,同时还应该考虑这三部分所普适的编程语言。
关于编程语言的选择,本文探讨的自动化测试方案中选择的是目前主流的编程语言Python,其优点如下:
要实现Web UI自动化测试,我们就采用一种工具来实现控制客户端进行模拟人工操作,大致的操作流程就是打开目标网页,定位到目标位置,进行点击,输入等操作。目前市面上相关的工具有:Selenium、Cypress、Playwright、Puppeteer等,下面我们大致了解一下这些工具的特点:
通过综合对比:Selenium的方案最为传统,也是目前最常见的浏览器控制方法。Selenium通常需要和Webdriver配合使用,Selenium通过Webdriver控制浏览器,再对上层执行层暴露API或sdk。同时 Selenium也提供standalone server的方案,允许执行层通过调用标准restful API控制浏览器,在这种模式下对执行层的编程语言和运行时都没有任何限制,这也是 Selenium 生态繁荣的重要原因。Selenium的API封装遵循 W3C 提供的Webdriver标准,因此Selenium对各大主流浏览器的支持都不错,如果测试场景对浏览器兼容性有较高的要求,需要在多种浏览器中执行测试用例,Selenium仍是首选。同时由于Selenium已经发展多年,各种解决方案也更为完善。例如并行方案 Selenium Grid,可以支持多节点的用例负载均衡;还有在CI场景下官方维护的各种Docker Image等。
所以本文选用的控制客户端进行模拟人工操作的工具选择了目前较为成熟和流行的Selenium。
自动化测试的执行方案的核心在于选取一套最优的自动化测试框架。
首先来理解一下什么是框架?框架是系统的可重用设计,代码的组织和运行控制问题就是通过框架来解决的。在编写自动化测试脚本时,我们配置文件的读取,数据文件的读取,日志的记录等各种各样的方法在我们编写自动化测试脚本时,不可能需要用时就写一遍,多次用就写多遍,这样无形中会增加我们的工作量。所以我们需要提取出公共的方法,进行单独封装,放到公用模块里。同时我们需要将配置文件,数据文件,日志等存放到独立的文件夹中。这种对公共方法的封装及对脚本及配置文件怎么组织的设计就叫做框架。
我们可以将框架总结如下几个方面:
那何为测试框架?一个完整的测试脚本包含的步骤有以下几个:环境准备、业务操作、结果断言、环境销毁。而测试框架一般还要完成的功能有:用例加载,批量执行,异常控制,结果输出等。测试框架应具有的特点:
当下基于Python语言的比较流行的测试框架有:Unittest、Pytest、Nose、Robot Framework。我们先来做个简单的对比:
由上面对比表格分析得出:
Unittest: Python自带,最基础的单元测试框架
Nose: 基于Unittest开发,易用性好,有许多插件
Pytest: 同样基于Unittest开发,易用性好,信息更详细,插件众多
Robot Framework:一款基于Python语言的关键字驱动测试框架,有界面,功能完善,自带报告及log清晰美观
总体来说,Unittest比较基础,二次开发方便,适合高手使用;Pytest/Nose更加方便快捷,效率更高,适合小白及追求效率的公司;Robot Framework由于有界面及美观的报告,易用性更好,灵活性及可定制性略差。
综上所述,最终确定选择Pytest,测试框架应具备的特点Pytest都满足了。
前面我们在选取执行方案时,对比了不同的测试框架,框架本身基本都具备输出html测试报告的功能,但都存在不够美观的缺点。而allure-pytest是Pytest的一个插件,比传统的Pytest/Unitest 生成的html报告更加优美。我们来看看官网对Allure的介绍:
毫无疑问,Allure是我们作为结果上报方案的最佳选择。
架构设计的基本任务就是回答:框架如何实现?因此框架设计又称为概要设计。经过前面需求分析和技术选型阶段的分析,我们已经得到了目标框架应该完成的功能和通过什么技术去完成。
对于一个优秀的框架,不可或缺的当属是分层思想,而在Web UI自动化测试中,PO模式即Page Object是十分流行的一项技术了。PO是一种设计模式,提供了一种页面元素定位和业务操作流程分离的模式。当页面元素发生变化时,只需要维护对应的page层修改定位,不需要修改业务逻辑代码。
PO核心思想是分层,实现脚本重复使用,易维护,可读性高,主要分三层:
对象库层:Base(基类),封装page 页面一些公共的方法,如初始化方法、查找元素方法、点击元素方法、输入方法、获取文本方法、截图方法等。
操作层:page(页面对象),封装对元素的操作,一个页面封装成一个对象
业务层:business(业务层),将一个或多个操作组合起来完成一个业务功能。比如登录:需要输入帐号、密码、点击登录三个操作。
基于分层思想和PO设计模式,我们可以设计出如下基本的框架模型:
公共层的主要任务就是实现通用功能的封装,比如封装一个公共的page类,用于被其他page继承,其需要对外提供的方法有:元素的定位、元素的操作(如鼠标点击、输入文本、鼠标悬浮等)、二次封装隐式等待、页面操作(如窗口切换)等。还可以建一个utils文件,专门在里面封装一些公共的工具类,比如日志捕获、读取配置文件、发送邮件、数据库操作、获取路径等。
basepage.py文件的代码示例如下:
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :basepage.py
@Auth : luoluo
@Description:
"""
import json
from time import sleep, strftime, localtime, time
from typing import Dict, List
import allure
import yaml
from selenium.webdriver import ActionChains
from selenium.webdriver.remote.webdriver import WebDriver
from common.utils import get_logger
class BasePage():
"""
公共page类,用于被其他page继承
"""
# 动态传参参数,定义一个字典,要替换的内容放在字典里
params = {}
# 定义一个弹框黑名单列表,后续处理
black_list = []
# 定义最大查找次数
max_num = 10
# 定义异常次数
error_num = 0
# 定义入口url
# base_url = ''
# 实例化logger
logger = get_logger()
def __init__(self, driver: WebDriver = None):
self.driver = driver
self.driver.maximize_window()
self.driver.implicitly_wait(8)
def setup_implicitly_wait(self, timeout):
"""
自定义隐式等待时间
:param timeout: 等待时间
:return:
"""
self.driver.implicitly_wait(timeout)
def switch_to_window(self, index):
"""
切换到指定窗口
:param index: 窗口索引
:return:
"""
handles = self.driver.window_handles
self.driver.switch_to.window(handles[index])
def find(self, by, locator):
"""
查找元素
:param by: 定位方式
:param locator: 定位表达式
:return:
"""
try:
element = self.driver.find_element(by, locator)
self.error_num = 0
return element
except Exception as e:
self.logger.error(f'未找到目标元素: {locator},{e}')
# 捕获到异常时进行截图,并保存到指定screenshots文件夹中,文件按当前时间命名
st = strftime('%Y-%m-%d_%H-%M-%S', localtime(time()))
file_name = '../reports/screenshots/' + st + '.png'
self.driver.get_screenshot_as_file(file_name)
# 异常截图添加到allure测试报告中
allure.attach.file(file_name, attachment_type=allure.attachment_type.PNG)
# 设置最大查找次数
if self.error_num > self.max_num:
self.error_num = 0
self.setup_implicitly_wait(10)
raise e
# 每次进except一次都执行+1操作
self.error_num += 1
# 黑名单中的弹框处理
for ele in self.black_list:
self.logger.info(f'查找黑名单元素')
# find_elements 会返回元素的列表[ele1,ele2,...],如果没有元素则返回一个空列表
eles = self.driver.find_elements(*ele)
print(f'查找到的黑名单元素有:{eles}')
if len(eles) > 0:
self.logger.info(f'处理黑名单元素{ele[0]}')
eles[0].click()
# self.driver.execute_script('arguments[0].click();', ele[0])
# ActionChains(self.driver).click(ele[0]).perform()
# 弹框异常都处理完了,再次查找元素
self.logger.info(f'find:by = {by}, locator = {locator}')
# return self.driver.find_element(by, locator)
return self.find(by, locator)
# 如果黑名单都处理完,还没有找到想要的元素,则抛出异常
raise e
def find_click(self, by, locator):
"""
查找元素并点击
:param by: 定位方式
:param locator: 定位表达式
:return:
"""
self.logger.info(f'find_click:by = {by}, locator = {locator}')
self.find(by, locator).click()
def find_send(self, by, locator, text):
"""
查找元素并输入
:param by: 定位方式
:param locator: 定位表达式
:param text: 输入文本
:return:
"""
self.logger.info(f'find_send:by = {by}, locator = {locator}, text = {text}')
self.find(by, locator).send_keys(text)
def find_js_click(self, by, locator):
"""
查找元素并通过js点击
:param by: 定位方式
:param locator: 定位表达式
:return:
"""
self.logger.info(f'find_js_click:by = {by}, locator = {locator}')
ele = self.find(by, locator)
self.driver.execute_script('arguments[0].click();', ele)
def find_hover(self, by, locator):
"""
查找元素并鼠标悬浮在上方
:param by: 定位方式
:param locator: 定位表达式
:return:
"""
self.logger.info(f'find_hover:by = {by}, locator = {locator}')
ele = self.find(by, locator)
ActionChains(self.driver).move_to_element(ele).perform()
sleep(3)
def parse_action(self, path, fun_name):
"""
解析行为
:param path:存放了测试步骤的yaml文件路径
:param fun_name:行为方法名
:return:
"""
with open(path, 'r', encoding='utf-8') as f:
function = yaml.safe_load(f)
steps: List[Dict] = function[fun_name]
raw = json.dumps(steps)
# 动态传参params处理
for key, value in self.params.items():
raw = raw.replace("${" + key + "}", value)
steps = json.loads(raw)
for step in steps:
if step['action'] == 'find':
self.find(step['by'], step['locator'])
elif step['action'] == 'find_click':
self.find_click(step['by'], step['locator'])
elif step['action'] == 'find_send':
self.find_send(step['by'], step['locator'], step['text'])
elif step['action'] == 'find_js_click':
self.find_js_click(step['by'], step['locator'])
elif step['action'] == 'find_hover':
self.find_hover(step['by'], step['locator'])
utils.py文件中关于日志捕获的代码示例如下:
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :utils.py
@Auth : luoluo
@Description:存放公共方法:如日志记录、发送邮件、数据库操作、获取路径等
"""
import logging
import logging.handlers
import datetime
# 【日志配置】
def get_logger():
"""捕获日志"""
logger = logging.getLogger('mylogger')
logger.setLevel(logging.DEBUG)
rf_handler = logging.handlers.TimedRotatingFileHandler('../logs/all.log', when='midnight', interval=1, backupCount=0,
atTime=datetime.time(0, 0, 0, 0))
rf_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
f_handler = logging.FileHandler('../logs/error.log')
f_handler.setLevel(logging.ERROR)
f_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(filename)s[:%(lineno)d] - %(message)s"))
logger.addHandler(rf_handler)
logger.addHandler(f_handler)
return logger
页面对象层,是以一个页面为一个对象,单独对每个页面的业务操作进行封装,是Page Object设计模式最核心的特点,其原理是将具体的测试用例和测试业务进行分离,提高了代码的可维护性。
pages页面对象层代码示例1: 首页 main_page.py 文件
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :main.py
@Auth : luoluo
@Description:
"""
from common.basepage import BasePage
from pages.ai_page import AiPage
from pages.article_analysis_page import ArticleAnalysisPage
from pages.column_analysis_page import ColumnAnalysisPage
from pages.column_atlas_page import ColumnAtlasPage
from pages.column_daily_page import ColumnDailyPage
from pages.column_gallery_page import ColumnGalleryPage
from pages.column_vedio_page import ColumnVedioPage
from pages.member_brandfollow_page import MemberBrandfollowPage
from pages.member_userinfo_page import MemberUserinfoPage
from pages.search_picture_page import SearchPicturePage
from time import sleep
class Main(BasePage):
"""首页"""
file_path = '../datas/main.yaml'
def login(self):
"""登录"""
self.parse_action(self.file_path, 'login')
def from_carousel_goto_article_page(self):
"""点击首页轮播图推荐位,跳转主题详情页"""
self.parse_action(self.file_path, 'from_carousel_goto_article_page')
return ArticleAnalysisPage(self.driver)
def from_data_analysis_goto_article_page(self):
"""点击首页数据分析推荐位,跳转主题详情页"""
self.parse_action(self.file_path, 'from_data_analysis_goto_article_page')
return ArticleAnalysisPage(self.driver)
def from_nav_goto_analysis_column_page(self):
"""点击导航中分析类栏目"""
self.parse_action(self.file_path, 'from_nav_goto_analysis_column_page')
return ColumnAnalysisPage(self.driver)
def from_nav_goto_atlas_column_page(self):
"""点击导航中图集类栏目"""
self.parse_action(self.file_path, 'from_nav_goto_atlas_column_page')
return ColumnAtlasPage(self.driver)
def from_nav_goto_gallery_column_page(self):
"""点击导航中图库类栏目"""
self.parse_action(self.file_path, 'from_nav_goto_gallery_column_page')
return ColumnGalleryPage(self.driver)
def from_nav_goto_daily_column_page(self):
"""点击导航中DailyTrends栏目"""
self.parse_action(self.file_path, 'from_nav_goto_daily_column_page')
return ColumnDailyPage(self.driver)
def from_nav_goto_vedio_column_page(self):
"""点击导航中视频栏目"""
self.parse_action(self.file_path, 'from_nav_goto_vedio_column_page')
return ColumnVedioPage(self.driver)
def from_main_goto_picture_search_page(self):
"""从首页点击【图片库】"""
self.parse_action(self.file_path, 'from_main_goto_picture_search_page')
self.switch_to_window(-1)
sleep(5)
return SearchPicturePage(self.driver)
def from_main_goto_ai_page(self):
"""从首页点击【AI搜图】"""
self.parse_action(self.file_path, 'from_main_goto_ai_page')
self.switch_to_window(-1)
return AiPage(self.driver)
def from_main_goto_memger_userinfo_page(self):
"""从首页点击头像跳转至会员中心-个人中心页"""
self.parse_action(self.file_path, 'from_main_goto_memger_userinfo_page')
self.switch_to_window(-1)
return MemberUserinfoPage(self.driver)
def from_main_goto_memger_brandfollow_page(self):
"""从首页点击关注品牌跳转至会员中心-关注品牌页"""
self.parse_action(self.file_path, 'from_main_goto_memger_brandfollow_page')
self.switch_to_window(-1)
return MemberBrandfollowPage(self.driver)
pages页面对象层代码示例2: 分析类文章详情页 article_analysis_page.py 文件
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :article_analysis_page.py
@Auth : luoluo
@Description:
"""
from common.basepage import BasePage
from pages.member_userinfo_page import MemberUserinfoPage
import os
class ArticleAnalysisPage(BasePage):
"""分析类文章详情页"""
file_path = '../datas/article_analysis.yaml'
def switch_page(self):
"""切换版面"""
self.parse_action(self.file_path, 'switch_page')
def from_analysis_article_goto_main(self):
"""从分析类文章详情页跳转至首页"""
self.parse_action(self.file_path, 'from_analysis_article_goto_main')
self.switch_to_window(-1)
from pages.main import Main
return Main(self.driver)
def from_analysis_article_goto_userinfo_page(self):
"""从分析类文章详情页跳转至个人中心页"""
self.parse_action(self.file_path, 'from_analysis_article_goto_userinfo_page')
self.switch_to_window(-1)
return MemberUserinfoPage(self.driver)
测试用例层的主要功能是调用各类page完成业务流程并进行断言,为了提高我们测试用例可维护性,我们采用PO设计模式,其优点在于:就算UI页面频繁被修改,我们无需去修改用例,而只需去修改对应的page即可。
由于我们是使用Pytest框架来组织我们的测试用例的,所以我们的测试用例层会有一个Pytest框架特有的文件:conftest.py,该文件主要是实现fixture共享的,其作用和特点如下:
我们将在conftest.py文件里面定义一些通用的fixture函数,比如初始化driver、登陆、窗口切换等,代码示例如下:
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :conftest.py
@Auth : luoluo
@Description:
"""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import pytest
# @pytest.fixture(scope='session', autouse=True)
# def init_driver(base_url):
# """
# selenium-grid分布式模式
# driver初始化和回收,作用于全局
# :param base_url: 入口url
# :return: driver
# 操作说明:
# 1、从官网把selenium的jar包下载下来:https://www.selenium.dev/downloads/
# 2、启动hub节点:java -jar selenium-server-standalone-3.141.59.jar -role hub
# 3、启动node节点
# 3.1、通过配置文件node.json来启动node节点
# 3.1.1、先从使用文档中把node配置的json代码复制到文件node.json中,修改为自己想要的浏览器配置
# 3.1.2、另起终端执行命令启动node节点:java -jar selenium-server-standalone-3.141.59.jar -role node -nodeConfig node_win.json
# 3.2、通过命令行参数启动node节点
# java -jar selenium-server-standalone-3.141.59.jar -role node -port 5555 -hub http://192.168.8.23:4444/grid/register
# -maxSession 10 -browser browserName=chrome,seleniumProtocol=WebDriver,maxInstances=10 ,platform=WINDOWS,version=92.0.4515.131
# 注意点:node节点一定要配置好python和webdriver的环境变量
# """
# hub_url = 'http://127.0.0.1:4444/wd/hub'
# capability = DesiredCapabilities.CHROME.copy()
# # capability = DesiredCapabilities.SAFARI.copy()
# driver = Remote(command_executor=hub_url, desired_capabilities=capability)
# driver.maximize_window()
# driver.implicitly_wait(8)
# base_url = 'https://www.trendtest.com/?gender_id=72105'
# driver.get(base_url)
# yield driver
# driver.quit()
@pytest.fixture(scope='session', autouse=True)
def init_driver(base_url):
"""
driver初始化和回收,作用于全局
:param base_url: 入口url
:return: driver
"""
# 用无头浏览器打开
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--no-sandbox')
driver = webdriver.Chrome(chrome_options=chrome_options)
# # 用界面浏览器打开
# # driver = webdriver.Safari()
# driver = webdriver.Chrome()
driver.maximize_window()
driver.implicitly_wait(8)
driver.get(base_url)
yield driver
driver.quit()
@pytest.fixture(scope='session')
def init_main(init_driver):
"""
初始化main,作用于全局
:param init_driver:
:return: main
"""
from pages.main import Main
main = Main(init_driver)
yield main
@pytest.fixture(scope='session', autouse=True)
def login(init_driver):
"""
初始化登录,作用于全局
:param init_driver:
:return: None
"""
from pages.main import Main
main = Main(init_driver)
main.login()
@pytest.fixture(scope='function')
def swhich_latest_win(init_driver):
"""测试方法后置条件:切换到当前最新窗口"""
yield
handles = init_driver.window_handles
init_driver.switch_to.window(handles[-1])
@pytest.fixture(scope='function')
def close_active_win(init_driver):
"""测试方法后置条件:关闭当前活动窗口"""
yield
init_driver.close()
@pytest.fixture(scope='function')
def swhich_close_swhich_win(init_driver):
"""测试方法后置条件:切换到最新窗口,关闭当前活动窗口,再切换到最新窗口"""
yield
handles1 = init_driver.window_handles
init_driver.switch_to.window(handles1[-1])
init_driver.close()
handles2 = init_driver.window_handles
init_driver.switch_to.window(handles2[-1])
@pytest.fixture(scope='function')
def close_swhich_win(init_driver):
"""测试方法后置条件:关闭当前活动窗口,再切换到当前最新窗口"""
yield
init_driver.close()
handles = init_driver.window_handles
init_driver.switch_to.window(handles[-1])
@pytest.fixture(scope='function')
def close_swhich_close_swhich_win(init_driver):
"""测试方法后置条件:关闭当前活动窗口,再切换到当前最新窗口,再关闭当前活动窗口,再切换到当前最新窗口"""
yield
init_driver.close()
handles1 = init_driver.window_handles
init_driver.switch_to.window(handles1[-1])
init_driver.close()
handles2 = init_driver.window_handles
init_driver.switch_to.window(handles2[-1])
@pytest.fixture(scope='function')
def swhich_close_swhich_close_win(init_driver):
"""测试方法后置条件:切换到最新窗口,关闭当前活动窗口,再切换到最新窗口"""
yield
handles1 = init_driver.window_handles
init_driver.switch_to.window(handles1[-1])
init_driver.close()
handles2 = init_driver.window_handles
init_driver.switch_to.window(handles2[-1])
init_driver.close()
cases测试用例层代码示例1:首页测试类test_main_page.py文件
(其中就使用了conftest.py中定义的fixture函数init_main,而初始化driver函数:init_driver和登陆函数:login 因为已设置为session级别,且是自动调用,因此无需再在测试用例中进行引用了)
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :test_main_page.py
@Auth : luoluo
@Description:
"""
import allure
import pytest
@allure.feature('首页测试类')
class TestMainPage:
@allure.story('测试点击推荐位')
@pytest.mark.smoke
def test_carousel_recommend(self, init_main):
init_main.from_carousel_goto_article_page()
@allure.story('测试点击数据分析')
def test_data_analysis(self, init_main, swhich_close_swhich_win):
init_main.from_data_analysis_goto_article_page()
@allure.story('测试点击导航分析类栏目')
def test_nav_analysis_column(self, init_main):
init_main.from_nav_goto_analysis_column_page()
@allure.story('测试点击导航图集类栏目')
def test_nav_atlas_column(self, init_main):
init_main.from_nav_goto_atlas_column_page()
@allure.story('测试点击导航图库类栏目')
def test_nav_gallery_column(self, init_main):
init_main.from_nav_goto_gallery_column_page()
@allure.story('测试点击导航DailyTrends栏目')
def test_nav_daily_column(self, init_main):
init_main.from_nav_goto_daily_column_page()
@allure.story('测试点击【会员头像】')
def test_click_me(self, init_main, swhich_close_swhich_win):
init_main.from_main_goto_memger_userinfo_page()
@allure.story('测试点击【品牌关注】')
def test_click_brandfollow(self, init_main, swhich_close_swhich_win):
init_main.from_main_goto_memger_brandfollow_page()
@allure.story('测试点击【图片库】')
def test_click_picutre_search(self, init_main, swhich_close_swhich_win):
init_main.from_main_goto_picture_search_page()
if __name__ == '__main__':
pytest.main()
cases测试用例层代码示例2:分析类主题详情页测试类est_article_analysis_page.py文件
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :test_article_analysis_page.py
@Auth : luoluo
@Description:
"""
import allure
import pytest
@allure.feature('分析类文章详情页测试类')
class TestAnalysisArticlePage:
@pytest.fixture(scope='class')
def init_article(self, init_main):
"""
测试类前置条件:进入分析类文章详情页
测试类后置条件:点击浏览器返回按钮返回首页
"""
article = init_main.from_nav_goto_analysis_column_page().from_list_goto_analysis_article_page()
yield article
article.driver.back()
article.driver.back()
@pytest.fixture(scope='function')
def back_article_win(self, init_main):
"""测试方法后置条件:点击浏览器返回按钮返回文章详情页"""
yield
init_main.driver.back()
@allure.story('测试在分析类文章详情页点击切换版面')
def test_switch_page(self, init_article):
init_article.switch_page()
@allure.story('测试从分析类文章详情页跳转至首页')
def test_from_analysis_article_goto_main(self, init_article, back_article_win):
init_article.from_analysis_article_goto_main()
@allure.story('测试从分析类文章详情页跳转至个人中心页')
def test_from_analysis_article_goto_userinfo_page(self, init_article, close_swhich_close_swhich_win):
init_article.from_analysis_article_goto_userinfo_page()
if __name__ == '__main__':
pytest.main()
测试数据层,是将测试用例,与测试数据进行分离,通过yaml文件的方式对数据进行管理,实现测试用例的数据驱动和操作步骤的数据驱动。
datas测试数据层代码示例1:首页测试类 所对应的测试数据main.yaml 文件
################### 【登陆】 ########################################################################
login:
# 关闭今日热点弹窗
- action: find_js_click
by: xpath
locator: //*[@class="ant-modal-close-x"]
# 点击我的
- action: find_js_click
by: xpath
locator: //*[@class="loginCom_3Gl0e"]/span
# 输入用户名
- action: find_send
by: xpath
locator: //*[@name="username"]
text: 13800000000
# 输入密码
- action: find_send
by: xpath
locator: //*[@name="password"]
text: 123456
# 点击登录按钮
- action: find_js_click
by: xpath
locator: //*[@class="inputStyle_1xJNL sumbitBtn_2QOT4"]
# 验证登录成功
- action: find
by: xpath
locator: //*[@class="smallAvatar_2Et7V"]
################### 【首页推荐位】 ########################################################################
## 从首页轮播图推荐位跳转至主题详情页
from_carousel_goto_article_page:
- action: find_js_click
by: xpath
# locator: //*[@class="swiper-container swiper-container-initialized swiper-container-horizontaz"]
locator: //*[@class="index-swiper "]
## 从首页数据分析推荐位跳转至主题详情页
from_data_analysis_goto_article_page:
- action: find_js_click
by: xpath
locator: //*[@id="__next"]/div/div/div[2]/div/div[1]/div[2]/div/div/a[3]/div
################### 【首页导航】 ########################################################################
## 从首页导航点击分析类栏目跳转至分析类栏目页
from_nav_goto_analysis_column_page:
- action: find_hover
by: xpath
locator: //*[@id="li-9"] # 一级导航:T台
- action: find_js_click
by: xpath
locator: //div[@id="item11"]/div/div/div/a/p # 二级导航:T台分析
## 从首页导航点击图集类栏目跳转至分析类栏目页
from_nav_goto_atlas_column_page:
- action: find_hover
by: xpath
locator: //*[@id="li-9"] # 一级导航:T台
- action: find_js_click
by: xpath
locator: //div[@id="item10"]/div/div/div/a/p # 二级导航:时装发布会
## 从首页导航点击图库类栏目跳转至图库类栏目页
from_nav_goto_gallery_column_page:
- action: find_hover
by: xpath
locator: //*[@id="li-19"] # 一级导航:成衣
- action: find_js_click
by: xpath
locator: //div[@id="item131"]/div/div/div/a/p # 二级导航:单品图库
## 从首页导航点击DailyTrends栏目跳转至DailyTrends栏目页
from_nav_goto_daily_column_page:
- action: find_hover
by: xpath
locator: //*[@id="li-195"] # 一级导航:DailyTrends
- action: find_js_click
by: xpath
locator: //div[@id="item196"]/div/div/div/a/p # 二级导航:DailyTrends
## 从首页导航点击视频栏目跳转至视频栏目页
from_nav_goto_vedio_column_page:
- action: find_hover
by: xpath
locator: //*[@id="li-13"] # 一级导航:视频
- action: find_js_click
by: xpath
locator: //div[@id="item184"]/div/div/div/a/p # 二级导航:全部视频
## 从首页导航点击【图片库】跳转至综合搜索-图片库页
from_main_goto_picture_search_page:
- action: find_js_click
by: xpath
locator: //*[@id="home-guide-2"]
## 从首页导航点击【AI搜图】跳转至综合搜索-AI搜图页
from_main_goto_ai_page:
- action: find_js_click
by: xpath
locator:
## 从首页导航点击会员头像跳转至会员中心-个人中心页
from_main_goto_memger_userinfo_page:
- action: find_hover
by: xpath
locator: //*[@class="loginCom_3Gl0e"]
- action: find_js_click
by: xpath
locator: //*[@class="iconfont iconwode"]
## 从首页导航点击关注品牌跳转至会员中心-关注品牌页
from_main_goto_memger_brandfollow_page:
- action: find_hover
by: xpath
locator: //*[@class="loginCom_3Gl0e"]
- action: find_js_click
by: xpath
locator: //*[@class="iconfont iconshipin_xihuan"]
datas测试数据层代码示例2:分析类文章详情页测试类 所对应的测试数据article_analysis.yaml文件
#################################### 分析类文章详情页 ##########################################
# 在分析类文章详情页点击切换版面
switch_page:
- action: find_js_click
by: xpath
locator: //*[@id="swiper-arrow-next"]
- action: find_click
by: xpath
locator: //*[@id="swiper-arrow-prev"]
# 从分析类文章详情页跳转至首页
from_analysis_article_goto_main:
- action: find_js_click
by: xpath
locator: //*[@class="logo_IJxdO"]
# 从分析类文章详情页跳转至个人中心页
from_analysis_article_goto_userinfo_page:
- action: find_hover
by: xpath
locator: //*[@id="aside-person"]
- action: find_js_click
by: xpath
locator: //*[@id="main-container"]/div[5]/aside/div/div[3]/a[1]/div[1]/span
完成了以上公共方法封装、页面对象建模、测试用例编写、测试数据准备的基础建设后,接下来就可以执行我们的测试了。
Pytest的测试用例执行主要是通过命令行的方式,前期写脚本调试的时候可以在Windows系统上执行,但我们最终是需要在测试服务器(Linux系统)上运行的。而为了实现全自动化,我们除了将测试执行的Pytest相关命令写在了run.py文件里面,同时还将测试报告的生成(通过allure插件采集和产出)、读取(无头浏览器打开检测是否有用例失败)、发送(当有失败用例时通过钉钉机器人通知)都写在了run.py文件中。
run_all_cases.py文件详细代码示例如下:
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
@File :run_all_cases.py
@Auth : luoluo
@Description:
"""
import os
import time
from dingtalkchatbot.chatbot import DingtalkChatbot
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
def run_all_cases(base_url):
# 【命令行执行:杀掉因异常导致未正常关闭的 chromedriver 进程】
print('【杀掉因异常导致未正常关闭的 chromedriver 进程】\n======================================')
os.system("ps -ef | grep chrome | awk '{print $2}' | xargs kill -9")
# 【命令行执行:pytest执行测试用例】
print('【pytest执行测试用例】\n=====================================')
os.system(f'pytest --base-url={base_url} ../cases/')
# 【命令行执行:将测试结果转成allure测试报告】
print('【将测试结果转成allure测试报告】\n======================================')
os.system(
'/home/luoluo/allure-2.8.0/bin/allure generate ../reports/allure_results -o ../reports/allure_report --clean')
# 【命令行执行:查找allure服务进程(即Java进程)并杀掉进程】
print('【关闭已开启的allure服务】\n======================================')
os.system("netstat -tunlp | grep java | awk '{print $7}' | awk -F \/ '{print $1}' | xargs kill -9")
# 【命令行执行:重新启动allure服务】
print('【重新启动allure服务】\n======================================')
os.system("nohup /home/luoluo/allure-2.8.0/bin/allure serve ../reports/allure_results &")
time.sleep(30)
# 查找allure服务端口命令
port_command = "netstat -tunlp | grep java | awk '{print $4}' | awk -F \: '{print $4}'"
with os.popen(port_command, 'r') as p:
port = p.read().strip()
print(f'【重新启动的allure服务端口是{port}】\n=====================================')
# 【无头浏览器打开allure测试报告】
print('【无头浏览器打开allure测试报告】\n======================================')
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--no-sandbox')
driver = webdriver.Chrome(chrome_options=chrome_options)
allure_report_url = f'http://192.168.1.15:{port}/index.html#behaviors'
print(f'【正在打开allure测试报告地址:{allure_report_url},检查是否有用例失败】\n=====================================')
driver.get(allure_report_url)
driver.maximize_window()
driver.implicitly_wait(5)
failed_no = driver.find_element_by_xpath('//*[@class="y-label y-label_status_failed"]').text
broken_no = driver.find_element_by_xpath('//*[@class="y-label y-label_status_broken"]').text
# 【检查allure测试报告是否有用例失败,有则发送钉钉机器人】
print(f'失败用例数量有: {failed_no} 个,出错用例数量有: {broken_no} 个')
if failed_no != '0' or broken_no != '0':
# 发送通知到钉钉机器人
print('报告已生成,但有异常用例,开始发送通知到钉钉机器人...')
webhook = 'https://oapi.dingtalk.com/robot/send?' \
'access_token=a26d714ae9d7fb89c3017b640a6a7293147adf443377dba18478daaexxxxxxxx'
xiaoding = DingtalkChatbot(webhook)
xiaoding.send_text(
msg=f"《{base_url} UI回归测试报告》已生成\n"
f"有 {failed_no} 个用例失败,有 {broken_no} 个用例出错!\n"
f"请用浏览器打开查看详情\n"
f"测试报告链接是:{allure_report_url}")
else:
print(f'《{base_url} UI回归测试报告》已生成,无异常用例,很棒!')
# 【关闭浏览器】
print('【关闭浏览器】\n=====================================')
driver.quit()
if __name__ == '__main__':
base_url = 'https://www.trendtest.com/?gender_id=72105'
run_all_cases(base_url)
测试日志层主要是存放在测试执行过程中捕获的日志信息,包含了通用日志和错误日志,便于运行出错时可以通过查看日志信息来定位问题。其文件内容如下截图:
测试报告层主要是存放在测试执行过程中采集的测试结果数据,以及失败截图,我们还需要通过allure命令来生成最终的测试报告文件,或者进行在线查看等。其目录下文件内容如下截图:
我们还注意到该框架的根目录下有个命名为:pytest.ini的文件,它是Pytest配置文件,可以读取配置信息,改变Pytest的默认行为。要特别注意的是pytest.ini文件必须是放在项目的根目录下,且命名不能随意改变,就是固定的命名pytest.ini。
pytest.ini文件常用的参数使用分类有:
更改命令行参数addopts
自定义标记marks
自定义用例搜索规则
日志配置参数log_cli
插件配置参数
下面列举几个常用的案例:
当前项目的所有依赖包及其精确版本号清单都会记录在此文件中,当需要在新环境部署时就可以直接拿来用了。
所以我们一般在完成详细设计后,即可生成requirements.txt 文件,生成命令是:pip freeze > requirements.txt,该文件就会存放在我们的项目根目录下。
当项目需要迁移,或者新成员需要拷贝你的项目时,首先就需要安装好项目所依赖的包,此时直接运行命令:pip install -r requirements.txt即可完成。
其文件内容如下:
requirements.txt的详细使用见下面环境部署章节。
本系统我们是在Windows系统上进行脚本的编写和调试,但最终的执行环境是在Linux系统的测试服务器上。为了方便项目的迁移和提高环境部署的效率,我们在Windows系统上进行脚本的编写和调试时使用的是Python虚拟环境,与Python全局环境独立开来,我们只需要在这个虚拟环境中安装当前项目的所有依赖包,统一通过requirements.txt 文件来进行管理。
同样的,既然项目需要从Windows系统迁移到Linux系统,我们也需要对项目的代码进行管理,代码的提交、拉取等同步工具我们选取了国内流行的gitee。
下面是环境部署的具体操作步骤:
前提条件:我们的Linux测试服务器上已经安装了jdk1.8、python3.8、allure、谷歌浏览器驱动chromedriver、Git工具,并配置好了环境变量。
通过以上环节,我们已经成功的将系统在Linux测试服务器上运行起来了,但我们注意到的一个问题是:我们仍需人工进入到Linux测试服务器上去操作。随着项目的频繁迭代,回归或冒烟测试的次数也会越来越频繁,人工构建时效率慢的缺点也暴露无遗。
为了能够快速的构建自动化测试,我们还需要将自动化测试与Jenkins工具结合起来。那如何将Jenkins与我们的自动化测试项目结合起来呢?
前提条件:我们已经安装部署好了Jenkins
操作步骤:
至此,我们即完成了自动化测试系统与Jenkins集成,从而实现了自动构建。
经过系统的架构设计、详细设计、部署测试后,系统本身会遇到很多问题,是需要不断的调试和优化的,接下来我们将列举几个遇到的问题和对应的调优方案。
【问题描述】:运行自动化测试脚本总是出现不稳定的情况,这次用例正常通过,下次用例又出现失败等。
【解决方案】:
第一,从基本的工具使用层面考虑:
第二,从框架设计层面考虑:
【问题描述】:我们做自动化测试的初衷是希望可以通过自动化脚本来替代人工去做重复的费时的回归测试,或者在发版时可以快速的执行完冒烟测试,但是也遇到了自动化测试执行时间过长的问题。
【解决方案】:增加按文件名进行分布式执行机制(并发)。
要想实现并发,我们就得引入多进程,目前pytest框架中有个pytest-xdist插件是支持进程级别的并发的。
具体操作步骤是:安装pytest-xdist插件,在pytest.ini文件的addopts中传命令行参数:-n auto --dist=loadfile。其中-n后面的参数表示cpu的数量,也可以指定为auto,它会自动检测cpu核数并发运行用例。
非常值得注意的是:分布式执行用例的三个设计原则
【问题描述】:我们希望可以实现多环境自由切换测试(测试环境、预发布环境、正式环境等)
【解决方案】:增加环境base_url自由切换机制。
我们只需要在执行测试时,在pytest的命令行传入对应环境的base_url即可。具体操作步骤如下:
本系统着眼于当前软件研发流程中自动化软件测试环节,详细剖析了Web UI自动化测试系统的开发、调试、部署、运行、调优过程,逐步的实现和完善了该系统所需要满足的功能需求和稳定性需求,并在实际项目中经过了严格的实践应用。系统技术栈:Python+Selenium+Pytest+Allure+Gitee+Jenkins 都是当下最为主流的,同时该系统因其具有复用性、容错性、可移植性等优点,也得到了广泛的认同,本系统也将逐步改进优化,相信它会得到很好的应用。