从零搭建完整python自动化测试框架(UI自动化和接口自动化 )——持续更新

一、总体框架

总体框架如下图:

用例扫描、测试结果反馈,如要和其它项目管理系统或是用例管理系统对接(比如testlink),就需要单独出来进行处理。

对于大型的产品,用例数特别多的话,需要建设一个master节点,专门负责管理用例和脚本,分发测试脚本,指定测试环境,汇总测试结果等。各节点执行分给自己的测试用例即可。

二、PO模式、DDT数据驱动、关键字驱动
 

PO模式(Page Object)是UI自动化测试常采用的一种设计模式,用于解决开发频繁修改UI页面而导致的自动化脚本维护困难的问题。

PO模式中心思想:

  • 每一个页面为一个对象;
  • 每一个对象维护着页面中的各元素和操作方法;
  • 用例测试脚本只需要聚集业务逻辑和测试数据;
  • UI页面的变更,只需要修改对应的PO对象,无需修改测试脚本(理想情况下。实际上也很难100%做到,因为UI的变更很多时候意味着业务逻辑的变更)。

DT(Data Driven Testing)数据驱动测试模式,用来解决部分自动化用例逻辑完全相同,只有测试数据和预期结果不同的问题。实际上就是同一测试脚本使用不同的测试数据来反复执行(但脚本只需要写一个),测试数据和测试行为完全分离。

DDT中心思想:

  • 将测试数据分离出来,单独维护;
  • 减少重复自动化用例的数量。

将以上两种思想进行结合,就可以做成 对象、数据、业务行为 三者分离的模型,再结合模块进行管理,为后续自动化用例脚本的长期维护打下基础。否则时间一长自动化就会乱成一团,维护成本越来越高,陷入自动化率不升反降的怪圈。

关键字驱动(Keyword Driven Testing),在前面的基础上,可以进一步实现关键字驱动。即将业务逻辑相同的部分,抽象成关键字库。这样在写自动化用例脚本时,只需要写关键字和对应测试数据即可,可以进一步减少工作量,减少测试人员对代码的学习和依赖。

如京东搜索商品时直接写脚本需要好多步:

  1. 定位到搜索框
  2. 输入关键字
  3. 定位到搜索按钮
  4. 点击搜索按钮
  5. 定位结果列表
  6. 获取结果并返回

 以关键字驱动的思想,即将这6步抽象出一个方法jd_search(),测试人员只需要写一句话就能完成以上所有动作获得结果。如:
 

result = jd_search('电脑')

方便、省时省力,测试人员可聚焦于产品业务,而不是自动化脚本和语言学习。

甚至可以直接在设计测试用例的时候写关键字,由自动化平台去解析用例,都不需要写脚本。这方面最有名的自动化框架就是RobotFrameWork。但是RobotFrameWork过于笨重。建议大家适当抽象即可,不要过度抽象。

三、框架技术选择
 

大多数框架采用java语言或是python语言来实现,考虑到python容易掌握,各种库也比较全,所以采用python语言来实现。

python自动化框架最常用的有unittest和pytest,两者都可以,这里采用python自带的unittest。

对于WEB UI自动化测试,没有别的选择,基本都是采用selenium来驱动浏览器来完成。

对于接口自动化测试,可采用的办法较多,postman、jmeter都可以,但灵活性都不如直接采用python的request库。

数据驱动,由于unittest没有直接可用的dataprovider,采用常见的ddt来实现。

对于手机自动化,暂未实现,后续考虑加入,可采用appnium来实现。

测试数据,第1阶段采用excel管理,对于大型系统,建议直接采用数据库进行管理。

所以总的来讲,这个所谓的框架,就是东拼本凑,即没有新思想,也没有新技术,只是将一些常用的技术,按一定的思路组织起来、驱动起来而已。

四、框架运行结果

总共执行6个用例,4个为京东搜索并抓取结果(WEB UI自动化测试),2个为百度翻译通用接口(接口自动化测试)。

五、各用例对应的定义方式(PO/DDT)

页面定义方式

PO对象定义:京东主页面定义了搜索框和搜索按钮,以name为关键字,定义元素定位方式和执行的动作。

测试数据定义方式

六、API接口定义方式

直接采用大家接口测试时熟悉的json格式来定义。

 
  1. # 接口地址信息

  2. uri_scheme = 'http'

  3. endpoint = 'api.fanyi.baidu.com'

  4. resource_path = '/api/trans/vip/translate'

  5. url = uri_scheme + u'://' + endpoint + resource_path

  6. # 保持不变的参数

  7. _from = 'en'

  8. _to = 'zh'

  9. # 请求消息参数模板

  10. req_param = {

  11. "q": "", # 请求翻译 query, UTF-8

  12. "from": _from, # 翻译源语言

  13. "to": _to, # 翻译目标语言

  14. "appid": "", # APP ID

  15. "salt": "", # 随机数

  16. "sign": "", # 签名,app_id+q+salt+密钥 的MD5值

  17. }

  18. # 响应消息参数模板

  19. res_param = {

  20. "from": _from,

  21. "to": _to,

  22. "trans_result": [

  23. {

  24. "src": "Hello World! This is 1st paragraph.",

  25. "dst": "你好,世界!这是第一段。"

  26. },

  27. {

  28. "src": "This is 2nd paragraph.",

  29. "dst": "这是第二段。"

  30. }

  31. ]

  32. }

