一、HTML测试报告
对软件测试人员来讲,需要一份漂亮且通俗易懂的测试报告来展示自动化测试成果。显然,一个简单的Log文件是不够的。
HTMLTestRunner 是Python 标准库 unittest 单元测试框架的一个扩展,它生成易于使用的 HTML 测试报告。HTMLTestRunner 是在BSD 许可证下发布的。
下载地址如下:
http://tungwaiyip.info/software/HTMLTestRunner.html
这个扩展非常简单,只有一个HTMLTestRunner.py 文件,选中后单击鼠标右键,在弹出的快捷菜单中选择目标另存为,将它保存到本地。安装方法也很简单,将其复制到Python安装目录下即可。
Windows:将下载的文件保存到 ...Python3xLib目录下。
Linux:以Ubuntu为例,首先需要打开终端,找到Python的安装目录。打开终端后,输入Python命令进入Python交互模式,通过sys.path 可以查看本机的Python的安装目录。
1.1 修改 HTMLTestRunner
因为 HTMLTestRunner.py 是基于 Python2 开发的,为了使其支持 Python3 的环境,需要对其中的部分内容进行修改。
# 第 94 行 import StingIO 修改为: import io # 第 539 行 self.outputBuffer = StringIO.StringIO() 修改为: self.outputBuffer = io.StingIO() # 第 631 行 print >>sys.stderr,' Time Elapsed %s' % (self.stopTime-self.startTime) 修改为: print(sys.stderr,' Time Elapsed %s' % (self.stopTime-self.startTime)) # 第 642 行 if not rmap.has_key(cls): 修改为: if not cls in rmap: # 第 766 行 uo = o.decode("Iatin-1") 修改为: uo = e # 第 772 行 uo = o.decode("Iatin-1") 修改为: uo = e
1.2 生成 HTML 测试报告
以 test_baidu.py 文件为例生成 HTMLTestRunner 测试报告。
# test_baidu.py from selenium import webdriver import unittest from HTMLTestRunner import HTMLTestRunner class Baidu(unittest.TestCase): def setUp(self): self.driver = webdriver.Firefox() self.driver.maximize_window() self.driver.implicitly_wait(10) self.base_url = "http://www.baidu.com" def test_baidu_search(self): driver = self.driver driver.get(self.base_url + "/") driver.find_element_by_id("kw").clear() driver.find_element_by_id("kw").send_keys("HTMLTestRunner") driver.find_element_by_id("su").click() def tearDown(self): self.driver.quit() if __name__ == '__main__': testunit = unittest.TestSuite() testunit.addTest(Baidu("test_baidu_search")) # 定义报告存放路径 fp = open("./result.html", "wb") # 定义测试报告 runner = HTMLTestRunner(stream=fp, title="百度搜索测试报告", description = "用例执行情况:") runner.run(testunit) # 运行测试用例 fp.close() # 关闭报告文件
代码分析
- 首先,将 HTMLTestRunner 模块用 import 导入进来。
- 其次,通过 open() 方法以二进制写模式打开当前目录下的 result.html,如果没有,则自动创建该文件。
- 接着,调用 HTMLTestRunner 模块下的 HTMLTestRunner 类。stream 指定测试报告文件,title 用于定义测试报告的标题,description 用于定义测试报告的副标题。
- 最后,通过 HTMLTestRunner 的 run() 方法来运行测试套件中所组装的测试用例。最后通过 close() 关闭测试报告文件。
生成的文档如下图:
1.3 更易读的测试报告
现在生成的测试报告还不易读,因为它只罗列了一堆测试类和测试方法,我们需要用心地为测试类和测试方法命名才能提供测试报告的可读性。如果随意命名为 “ test_case1 ”、“ test_case2 ” 等,那么这份报告就失去了可读性,也许时间久了连脚本开发者都不清楚 “ test_case1 ” 是测试什么功能了。
在编写功能测试用例时,每条测试用例都有标题,那么我们能不能也为自动化测试用例加上标题呢?在此之前我们先来学习另外一个知识点:Python的注释。Python的注释有两种,一种叫comment,另一种叫doc string,前者为普通注释,后者用于函数、类和方法的描述。
def add(a,b): """add()函数需要两个入参,并返回两个参数相加的值。""" return a + b >>>help(add) Help on function add in module __main__: add(a, b) add()函数需要两个入参,并返回两个参数相加的值。
在类或方法的下方,通过三引号(""" """ 或 ''' ''')来添加 doc string 类型的注释,这类注释在平时调用的时候不显示,可以通过 help() 方法来查看类或方法的这种注释。
回到问题的原点,HTMLTestRunner 可以读取doc string 类型的注释。所以,我们只需给测试类或方法添加这种类型的注释即可。
class Baidu(unittest.TestCase): """ 百度搜索测试 """ ...... def test_baidu_search(self): """ 搜索关键字:HTMLTestRunner """ ......
再次运行测试用例,查看测试报告。如下图:
1.4 测试报告文件名
在每次运行测试之前,都要手动修改报告的名称,如果忘记修改,就会把之前的报告覆盖,这样做显然很麻烦,那么有没有办法使每次生成的报告名称都不重复并且有意义?最好的办法是在报告名称中加入当前时间,这样生成的报告既不会重叠,又能更清晰地知道报告的生成时间。
if __name__ == '__main__': testunit = unittest.TestSuite() testunit.addTest(Baidu("test_baidu_search")) # 按照一定格式获取当前时间 now = time.strftime("%Y-%m-%d %H_%M_%S") # 这里不能使用 : ,此符号在windows下不能命名文件,不然会报错。 # 定义报告存放路径 filename = "./" + now + "result.html" fp = open(filename, "wb") # 定义测试报告 runner = HTMLTestRunner(stream=fp, title="百度搜索测试报告", description = "用例执行情况:") runner.run(testunit) # 运行测试用例 fp.close() # 关闭报告文件
系列结果如下:
1.5 项目集成测试报告
目前 HTMLTestRunner 只是针对单个测试文件生成测试报告,我们的最终目的是希望将它集成到 runtest.py 文件中,使其作用于整个测试项目。下面打开 runtest.py 文件进行修改。
import unittest,time from HTMLTestRunner import HTMLTestRunner # 指定测试用例为当前文件夹下的 test_case 目录 test_dir = "./test_case" discover = unittest.defaultTestLoader.discover(test_dir,pattern="test_*.py") if __name__ == '__main__': now = time.strftime("%Y-%m-%d %H_%M_%S") filename = test_dir + '/' + now + "result.html" fp = open(filename,"wb") runner = HTMLTestRunner( stream=fp, title="测试报告", description="用例执行情况:") runner.run(discover) fp.close()
二、自动发邮件功能
自动发邮件功能也是自动化测试项目的重要需求之一。例如,我们想在自动化脚本运行完成之后,邮箱就可以收到最新的测试报告结果。假设生成的测试报告与多人相关,每个人都去测试服务器查看就会比较麻烦,如果把这种主动的且不及时的查看变成被动且及时的查收,就方便多了。
SMTP (Simple Mail Transfer Protocol) 是简单邮件传输协议,它是一组用于由原地址到目的地址传送邮件的规则,由它来控制信件的中转方式。
Python 的 smtplib 模块提供了一种很方便的途径用来发送电子邮件。它对 SMTP 协议进行了简单的封装。我们可以使用 SMTP 对象的 sendmail 方法发送邮件,通过 help() 查看 SMTP 所提供的方法如下。
>>> from smtplib import SMTP >>> help(SMTP) Help on class SMTP in module smtplib: | connect(self, host='localhost', port=0, source_address=None) | Connect to a host on a given port. | | If the hostname ends with a colon (`:') followed by a number, and | there is no port specified, that suffix will be stripped off and the | number interpreted as the port number to use. | | Note: This method is automatically invoked by __init__, if a host is | specified during instantiation. | quit(self) | Terminate the SMTP session. | sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]) | This command performs an entire mail transaction. | | The arguments are: | - from_addr : The address sending this mail. | - to_addrs : A list of addresses to send this mail to. A bare | string will be treated as a list with 1 address. | - msg : The message to send. | - mail_options : List of ESMTP options (such as 8bitmime) for the | mail command. | - rcpt_options : List of ESMTP options (such as DSN commands) for | all the rcpt commands. | If there has been no previous EHLO or HELO command this session, this | method tries ESMTP EHLO first. If the server does ESMTP, message size | and each of the specified options will be passed to it. If EHLO | fails, HELO will be tried and ESMTP options suppressed. ........................... ........................... ...........................
导入 SMTP 对象,通过 help() 查看对象的注释,从中找到 sendmail() 方法的使用说明。
connect(host,port)方法参数说明如下:
host: 指定连接的邮箱服务器
post: 指定连接服务器的端口号
login(user,password)方法参数说明如下:
user: 登录邮箱用户用
password: 登录邮箱密码
sendmail(from_addr,to_addrs,msg...)方法参数说明如下:
from_addr: 邮箱发送者地址
to_addrs: 字符串列表,邮箱发送地址
msg: 发送消息
quit()方法: 用于结束 SMTP 会话
通过 Python 的 SMTP 对象发邮件需要填写邮箱服务器。
2.1 发送HTML格式的邮件
#! /usr/bin/env python # -*- coding: utf-8 -*- # __author__ = "Q1mi" # Date: 2018/7/6 import smtplib from email.mime.text import MIMEText from email.header import Header # 发送邮箱服务器 smtpserver = "smtp.qq.com" # 发送邮箱用户/密码 user = "578389018@qq.com" password = "*******" # 发送邮箱 sender = "dongye95@foxmail.com" # 接收邮箱 receiver = "1029027408@qq.com" # 发送邮件主题 subject = "Python email test" # 编写HTML类型的邮件正文 msg = MIMEText("<html><h1>你好!</h1></html>", "html", "utf-8") msg["Subject"] = Header(subject, "utf-8") # 连接发送邮件 smtp = smtplib.SMTP() smtp.connect(smtpserver) smtp.login(user, password) smtp.sendmail(sender, receiver, msg.as_string()) smtp.quit()
本例中,除SMTP模块外,我们还用到了 email 模块,它主要用来定义邮件的标题和正文;Header() 方法用来定义邮件标题;MIMEText() 用于定义邮件正文,参数为 html 格式的文本。登录 receive@123.com 邮箱,查看邮件内容如图:
2.2 发送带附件的邮件
import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart # 发送邮箱服务器 smtpserver = "smtp.qq.com" # 发送邮箱用户/密码 user = "578389018@qq.com" password = "*******" # 发送邮箱 sender = "578389018@qq.com" # 接收邮箱 receiver = "1029027408@qq.com" # 发送邮件主题 subject = "Python email test" # 发送的附件 sendfile = open("C:UsersAdministratorDesktop\test.xml","rb").read() att = MIMEText(sendfile,"base64","utf-8") att["Content-Type"] = "application/octet-stream" att["Content-Disposition"] = 'attachment; filename="log.txt"' msgRoot = MIMEMultipart("related") msgRoot["Subject"] = subject msgRoot.attach(att) # 连接发送邮件 smtp = smtplib.SMTP() smtp.connect(smtpserver) smtp.login(user, password) smtp.sendmail(sender, receiver, msgRoot.as_string()) smtp.quit()
相比于上一个实例,通过MIMEMultipart() 模块构造的带附件的邮件如下图所示:
2.3 查找最新的测试报告
现在已经知道如何通过 Python 编写发邮件程序,但要想和自动化测试项目结合还需要解决一个问题,因为测试报告的名称是根据当前时间生成的,所以如何找到最新生成的测试报告是实现发邮件功能的关键。
import os # 定义文件目录 result_dir = "C:/Users/Administrator/Desktop/" lists = os.listdir(result_dir) # 重新按时间对目录下的文件进行排序 lists.sort(key=lambda fn:os.path.getmtime(result_dir+"\"+fn)) print(('最新的文件为:' + lists[-1])) file = os.path.join(result_dir,lists[-1]) print(file)
首先定义测试报告的目录 result_dir,os.listdir() 可以获取目录下的所有文件及文件夹。利用sort() 方法对目录下的文件及文件夹按时间重新排序。list[-1] 取到的就是最新生成的文件或文件夹。程序运行结果如下
最新的文件为:test.72.py C:/Users/Administrator/Desktop/test.72.py ***Repl Closed***
2.4 整合自动发邮件功能
解决了前面的问题后,现在就可以将自动发邮件功能集成到自动化测试项目中了。下面打开runtest.py 文件重新进行编辑。
from HTMLTestRunner import HTMLTestRunner from email.mime.text import MIMEText from email.header import Header import smtplib import unittest import time import os # 定义发送邮件 def send_mail(file_new): f = open(file_new,"rb") mail_body = f.read() f.close() msg = MIMEText(mail_body,"html","utf-8") msg["Subject"] = Header("自动化测试报告","utf-8") smtp = smtplib.SMTP() smtp.connnect("smtp.qq.com") smtp.login("1029027408@qq.com","123456") smtp.sendmail("1029027408@qq.com","578389018@qq.com",msg.as_string()) smtp.quit() print("email has send out!") # 查找测试报告目录,找到最新生成的测试报告文件 def new_report(testreporr): lists = os.listdir(testreport) lists.sort(key=lambda fn:os.path.getatime(testreport + "\" + fn)) file_new = os.path.join(testreport,lists[-1]) print(file_new) return file_new if __name__ == "__main__": test_dir = "D:\testpro\test_case" test_report = "D:\testpro\report" discover = unittest.defaultTestLoader.discover(test_dir, pattern="test_*.py") now = time.strftime("%Y-%m-%d_%H_%M_%S") filename = test_report + "\" + now + "result.html" fp = open(filename,"wb") runner = HTMLTestRunner(stream=fp,title="测试报告",description="用例执行情况:") runner.run(discover) fp.close() new_report = new_report(test_report) send_mail(new_report) # 发送测试报告
整个程序的执行过程可以分为三个步骤:
- 通过unittest框架的 discover() 找到匹配测试用例,由 HTMLTestRunner 的 run() 方法执行测试用例并生成最新的测试报告。
- 调用 new_report() 函数找到测试报告目录 (report) 下最新生成的测试报告,返回测试报告的途径。
- 将得到的最新测试报告的完整路径传给send_mail() 函数,实现发邮件功能。
整个脚本执行完成后,打开接收邮箱,即可看到最新测试执行的测试报告。
# TODO
三、Page Object 设计模式
Page Object 是 Selenium 自动化测试项目开发实践的最佳设计模式之一,它主要体现在对界面交互细节的封装,这样可以使测试案例更关注业务而非界面细节,从而提高测试案例的可读性。
3.1 认识Page Object
Page Object 设计模式的优点如下:
- 减少代码的重复
- 提高测试用例的可读性
- 提高测试用例的可维护性,特别是针对 UI 频繁变化的项目
当为 Web 页面编写测试时,需要操作该Web页面上的元素。然后,如果在测试代码中直接操作HTML元素,那么你的代码是极其脆弱的,因为 UI 经常变动。我们可以将一个 page 对象封装成一个 HTML 页面,然后通过提供的应用程序特定的 API 来操作页面元素,而不是在 HTML 中四处搜寻。Page Object 原理如下所示:
page 对象的一个基本经验法则是:凡是人能做的事,page对象通过软件客户端都能够做到。因此,它也应当提供一个易于编程的接口并隐藏窗口中底层的部件。所以访问一个文本框应该通过一个访问方法(accessor method)来实现字符串的获取与返回,复选框应当使用布尔值,按钮应该被表示为行为导向的方法名。page 对象应当将在 GUI 控件上所有查询和操作数据的行为封装为方法。一个好的经验法则是,即使改变具体的控件,page对象的接口也不应当发生变化。
尽管该术语是“页面”对象,但并不意味着需要针对每个页面建立一个这样的对象,例如,页面有重要意义的元素可以独立为一个page对象。经验法则的目的是通过给页面建模,使其对应用程序的使用者变得有意义。
3.2 Page Obiect实例
# po_model.py class Page(object): """ 基础类,用于所页面的继承 """ login_url = "http://www.126.com" def __init__(self, selenium_driver,base_url=login_url): self.base_url = base_url self.driver = selenium_driver self.timeout = 30 def on_page(self): return self.driver.current_url == (self.base_url + self.url) def _open(slef,url): url = self.base_url + url self.driver.get(url) assert self.on_page(),"Did not land on %s" % url def open(self): slef._open(self.url) def find_element(self,*loc): return self.driver.find_element(*loc) class LoginPage(Page): """ 126邮箱登录页面模型 """ url = "/" # 定位器 username_loc = (By.ID,"idInput") password_loc = (By.ID,"pwdInput") submit_loc = (By.ID,"loginBtn") # Action def type_username(self,username): self.find_element(*self.username_loc).send_keys(username) def type_password(self,password): self.find_element(*self.password_loc).send_keys(password) def submit(self): self.find_element(*self.submit_loc).click() def test_user_login(driver,username,password): """ 测试获取的用户名/密码是否可以登录 """ login_page = LoginPage(driver) login_page.open() login_page.type_username(username) login_page.type_password(password) login_page.submit() def main(): try: driver = webdriver.Firefox() username = "username" password = "123456" test_user_login(driver,username,password) sleep(3) text = driver.find_element_xpath("//span[@id="spnUid"]").text assert(text == "username@126.com"),"用户名称不匹配,登录失败!" finally: # 关闭浏览器窗口 driver.close() if __name__ == '__main__': main()
逐段分析,来体会这样设计的好处。
1 创建 page 类
# po_model.py # ...... class Page(object): """ 基础类,用于所页面的继承 """ login_url = "http://www.126.com" def __init__(self, selenium_driver,base_url=login_url): self.base_url = base_url self.driver = selenium_driver self.timeout = 30 def on_page(self): return self.driver.current_url == (self.base_url + self.url) def _open(slef,url): url = self.base_url + url self.driver.get(url) assert self.on_page(),"Did not land on %s" % url def open(self): slef._open(self.url) def find_element(self,*loc): return self.driver.find_element(*loc) # ......
首先创建一个基础类 Page,在初始化方法__intit__() 中定义驱动 (driver)、基本的 URL(base_url) 和超时时间(timeout)等。
定义 open() 方法用于打开 URL 网站,但它本身并未做这件事情,而是交由 _open() 方法来实现。关于 URL 地址的断言部分,则交由 on_page() 方法来实现,而 find_element() 方法用于元素的定位。
2 创建 LoginPage 类
Page 类中定义的这些方法都是页面操作的基本方法。下面根据登录页的特点再创建 LoginPage 类并继承 Page 类,这也是 Page Object 设计模式中最重要的对象层。
# po_model.py # ...... class LoginPage(Page): """ 126邮箱登录页面模型 """ url = "/" # 定位器 username_loc = (By.ID,"idInput") password_loc = (By.ID,"pwdInput") submit_loc = (By.ID,"loginBtn") # Action def type_username(self,username): self.find_element(*self.username_loc).send_keys(username) def type_password(self,password): self.find_element(*self.password_loc).send_keys(password) def submit(self): self.find_element(*self.submit_loc).click() # ......
LoginPage类中主要度登录页面上的元素进行封装,使其成为更具体的操作方法。例如,用户名、密码和登录按钮都被封装成了方法。
3 创建test_user_login() 函数
# ...... def test_user_login(driver,username,password): """ 测试获取的用户名/密码是否可以登录 """ login_page = LoginPage(driver) login_page.open() login_page.type_username(username) login_page.type_password(password) login_page.submit() # ......
test_user_login() 函数将单个的元素操作组成一个完整的动作,而这个动作包含了打开浏览器、输入用户名/密码、点击登录等单步操作。在使用该函数时需要将 driver/username、password等信息作为函数的入参,这样该函数具有很强的可重用性。
4 创建main() 函数
def main(): try: driver = webdriver.Firefox() username = "username" password = "123456" test_user_login(driver,username,password) sleep(3) text = driver.find_element_xpath("//span[@id="spnUid"]").text assert(text == "username@126.com"),"用户名称不匹配,登录失败!" finally: # 关闭浏览器窗口 driver.close() if __name__ == '__main__': main()
main() 函数更接近于用户的操作行为。对用于来说,要进行邮箱的登录,需要关心的就是通过哪个浏览器打开邮箱网址、登录的用户名和密码是什么,至于输入框、按钮是如何定位的,则不需要关心。
这样分层的好处是,不同的层关心不同的问题。页面对象层只关心元素的定位问题,测试用例只关心测试的数据。
一个有分歧的地方是page对象是否应自身包含断言,或者仅仅提供数据给测试脚本来设置断言。在page对象中包含断言的倡导者认为,这有助于避免在测试脚本中出现重复的断言,可以更容易地提供更好的错误信息,并且提供更接近只做不问风格的API。不在page对象中包含断言的倡导者认为,包含断言会混合访问页面数据和实现断言逻辑的职责,并且导致page对象过于臃肿。
笔者(指虫师)赞成在page对象中不包含断言,虽然我们可以通过为常用的断言提供断言库的方式来消除重复,提供更好的诊断,但从用户的角度去自动化的观点来看,判断是否登录成功是用户需要做得事情,不应该交由页面对象层来完成。
使用Page Object模式之后的另一个好处就是有助于降低冗余。如果需要在10个用例中输入不同的用户名/密码登录,那么用main() 方法写将会变得非常简洁。
因此,Page Object 模型的作用在一个测试人员自己写主场景测试案例时是不容易体会到的,因为你不需要和开发、业务交流案例,也不会写很多重复的动作。但是,当你真正开始尝试ATDD或BDD,当你开始写一些重要的异常分支流程时,当你开始为新需求频繁维护修改案例时,就会意识到Page Object的作用。
最后,Page Object不是万灵药,也不是唯一方案,提高测试案例的可读性,避免案例步骤冗余才是终极目标。