对应的请求消息头headers等内容也可以定义在这里面。

主程序main.py

负责扫描用例,执行用例,并生成测试报告,发送邮件。

七、测试执行结果

3个脚本,每个脚本2条测试数据,共6个用例。运行main.py,执行测试,测试结果如下,3个失败的是故意修改了测试数据。

 红线部分为接口测试时,自动比对的json差异,预期结果为“苹果”,实际结果为“期望值”。

测试报告邮件:

 测试报告详情: 

从零开始搭建项目
一、开发环境搭建


开发IDE: pycharm 安装指导

python: python 3 安装指导

依赖库:anaconda 3(个人比较懒,懒得一个一个库的安装,这个库比较全) 安装指导

pycharm、python、anaconda三者的关系:添加链接描述

基本上都是直接上对应官网,下载安装。准备好了以后,直接开干。

开发IDE: pycharm 安装指导

python: python 3 安装指导

依赖库:anaconda 3(个人比较懒,懒得一个一个库的安装,这个库比较全) 安装指导

pycharm、python、anaconda三者的关系:添加链接描述

基本上都是直接上对应官网,下载安装。准备好了以后,直接开干。

二、新建项目

pycharm上新建项目TestFrame,选择好存放目录,并在TestFrame项目下新建各模块。注意除了Log和Report是新建Directory外,其它的都是新建Python Package,因为下面还要放py文件的。

pycharm上切换项目的python环境为anaconda,File—>Settings—>Project下面切换,如下图:

三、基础功能实现
1. 配置功能实现(Conf)

配置功能是项目的基础,所以先实现。在Conf目录下新建2个文件,分别为config.ini和config.py。

config.ini内容如下:

 
  1. [sys]

  2. base_url = https://www.jd.com

  3. [smtp]

  4. host = smtp.163.com

  5. port = 465

  6. user = example@163.com

  7. passwd = password

暂时先加这么多,后续需要再慢慢添加。

config.py文件实现config.ini文件的读取。

ini文件读取,python有ConfigParser库可以使用,那就直接用。

ConfigParser库传送门

但是每次取值都要用他的方法,比较麻烦,因此对它的方法进行了一个继承和改写,直接将配置文件中所有内容读出来字典形式,方便后续使用。

代码如下:
 

 
  1. import os

  2. from configparser import ConfigParser

  3. # 使用相对目录确定文件位置

  4. _conf_dir = os.path.dirname(__file__)

  5. _conf_file = os.path.join(_conf_dir, 'config.ini')

  6. # 继承ConfigParser,写一个将结果转为dict的方法

  7. class MyParser(ConfigParser):

  8. def as_dict(self):

  9. d = dict(self._sections)

  10. for k in d:

  11. d[k] = dict(d[k])

  12. return d

  13. # 读取所有配置,以字典方式输出结果

  14. def _get_all_conf():

  15. _config = MyParser()

  16. result = {}

  17. if os.path.isfile(_conf_file):

  18. try:

  19. _config.read(_conf_file, encoding='UTF-8')

  20. result = _config.as_dict()

  21. except OSError:

  22. raise ValueError("Read config file failed: %s" % OSError)

  23. return result

  24. # 将各配置读取出来,放在变量中,后续其它文件直接引用这个这些变量

  25. config = _get_all_conf()

  26. sys_cfg = config['sys']

  27. smtp_cfg = config['smtp']

  28. print(sys_cfg)

  29. print(smtp_cfg)

  30. print(smtp_cfg['host'])

  31. 运行结果:

  32. {'base_url': 'https://www.jd.com'}

  33. {'host': 'smtp.163.com', 'port': '465', 'user': 'example@163.com', 'passwd': 'password'}

  34. smtp.163.com

后续其它文件就可以直接使用 sys_cfg 和 smtp_cfg 这两个字典,以key的方式访问需要的配置内容。

2. 日志功能实现(Log)


日志在项目中也是基础功能,所以接着做日志。
python自带logging库,可以定制日志的格式,就直接使用该库实现,没必要自己造。

先去我们的配置文件中config.ini添加日志相关的配置,这里先定义3个配置:日志级别、日志格式、日志路径。

 
  1. [log]

  2. log_level = logging.DEBUG

  3. log_format = %(asctime)s - %(name)s - %(filename)s[line:%(lineno)d] - %(levelname)s - %(message)s

  4. log_path = Log

再在config.py中最后面添加一行代码,把log相关的配置放在一个变量中,好直接使用。

 
  1. log_cfg = config['log']

  2. print(smtp_cfg)

打印出来看一下结果:

{'log_level': 'logging.DEBUG', 'log_format': '%(asctime)s - %(name)s - %(filename)s[line:%(lineno)d] - %(levelname)s - %(message)s', 'log_path': 'Log'}

日志级别有:DEBUG、INFO、WARN、ERROR、FATAL。一般调试都是DEBUG,上线就改为INFO。

这里简单介绍一下日志格式log_format的内容:

再接着在Comm目录下,新建一个Log.py,开始定制日志。定制日志还有几个问题要提前考虑:

一是存放目录问题,我们这里使用了固定目录,所以问题不大。

二是日志分割、滚动问题,每天跑持续集成,大量用例生成大量日志,日志堆成山。如果觉得日志有用呢,就搞个ELK把日志取走存放起来做分析。如果觉得日志没用呢,保存几天后就删除掉。无论怎么讲,都要实现日志的分割和滚动。

幸好你想到的大佬们早就想到了,logging模块就有这个功能,只要配置一下就可以了。

下面开搞,引入logging库,把项目的根路径取出来,把上面config.ini中的日志配置取过来,最后拼接好日志文件存放的绝对路径:

 
  1. import os

  2. import logging

  3. from Conf.Config import log_cfg

  4. _BaseHome = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))

  5. _log_level = eval(log_cfg['log_level'])

  6. _log_path = log_cfg['log_path']

  7. _log_format = log_cfg['log_format']

  8. _log_file = os.path.join(_BaseHome, _log_path, 'log.txt')

注意上面log_level的写法,这里用了个eval,如果不加这个函数,log_level取过来是个字符串,没法直接用,通过eval执行后,就变成了logging定义的对象了。

再配置日志,引入TimedRotatingFileHandler这个东东,这是实现滚动日志的。

 
  1. from logging.handlers import TimedRotatingFileHandler

  2. def log_init():

  3. logger = logging.getLogger('main')

  4. logger.setLevel(level=_log_level)

  5. formatter = logging.Formatter(_log_format)

  6. handler = TimedRotatingFileHandler(filename=_log_file, when="D", interval=1, backupCount=7)

  7. handler.setLevel(_log_level)

  8. handler.setFormatter(formatter)

  9. logger.addHandler(handler)

  10. console = logging.StreamHandler()

  11. console.setLevel(_log_level)

  12. console.setFormatter(formatter)

  13. logger.addHandler(console)

这个日志里面,加了两个输出,handler用于向日志文件打印日志,console 用于向终端打印日志,两个的定义方式不同。

TimedRotatingFileHandler的参数简介:

我这里配置的是每天生成1个日志文件,保留7天的日志。

日志就做好了,试一下效果。

 
  1. log_init()

  2. logger = logging.getLogger('main')

  3. logger.info('log test----------')

 运行结果:

2021-03-15 21:53:41,972 - main - Log.py[line:49] - INFO - log test----------

其它文件使用日志:

先在main.py里面引入这个log_init(),在最开始的时候初始化一下,日志就配置好了。

再在各个要使用日志的文件中,直接按下面这种方式使用:

 
  1. import logging

  2. logger = logging.getLogger('main.jd')

注意各个模块自己getLogger的时候,直接main后面加上“.模块名”,就能使用同一个logger区分模块了。

到这里日志功能就完成了。

顺手做个截图的功能,供大家使用。截图可以直接在用例里面用selenium提供的截图功能,也可以自己做一个公共的。下面是用PIL里面的功能做的截图。

 
  1. from PIL import ImageGrab

  2. # 先定义截图文件的存放路径,这里在Log目录下建个Screen目录,按天存放截图

  3. _today = time.strftime("%Y%m%d")

  4. _screen_path = os.path.join(_BaseHome, _log_path, 'Screen', _today)

  5. #再使用PIL的ImageGrab实现截图

  6. def screen(name):

  7. t = time.time()

  8. png = ImageGrab.grab()

  9. if not os.path.exists(_screen_path):

  10. os.makedirs(_screen_path)

  11. image_name = os.path.join(_screen_path, name)

  12. png.save('%s_%s.png' % (image_name, str(round(t * 1000)))) # 文件名后面加了个时间戳,避免重名

运行这个方法就能截图了,大功告成。截图文件其实也需要一个滚动删除,后面有时间再写吧。

3. 读取EXCEL实现(data)

接着写一个读取EXCEL文件数据的功能吧,这个项目里面主要是用来读测试数据,以实现数据驱动。

python读取excel数据,我看大家都喜欢用xlrd和xlwt,还有用openpyxl的,对于我这种懒人来讲,都太麻烦了。

我们用pandas来干,一句话的事情,搞那么多干吗,用python就是要快。

在Comm目录下,新建一个data.py,专门来处理数据。引入pandas,直接用pandas的read_excel读excel,而且支持它原始的其它参数,只是最后将结果转了字典,方便使用:

 
  1. import pandas as pd

  2. def read_excel(file, **kwargs):

  3. data_dict = []

  4. try:

  5. data = pd.read_excel(file, **kwargs)

  6. data_dict = data.to_dict('records')

  7. finally:

  8. return data_dict

  9. 随便放一个excel在同一个目录下,填上数据,试一下效果。excel里面2页数据,Shee

随便放一个excel在同一个目录下,填上数据,试一下效果。excel里面2页数据,Sheet1如下:

 Sheet2如下:

 调用我们写好的方法,打印数据:

 
  1. sheet1 = read_excel('baidu_fanyi.xlsx')

  2. sheet2 = read_excel('baidu_fanyi.xlsx', sheet_name='Sheet2')

  3. print(sheet1)

  4. print(sheet2)

 运行结果如下:

 
  1. [{'req.q': '计算机\n计算机', 'req.from': 'zh', 'req.to': 'en', 'res.from': 'zh', 'res.to': 'en', 'res.trans_result.0.src': '计算机', 'res.trans_result.0.dst': 'computer', 'res.trans_result.1.src': '计算机', 'res.trans_result.1.dst': 'computer'},

  2. {'req.q': 'computer\nexpected value', 'req.from': 'en', 'req.to': 'zh', 'res.from': 'en', 'res.to': 'zh', 'res.trans_result.0.src': 'computer', 'res.trans_result.0.dst': '计算机', 'res.trans_result.1.src': 'expected value', 'res.trans_result.1.dst': '苹果'}]

  3. [{'req.q': '计算机', 'req.from': 'zh', 'req.to': 'en', 'res.from': 'zh', 'res.to': 'en'},

  4. {'req.q': 'computer', 'req.from': 'en', 'req.to': 'zh', 'res.from': 'en', 'res.to': 'zh'}]

每页数据都读出来了,而且每一行都是字典形式,直接通过key就可以方便的使用。

pandas还能直接计算数据,如通过几个列算加密签名,写动态cookie等,使用方法也很简单。比如在数据中增加一列sign, 让它简单等于 req.from列 + ‘.aaaa.’ + req.to列,给大家演示一下。

 
  1. data = pd.read_excel('baidu_fanyi.xlsx')

  2. data['sign'] = data["req.from"] +'.aaaaa.' + data["req.to"]

  3. data_dict = data.to_dict('records')

  4. print(data_dict)

运行结果:

 
  1. [{'req.q': '计算机\n计算机', 'req.from': 'zh', 'req.to': 'en', 'res.from': 'zh', 'res.to': 'en', 'res.trans_result.0.src': '计算机', 'res.trans_result.0.dst': 'computer', 'res.trans_result.1.src': '计算机', 'res.trans_result.1.dst': 'computer', 'sign': 'zh.aaaaa.en'},

  2. {'req.q': 'computer\nexpected value', 'req.from': 'en', 'req.to': 'zh', 'res.from': 'en', 'res.to': 'zh', 'res.trans_result.0.src': 'computer', 'res.trans_result.0.dst': '计算机', 'res.trans_result.1.src': 'expected value', 'res.trans_result.1.dst': '苹果', 'sign': 'en.aaaaa.zh'}]

我们可以看到多了一列sign,值就是自动根据每一行的数据算出来的,这对于我们数据驱动来讲,去计算一些动态值非常有用。我这里没有用到动态的,只是读而已。大家如果要计算,就要自己写计算方法。

pandas还支持直接读各种主流数据库,后面扩展也很方便,我们一直都用它。

4. 邮件发送实现(Email)

实现邮件功能,用于发送测试报告。使用python的smtplib模块实现。

先在Conf目录下的config.ini中添加好邮件相关的配置:

 
  1. [smtp]

  2. host = smtp.163.com

  3. port = 465

  4. user = example@163.com

  5. passwd = password

  6. [email]

  7. sender = example@163.com

  8. receivers = example@qq.com, example@163.com

再在Config.py中将它们取到变量中放好:

 
  1. smtp_cfg = config['smtp']

  2. email_cfg = config['email']

然后在Comm目录下新建Email.py,开始撸代码。邮件支持了定义主题、正文和多个附件,控制了单个附件大小和附件总数。代码如下:

 
  1. import smtplib

  2. import os

  3. import logging

  4. from email.mime.text import MIMEText

  5. from email.mime.application import MIMEApplication

  6. from email.mime.multipart import MIMEMultipart

  7. from email.header import Header

  8. from Conf.Config import smtp_cfg, email_cfg

  9. _FILESIZE = 20 # 单位M, 单个附件大小

  10. _FILECOUNT = 10 # 附件个数

  11. _smtp_cfg = smtp_cfg

  12. _email_cfg = email_cfg

  13. _logger = logging.getLogger('main.email')

  14. class Email:

  15. def __init__(self, subject, context=None, attachment=None):

  16. self.subject = subject

  17. self.context = context

  18. self.attachment = attachment

  19. self.message = MIMEMultipart()

  20. self._message_init()

  21. def _message_init(self):

  22. if self.subject:

  23. self.message['subject'] = Header(self.subject, 'utf-8') # 邮件标题

  24. else:

  25. raise ValueError("Invalid subject")

  26. self.message['from'] = _email_cfg['sender'] # from

  27. self.message['to'] = _email_cfg['receivers'] # to

  28. if self.context:

  29. self.message.attach(MIMEText(self.context, 'html', 'utf-8')) # 邮件正文内容

  30. # 邮件附件

  31. if self.attachment:

  32. if isinstance(self.attachment, str):

  33. self._attach(self.attachment)

  34. if isinstance(self.attachment, list):

  35. count = 0

  36. for each in self.attachment:

  37. if count <= _FILECOUNT:

  38. self._attach(each)

  39. count += 1

  40. else:

  41. _logger.warning('Attachments is more than ', _FILECOUNT)

  42. break

  43. def _attach(self, file):

  44. if os.path.isfile(file) and os.path.getsize(file) <= _FILESIZE * 1024 * 1024:

  45. attach = MIMEApplication(open(file, 'rb').read())

  46. attach.add_header('Content-Disposition', 'attachment', filename=os.path.basename(file))

  47. attach["Content-Type"] = 'application/octet-stream'

  48. self.message.attach(attach)

  49. else:

  50. _logger.error('The attachment is not exist or more than %sM: %s' % (_FILESIZE, file))

  51. def send_mail(self):

  52. s = smtplib.SMTP_SSL(_smtp_cfg['host'], int(_smtp_cfg['port']))

  53. result = True

  54. try:

  55. s.login(self._smtp_cfg['user'], self._smtp_cfg['passwd'])

  56. s.sendmail(self._smtp_cfg['sender'], self._smtp_cfg['receivers'], self.message.as_string())

  57. except smtplib.SMTPException as e:

  58. result = False

  59. _logger.error('Send mail failed', exc_info=True)

  60. finally:

  61. s.close()

  62. return result

邮件初始化发送时的调用方式如下:

 
  1. mail = Email(title, context, file)

  2. send = mail.send_mail()

  3. print(send)

返回结果为True则发送成功,否则发送失败。

四、WEB UI自动化

WEB UI自动化,采用 selenium来完成。通过PO对象、测试数据、业务逻辑三者分离的方式来实现。

另外一个主旨是尽量让测试人员使用selenium原生的各种方法,而不要做过多封装。原因很简单,不要让测试人员来学这个框架,而是去学selenium,这样以后他出去换工作才有饭吃。如果过度封装,就会让测试人员来学这个框架,他以后出去selenium都不会用,这不是害了别人么。框架的目的只是把对象、数据、业务逻辑三者驱动起来,让测试人员工作起来更快。

我们以京东搜索爬虫为例来看如何构建这三者的关系:在京东主页面,搜索“电脑”,再获取搜索结果,保存。

1. 页面PO对象配置

打开京东商城主页,找到搜索框元素、和搜索按钮元素,分别确定他们的定位方式,以及元素对应的操作。

然后建立这个页面对象,在Page下新建一个名为"jd"的python package,再在这个package下新建一个jd.py,用来定义京东商城的主页面对象。

 
  1. from selenium.webdriver.common.by import By

  2. page_url = 'https://www.jd.com'

  3. elements = [

  4. {'name': 'search_ipt', 'desc': '搜索框点击', 'by': (By.ID, u'key'), 'ec': 'presence_of_element_located', 'action': 'send_keys()'},

  5. {'name': 'search_btn', 'desc': '搜索按钮点击', 'by': (By.CLASS_NAME, u'button'), 'ec': 'presence_of_element_located', 'action': 'click()'},

name: 每个元素+操作的唯一标识。一个元素可能由于操作不同,而要定义多个,但大部分只要定义一个。

desc:元素+操作的描述。

by:元素的定位方式,使用selenium的原生定位方式,不自己定义封装。

ec: 等待元素出现的方式,这个暂时未用。

action:元素的对应操作。使用原生的selenium动作方法,不自己定义封装。

京东商城主页面现在只用到这两个,就只定义这两个。

搜索结果页面,定义如下:
 

 
  1. from selenium.webdriver.common.by import By

  2. page_url = 'https://search.jd.com/'

  3. elements = [

  4. {'name': 'result_list', 'desc': '结果列表', 'by': (By.CLASS_NAME, u'gl-item'), 'ec': 'presence_of_all_elements_located', 'action': None},

  5. {'name': 'price', 'desc': '价格', 'by': (By.XPATH, u".//div[@class='p-price']/strong/i"), 'ec': 'presence_of_element_located', 'action': 'text'},

  6. {'name': 'pname', 'desc': '描述', 'by': (By.XPATH, u".//div[@class='p-name p-name-type-2']/a/em"), 'ec': 'presence_of_element_located', 'action': 'text'}

  7. ]

2. 实现basePage基类

basePage基类的实现思想是不做过多的封装,尽量让测试人员直接使用selenium原装的方法,而不像其它框架一样什么都封装在这里面。

所以我对basePage的定义是:根据业务逻辑(测试用例)指定的元素,输入的数据,协助它完成元素定位和操作,仅此而已。

当然如果去封装各种东西也是可以的,直接在里面加就行了。

在Page目录下,新建basePage.py,开始撸代码:

 
  1. from selenium.webdriver.common.by import By

  2. from selenium import webdriver

  3. import os

  4. import importlib

  5. import logging

  6. SimpleActions = ['clear()', 'send_keys()', 'click()', 'submit()', 'size', 'text', 'is_displayed()', 'get_attribute()']

  7. logger = logging.getLogger('main.page')

  8. class Page(object):

  9. def __init__(self, driver, page):

  10. self.driver = driver

  11. self.page = page

  12. self.elements = get_page_elements(page)

  13. self.by = ()

  14. self.action = None

  15. def _get_page_elem(self, elem):

  16. # 获取定位元素的 by,以及操作action

  17. for each in self.elements:

  18. if each['name'] == elem:

  19. self.by = each['by']

  20. if 'action' in each and each['action'] is not None:

  21. self.action = each['action']

  22. else:

  23. self.action = None

  24. def oper_elem(self, elem, args=None):

  25. self._get_page_elem(elem)

  26. cmd = self._selenium_cmd('find_element', args)

  27. return eval(cmd)

  28. def oper_elems(self, elem, args=None):

  29. self._get_page_elem(elem)

  30. cmd = self._selenium_cmd('find_elements', args)

  31. return eval(cmd)

  32. def _selenium_cmd(self, find_type='find_element', args=None):

  33. # 拼接 selenium 查找命令, 查找单个元素时find_type为'find_element',多个元素时为'find_elements'

  34. cmd = 'self.driver.' + find_type + '(*self.by)'

  35. if self.action:

  36. if self.action in SimpleActions:

  37. cmd = cmd + '.' + self.action

  38. if args:

  39. cmd = cmd[:-1] + 'args' + ')'

  40. return cmd

  41. def get_page_elements(page):

  42. """动态加载页面定义文件,获取文件中定义的元素列表elements"""

  43. elements = None

  44. if page:

  45. try:

  46. m = importlib.import_module(page)

  47. elements = m.elements

  48. except Exception as e:

  49. logger.error('error info : %s' %(e))

  50. return elements

这里面主要的只包含3个方法,一个是动态加载指定的PO对象获取元素列表,一个是在获取的元素列表中去找到当前要操作的元素,最后一个就是拼接原生的selenium命令,将测试数据插入到动作里面去。

其它的就简单了,直接调用selenium运行拼接出来的命令,把结果返回出去。

这里要注意的是,有些复杂的selenium操作,不能这么简单的拼命令,要特殊处理,这里暂时没弄;简单的命令,也没有列全。后面再慢慢加。

3. 写业务测试用例

下面开始写测试用例。
在Testcase目录下,新建一个python package:Model1。在Model1下面再建一个目录:Testdata,用于放测试数据;建一个python package:Case,用于放用例脚本。目录结构如下:
 

准备测试数据:

准备一份excel数据(test_jd_desktop.xlsx),存放在Model1/Testdata/jd下:

 keyword:搜索的关键字
count:搜索结果总数,只抓了一页,应该是60个

实现业务用例:
在Model1/Case/jd下新建一个文件:test_jd_desktop.py,开始写用例脚本。
用例使用unittest结合DDT来实现,具体代码如下:

 
  1. import os

  2. import unittest

  3. import ddt

  4. import logging

  5. from selenium import webdriver

  6. from time import sleep

  7. from Page.basePage import Page

  8. from Comm.Log import screen

  9. from Comm.data import read_excel

  10. from main import TestCasePath

  11. logger = logging.getLogger('main.jd')

  12. # 读取测试数据

  13. file = os.path.join(TestCasePath, 'Model1/Testdata/jd/test_jd_desktop.xlsx')

  14. test_data = read_excel(file)

  15. PO_jd = 'Page.jd.jd'

  16. PO_search = 'Page.jd.search_jd'

  17. @ddt.ddt # 数据驱动

  18. class TestJdSearchDesktop(unittest.TestCase):

  19. """京东搜索测试"""

  20. def setUp(self):

  21. self.driver = webdriver.Chrome()

  22. self.count = 0

  23. self.result = []

  24. @ddt.data(*test_data) # 数据驱动传具体数据

  25. def testJdSearchDesktop(self, test_data):

  26. """京东搜索测试--电脑"""

  27. url = 'https://www.jd.com'

  28. keyword = test_data['keyword']

  29. wait = self.driver.implicitly_wait(5)

  30. try:

  31. self.driver.get(url)

  32. # 实例化jd主页面

  33. jd = Page(self.driver, PO_jd)

  34. # 实例化jd搜索结果页面

  35. jd_search = Page(self.driver, PO_search)

  36. wait

  37. # jd主页面的搜索框元素中输入关键字

  38. jd.oper_elem('search_ipt', keyword)

  39. wait

  40. # 操作jd主页面的搜索按钮元素

  41. jd.oper_elem('search_btn')

  42. sleep(1)

  43. self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")

  44. sleep(1)

  45. # jd搜索结果页面,获取结果列表

  46. lis = jd_search.oper_elems('result_list')

  47. # 在取到的结果列表中,循环获取商品价格和商品名称,结果存EXCEL就没写了

  48. for each in lis:

  49. self.count += 1

  50. page_each = Page(each, PO_search)

  51. price = page_each.oper_elem('price')

  52. name = page_each.oper_elem('pname')

  53. self.result.append([name, price])

  54. sleep(1)

  55. except Exception as E:

  56. logger.error('error info : %s' % (E))

  57. screen(test_data['keyword'])

  58. # 判断是不是取到了60个商品

  59. self.assertEqual(test_data['count'], self.count)

  60. def tearDown(self):

  61. self.driver.quit()

 五、实现主程序

主程序的主要作用是 组织用例,执行用例,生成报告,发送测试报告邮件。

组织用例和执行用例都直接用unittest;

生成报告,采用BeautifulReport;

下面开始撸main.py的代码:

 
  1. import unittest

  2. import os

  3. import time

  4. import logging

  5. from Comm.Email import Email

  6. from Comm.Log import log_init

  7. from BeautifulReport import BeautifulReport

  8. # 定义各目录

  9. ProjectHome = os.path.split(os.path.realpath(__file__))[0]

  10. PageObjectPath = os.path.join(ProjectHome, "Page")

  11. TestCasePath = os.path.join(ProjectHome, "Testcase")

  12. ReportPath = os.path.join(ProjectHome, "Report")

  13. #对测试结果关键信息进行汇总,做为邮件正文

  14. def summary_format(result):

  15. summary = "\n" + u"<p> 测试结果汇总信息 </p>" + "\n" + \

  16. u"<p> 开始时间: " + result['beginTime'] + u" </p>" + "\n" + \

  17. u"<p> 运行时间: " + result['totalTime'] + u" </p>" + "\n" + \

  18. u"<p> 执行用例数: " + str(result['testAll']) + u" </p>" + "\n" + \

  19. u"<p> 通过用例数: " + str(result['testPass']) + u" </p>" + "\n" + \

  20. u"<p> 失败用例数: " + str(result['testFail']) + u" </p>" + "\n" + \

  21. u"<p> 忽略用例数: " + str(result['testSkip']) + u" </p>" + "\n"

  22. return summary

  23. # 发送邮件

  24. def send_email(file, context):

  25. title = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + '自动化测试结果'

  26. mail = Email(title, context, file)

  27. send = mail.send_mail()

  28. if send:

  29. print('测试报告邮件发送成功')

  30. else:

  31. print('测试报告邮件发送失败')

  32. # 加载测试用例

  33. def get_suite(case_path=TestCasePath, rule="test_*.py"):

  34. """加载所有的测试用例"""

  35. unittest_suite = unittest.TestSuite()

  36. discover = unittest.defaultTestLoader.discover(case_path, pattern=rule, top_level_dir=None)

  37. for each in discover:

  38. unittest_suite.addTests(each)

  39. return unittest_suite

  40. # 执行用例,生成测试报告,并返回报告附件路径、邮件正文内容

  41. def suite_run(unittest_suite):

  42. """执行所有的用例, 并把结果写入测试报告"""

  43. run_result = BeautifulReport(unittest_suite)

  44. now = time.strftime("%Y%m%d%H%M%S", time.localtime())

  45. filename = now + '_report.html'

  46. run_result.report(filename=filename, description=now, report_dir=ReportPath)

  47. rpt_summary = summary_format(run_result.fields)

  48. return os.path.join(ReportPath, filename), rpt_summary

  49. # 主程序,加载用例,执行用例,发送邮件

  50. if __name__ == "__main__":

  51. suite = get_suite()

  52. report_file, report_summary = suite_run(suite)

  53. print(report_summary)

  54. send_email(report_file, report_summary)

运行主程序,就可以把WEB UI自动化跑起来了。

六、API 自动化

API自动化,采用 request库来完成。还是通过PO对象、测试数据、业务逻辑三者分离的方式来实现。

这里以百度通用翻译接口为例,这个接口对个人用户是免费的,大家可以自己去申请。

1. API对象配置

在APIs下面新建python package:fanyi,再在fanyi下面建baidu.py。

将百度通用翻译接口定义在这里面,直接采用大家熟悉的json格式:
 

 
  1. """百度通用翻译接口"""

  2. API_NAME = 'fanyi'

  3. # 地址信息

  4. uri_scheme = 'http'

  5. endpoint = 'api.fanyi.baidu.com'

  6. resource_path = '/api/trans/vip/translate'

  7. url = uri_scheme + u'://' + endpoint + resource_path

  8. # 保持不变的参数

  9. _from = 'en'

  10. _to = 'zh'

  11. # 请求消息参数

  12. req_param = {

  13. "q": "", # 请求翻译 query, UTF-8

  14. "from": _from, # 翻译源语言

  15. "to": _to, # 翻译目标语言

  16. "appid": "", # APP ID

  17. "salt": "", # 随机数

  18. "sign": "", # 签名,appid+q+salt+密钥 的MD5值

  19. }

  20. # 响应消息参数

  21. res_param = {

  22. "from": _from,

  23. "to": _to,

  24. "trans_result": [

  25. {

  26. "src": "Hello World! This is 1st paragraph.",

  27. "dst": "你好,世界!这是第一段。"

  28. },

  29. {

  30. "src": "This is 2nd paragraph.",

  31. "dst": "这是第二段。"

  32. }

  33. ]

  34. }

2.实现base_api基类

base_api基类,主要是将数据、API对象、测试用例三者连起来;
在APIs目录下,新建base_api.py,代码如下:

 
  1. import logging

  2. import random

  3. import importlib

  4. import copy

  5. import json

  6. import unittest

  7. from hashlib import md5

  8. from ipaddress import ip_address

  9. from Comm.compare import json_compare

  10. logger = logging.getLogger('main.api')

  11. req_prefix = 'req.'

  12. res_prefix = 'res.'

  13. def _separate_data(data, prefix='req.'):

  14. pfx = prefix

  15. result = {}

  16. for key, value in data.items():

  17. if key.startswith(pfx):

  18. req_key = key[len(pfx):]

  19. result[req_key] = value

  20. return result

  21. def _get_cmd(key, dict_name='payload'):

  22. separator = '.'

  23. cmd = dict_name

  24. if separator in key:

  25. data_key = key.split(separator)

  26. for each in data_key:

  27. if each.isdigit():

  28. cmd = cmd + '[' + each + ']'

  29. else:

  30. cmd = cmd + '[\'' + each + '\']'

  31. cmd = cmd + ' = value'

  32. else:

  33. cmd = cmd + '[key] = value'

  34. return cmd

  35. def check_result(unittest_testcase, x, y):

  36. # 只有x,y完全相同才能通过,任意不同则返回失败。建议自己在用例中做结果检查

  37. testcase = unittest_testcase

  38. diff = json_compare(x, y)

  39. testcase.assertEqual(x, y)

  40. class BaseAPI(object):

  41. def __init__(self, api):

  42. self.api = api

  43. self.api_name = None

  44. self.url = ''

  45. self.req_template = {}

  46. self.res_template = {}

  47. self._get_api_param()

  48. def _get_api_param(self):

  49. """动态加载API定义文件,获取文件中定义的API参数"""

  50. try:

  51. m = importlib.import_module(self.api)

  52. self.api_name = m.API_NAME

  53. self.url = m.url

  54. self.req_template = m.req_param

  55. self.res_template = m.res_param

  56. except Exception as e:

  57. logger.error('error info : %s' % e)

  58. def payload(self, data=None):

  59. payload = copy.deepcopy(self.req_template)

  60. if data:

  61. req_pre = '.'.join([self.api_name, req_prefix])

  62. req_data = _separate_data(data, req_pre)

  63. for key, value in req_data.items():

  64. cmd = _get_cmd(key, 'payload')

  65. exec(cmd)

  66. return payload

  67. def load_expected(self, data=None):

  68. expected = copy.deepcopy(self.res_template)

  69. if data:

  70. res_pre = '.'.join([self.api_name, res_prefix])

  71. res_data = _separate_data(data, res_pre)

  72. for key, value in res_data.items():

  73. cmd = _get_cmd(key, 'expected')

  74. exec(cmd)

  75. return expected

这里面的思路是:

  • 动态加载API对象,获取API请求参数模板、和响应参数模板;
  • payload的时候,从测试数据中,取出API请求相关的数据(以API名.req开头,如fanyi.req.q),填入模板,没有的就用模板数据;
  • 加载预期结果的时候,从测试数据中,取出API响应相关的数据(以API名.res开头,如fanyi.res.trans_result.0.src),填入模板,没有的就用模板数据。
  • 提供json比较的方法;
  • 提供了一个随机handers。

具体的大家看一下就明白了。想进一步封装的还可以继续封装,比如生成hearders,数据配完了直接发送,取到结果直接比对什么的。但是建议不要过度封装。

附json比较的方法:
 

 
  1. import json_tools

  2. def json_compare(x, y):

  3. diff = json_tools.diff(x, y)

  4. if diff:

  5. for action in diff:

  6. if 'add' in action:

  7. print('++增加元素:', action['add'], ' 值:', action['value'])

  8. elif 'remove' in action:

  9. print('--删除元素:', action['remove'], ' 值:', action['prev'])

  10. elif 'replace' in action:

  11. print('**修改元素:', action['replace'], ' 值:', action['prev'], '-->', action['value'])

  12. return diff

3.测试用例

在Testcase下建API模块,API模块下建Case和Testdata,分别放用例和数据,目录如下:

定义测试数据

测试数据需要按一定的格式处理,即每个参数以api名称开头,用“.”连接,然后用res和req来区分响应还是请求,后面就是具体的参数了,多级参数以“.”连接。具体如下:

测试用例脚本:

仍然用unittest和ddt来实现。

 
  1. import os

  2. import unittest

  3. import ddt

  4. import random

  5. import json

  6. import requests

  7. from time import sleep

  8. from Comm.data import read_excel

  9. from Comm.encryption import make_md5

  10. from main import TestCasePath

  11. from APIs.base_api import BaseAPI, check_result

  12. # 开通普通个人的百度翻译接口,设置appid和appkey.

  13. app_id = your appid

  14. app_key = your appkey

  15. # 获取测试数据

  16. file = os.path.join(TestCasePath, 'API/TestData/baidu_fanyi.xlsx')

  17. test_data = read_excel(file)

  18. api = 'APIs.fanyi.baidu'

  19. @ddt.ddt

  20. class TestBaiduFanyi(unittest.TestCase):

  21. """百度翻译接口测试"""

  22. def setUp(self):

  23. self.api = BaseAPI(api)

  24. @ddt.data(*test_data)

  25. def test_baidu_fanyi(self, test_data):

  26. """百度翻译接口测试"""

  27. api = self.api

  28. # Build test_data,这是些动态参数,在这里计算

  29. test_data['fanyi.req.appid'] = app_id

  30. salt = random.randint(32768, 65536)

  31. test_data['fanyi.req.salt'] = salt

  32. sign = make_md5(app_id + test_data['fanyi.req.q'] + str(salt) + app_key)

  33. test_data['fanyi.req.sign'] = sign

  34. # Build request

  35. headers = {'Content-Type': 'application/x-www-form-urlencoded'}

  36. payload = api.payload(test_data )

  37. # Send request

  38. r = requests.post(api.url, params=payload, headers=headers)

  39. result = r.json()

  40. expected = api.load_expected(test_data)

  41. self.assertEqual(r.status_code, 200)

  42. check_result(self, expected, result) # 简单的模板验证,大家最好自己写验证。

  43. sleep(0.5)

然后运行主程序,API自动化测试也就可以跑起来了。

补:MD5函数

 
  1. from hashlib import md5

  2. def make_md5(s, encoding='utf-8'):

  3. return md5(s.encode(encoding)).hexdigest()

  最后: 下方这份完整的软件测试视频教程已经整理上传完成,需要的朋友们可以自行领取【保证100%免费】

 

  • 8
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值