搭建测试框架

Python必会的单元测试框架 —— unittest

unittest是xUnit系列框架中的一员,如果你了解xUnit的其他成员,那你用unittest来应该是很轻松的,它们的工作方式都差不多。

unittest核心工作原理

unittest中最核心的四个概念是:test case, test suite, test runner, test fixture。

  • 一个TestCase的实例就是一个测试用例。什么是测试用例呢?就是一个完整的测试流程,包括测试前准备环境的搭建(setUp),执行测试代码(run),以及测试后环境的还原(tearDown)。元测试(unit test)的本质也就在这里,一个测试用例是一个完整的测试单元,通过运行这个测试单元,可以对某一个问题进行验证。

  • 而多个测试用例集合在一起,就是TestSuite,而且TestSuite也可以嵌套TestSuite。

  • TestLoader是用来加载TestCase到TestSuite中的,其中有几个loadTestsFrom__()方法,就是从各个地方寻找TestCase,创建它们的实例,然后add到TestSuite中,再返回一个TestSuite实例。

  • TextTestRunner是来执行测试用例的,其中的run(test)会执行TestSuite/TestCase中的run(result)方法。 
    测试的结果会保存到TextTestResult实例中,包括运行了多少测试用例,成功了多少,失败了多少等信息。

  • 而对一个测试用例环境的搭建和销毁,是一个fixture。

一个class继承了unittest.TestCase,便是一个测试用例,但如果其中有多个以 test 开头的方法,那么每有一个这样的方法,在load的时候便会生成一个TestCase实例,如:一个class中有四个test_xxx方法,最后在load到suite中时也有四个测试用例。

到这里整个流程就清楚了:

写好TestCase,然后由TestLoader加载TestCase到TestSuite,然后由TextTestRunner来运行TestSuite,运行的结果保存在TextTestResult中,我们通过命令行或者unittest.main()执行时,main会调用TextTestRunner中的run来执行,或者我们可以直接通过TextTestRunner来执行用例。这里加个说明,在Runner执行时,默认将执行结果输出到控制台,我们可以设置其输出到文件,在文件中查看结果(你可能听说过HTMLTestRunner,是的,通过它可以将结果输出到HTML中,生成漂亮的报告,它跟TextTestRunner是一样的,从名字就能看出来,这个我们后面再说)。

unittest实例

下面我们通过一些实例来更好地认识一下unittest。

我们先来准备一些待测方法:

mathfunc.py

def add(a, b):
    return a+b def minus(a, b): return a-b def multi(a, b): return a*b def divide(a, b): return a/b

简单示例

接下来我们为这些方法写一个测试:

test_mathfunc.py

# -*- coding: utf-8 -*-

import unittest
from mathfunc import *


class TestMathFunc(unittest.TestCase): """Test mathfuc.py""" def test_add(self): """Test method add(a, b)""" self.assertEqual(3, add(1, 2)) self.assertNotEqual(3, add(2, 2)) def test_minus(self): """Test method minus(a, b)""" self.assertEqual(1, minus(3, 2)) def test_multi(self): """Test method multi(a, b)""" self.assertEqual(6, multi(2, 3)) def test_divide(self): """Test method divide(a, b)""" self.assertEqual(2, divide(6, 3)) self.assertEqual(2.5, divide(5, 2)) if __name__ == '__main__': unittest.main()

执行结果:

.F..
======================================================================
FAIL: test_divide (__main__.TestMathFunc)
Test method divide(a, b)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:/py/test_mathfunc.py", line 26, in test_divide self.assertEqual(2.5, divide(5, 2)) AssertionError: 2.5 != 2 ---------------------------------------------------------------------- Ran 4 tests in 0.000s FAILED (failures=1)

能够看到一共运行了4个测试,失败了1个,并且给出了失败原因,2.5 != 2 也就是说我们的divide方法是有问题的。

这就是一个简单的测试,有几点需要说明的:

  1. 在第一行给出了每一个用例执行的结果的标识,成功是 .,失败是 F,出错是 E,跳过是 S。从上面也可以看出,测试的执行跟方法的顺序没有关系,test_divide写在了第4个,但是却是第2个执行的。

  2. 每个测试方法均以 test 开头,否则是不被unittest识别的。

  3. 在unittest.main()中加 verbosity 参数可以控制输出的错误报告的详细程度,默认是 1,如果设为 0,则不输出每一用例的执行结果,即没有上面的结果中的第1行;如果设为 2,则输出详细的执行结果,如下:

test_add (__main__.TestMathFunc)
Test method add(a, b) ... ok
test_divide (__main__.TestMathFunc)
Test method divide(a, b) ... FAIL
test_minus (__main__.TestMathFunc)
Test method minus(a, b) ... ok
test_multi (__main__.TestMathFunc)
Test method multi(a, b) ... ok

======================================================================
FAIL: test_divide (__main__.TestMathFunc)
Test method divide(a, b)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:/py/test_mathfunc.py", line 26, in test_divide self.assertEqual(2.5, divide(5, 2)) AssertionError: 2.5 != 2 ---------------------------------------------------------------------- Ran 4 tests in 0.002s FAILED (failures=1)

可以看到,每一个用例的详细执行情况以及用例名,用例描述均被输出了出来(在测试方法下加代码示例中的”“”Doc String”“”,在用例执行时,会将该字符串作为此用例的描述,加合适的注释能够使输出的测试报告更加便于阅读)

组织TestSuite

上面的代码示例了如何编写一个简单的测试,但有两个问题,我们怎么控制用例执行的顺序呢?(这里的示例中的几个测试方法并没有一定关系,但之后你写的用例可能会有先后关系,需要先执行方法A,再执行方法B),我们就要用到TestSuite了。我们添加到TestSuite中的case是会按照添加的顺序执行的。

问题二是我们现在只有一个测试文件,我们直接执行该文件即可,但如果有多个测试文件,怎么进行组织,总不能一个个文件执行吧,答案也在TestSuite中。

下面来个例子:

在文件夹中我们再新建一个文件,test_suite.py:

# -*- coding: utf-8 -*-

import unittest
from test_mathfunc import TestMathFunc

if __name__ == '__main__': suite = unittest.TestSuite() tests = [TestMathFunc("test_add"), TestMathFunc("test_minus"), TestMathFunc("test_divide")] suite.addTests(tests) runner = unittest.TextTestRunner(verbosity=2) runner.run(suite)

执行结果:

test_add (test_mathfunc.TestMathFunc)
Test method add(a, b) ... ok
test_minus (test_mathfunc.TestMathFunc)
Test method minus(a, b) ... ok
test_divide (test_mathfunc.TestMathFunc)
Test method divide(a, b) ... FAIL

======================================================================
FAIL: test_divide (test_mathfunc.TestMathFunc)
Test method divide(a, b)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\py\test_mathfunc.py", line 26, in test_divide self.assertEqual(2.5, divide(5, 2)) AssertionError: 2.5 != 2 ---------------------------------------------------------------------- Ran 3 tests in 0.001s FAILED (failures=1)

可以看到,执行情况跟我们预料的一样:执行了三个case,并且顺序是按照我们添加进suite的顺序执行的。

上面用了TestSuite的 addTests() 方法,并直接传入了TestCase列表,我们还可以:

# 直接用addTest方法添加单个TestCase
suite.addTest(TestMathFunc("test_multi"))

# 用addTests + TestLoader
# loadTestsFromName(),传入'模块名.TestCase名'
suite.addTests(unittest.TestLoader().loadTestsFromName('test_mathfunc.TestMathFunc')) suite.addTests(unittest.TestLoader().loadTestsFromNames(['test_mathfunc.TestMathFunc'])) # loadTestsFromNames(),类似,传入列表 # loadTestsFromTestCase(),传入TestCase suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestMathFunc))

注意,用TestLoader的方法是无法对case进行排序的,同时,suite中也可以套suite。

将结果输出到文件中

用例组织好了,但结果只能输出到控制台,这样没有办法查看之前的执行记录,我们想将结果输出到文件。很简单,看示例:

修改test_suite.py:

# -*- coding: utf-8 -*-

import unittest
from test_mathfunc import TestMathFunc

if __name__ == '__main__': suite = unittest.TestSuite() suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestMathFunc)) with open('UnittestTextReport.txt', 'a') as f: runner = unittest.TextTestRunner(stream=f, verbosity=2) runner.run(suite)

执行此文件,可以看到,在同目录下生成了UnittestTextReport.txt,所有的执行报告均输出到了此文件中,这下我们便有了txt格式的测试报告了。

test fixture之setUp() tearDown()

上面整个测试基本跑了下来,但可能会遇到点特殊的情况:如果我的测试需要在每次执行之前准备环境,或者在每次执行完之后需要进行一些清理怎么办?比如执行前需要连接数据库,执行完成之后需要还原数据、断开连接。总不能每个测试方法中都添加准备环境、清理环境的代码吧。

这就要涉及到我们之前说过的test fixture了,修改test_mathfunc.py:

# -*- coding: utf-8 -*-

import unittest
from mathfunc import *


class TestMathFunc(unittest.TestCase): """Test mathfuc.py""" def setUp(self): print "do something before test.Prepare environment." def tearDown(self): print "do something after test.Clean up." def test_add(self): """Test method add(a, b)""" print "add" self.assertEqual(3, add(1, 2)) self.assertNotEqual(3, add(2, 2)) def test_minus(self): """Test method minus(a, b)""" print "minus" self.assertEqual(1, minus(3, 2)) def test_multi(self): """Test method multi(a, b)""" print "multi" self.assertEqual(6, multi(2, 3)) def test_divide(self): """Test method divide(a, b)""" print "divide" self.assertEqual(2, divide(6, 3)) self.assertEqual(2.5, divide(5, 2))

我们添加了 setUp() 和 tearDown() 两个方法(其实是重写了TestCase的这两个方法),这两个方法在每个测试方法执行前以及执行后执行一次,setUp用来为测试准备环境,tearDown用来清理环境,已备之后的测试。

我们再执行一次:

test_add (test_mathfunc.TestMathFunc)
Test method add(a, b) ... ok
test_divide (test_mathfunc.TestMathFunc)
Test method divide(a, b) ... FAIL
test_minus (test_mathfunc.TestMathFunc)
Test method minus(a, b) ... ok
test_multi (test_mathfunc.TestMathFunc)
Test method multi(a, b) ... ok

======================================================================
FAIL: test_divide (test_mathfunc.TestMathFunc)
Test method divide(a, b)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\py\test_mathfunc.py", line 36, in test_divide self.assertEqual(2.5, divide(5, 2)) AssertionError: 2.5 != 2 ---------------------------------------------------------------------- Ran 4 tests in 0.000s FAILED (failures=1) do something before test.Prepare environment. add do something after test.Clean up. do something before test.Prepare environment. divide do something after test.Clean up. do something before test.Prepare environment. minus do something after test.Clean up. do something before test.Prepare environment. multi do something after test.Clean up.

可以看到setUp和tearDown在每次执行case前后都执行了一次。

如果想要在所有case执行之前准备一次环境,并在所有case执行结束之后再清理环境,我们可以用 setUpClass() 与 tearDownClass():

...

class TestMathFunc(unittest.TestCase):
    """Test mathfuc.py""" @classmethod def setUpClass(cls): print "This setUpClass() method only called once." @classmethod def tearDownClass(cls): print "This tearDownClass() method only called once too." ...

执行结果如下:

...
This setUpClass() method only called once.
do something before test.Prepare environment.
add
do something after test.Clean up.
...
do something before test.Prepare environment.
multi
do something after test.Clean up.
This tearDownClass() method only called once too.

可以看到setUpClass以及tearDownClass均只执行了一次。

跳过某个case

如果我们临时想要跳过某个case不执行怎么办?unittest也提供了几种方法:

  1. skip装饰器
...

class TestMathFunc(unittest.TestCase):
    """Test mathfuc.py""" ... @unittest.skip("I don't want to run this case.") def test_divide(self): """Test method divide(a, b)""" print "divide" self.assertEqual(2, divide(6, 3)) self.assertEqual(2.5, divide(5, 2))

执行:

...
test_add (test_mathfunc.TestMathFunc)
Test method add(a, b) ... ok
test_divide (test_mathfunc.TestMathFunc)
Test method divide(a, b) ... skipped "I don't want to run this case."
test_minus (test_mathfunc.TestMathFunc)
Test method minus(a, b) ... ok test_multi (test_mathfunc.TestMathFunc) Test method multi(a, b) ... ok ---------------------------------------------------------------------- Ran 4 tests in 0.000s OK (skipped=1)

可以看到总的test数量还是4个,但divide()方法被skip了。

skip装饰器一共有三个 unittest.skip(reason)unittest.skipIf(condition, reason)unittest.skipUnless(condition, reason),skip无条件跳过,skipIf当condition为True时跳过,skipUnless当condition为False时跳过。

  1. TestCase.skipTest()方法
...

class TestMathFunc(unittest.TestCase):
    """Test mathfuc.py""" ... def test_divide(self): """Test method divide(a, b)""" self.skipTest('Do not run this.') print "divide" self.assertEqual(2, divide(6, 3)) self.assertEqual(2.5, divide(5, 2))

输出:

...
test_add (test_mathfunc.TestMathFunc)
Test method add(a, b) ... ok
test_divide (test_mathfunc.TestMathFunc)
Test method divide(a, b) ... skipped 'Do not run this.'
test_minus (test_mathfunc.TestMathFunc)
Test method minus(a, b) ... ok test_multi (test_mathfunc.TestMathFunc) Test method multi(a, b) ... ok ---------------------------------------------------------------------- Ran 4 tests in 0.001s OK (skipped=1)

效果跟上面的装饰器一样,跳过了divide方法。

我们需要一个什么样的框架

既然要搭一个框架,我们首先得弄明白我们需要一个什么样的框架,这个框架要支持什么功能?框架主要的作用就是帮助我们编写更加简单而且好维护的用例,让我们把主要精力放在测试用例的设计上,那么我们就需要把所有额外的东西抽象出来作为框架的部分。那么,额外的东西是什么?

  1. 日志以及报告
  2. 日志级别、URL、浏览器类型等基本配置
  3. 参数化
  4. 公共方法

搭建框架目录结构

现在我们很容易就把框架的结构搭建好了:

Test_framework
    |--config(配置文件)
    |--data(数据文件)
    |--drivers(驱动)
    |--log(日志)
    |--report(报告)
    |--test(测试用例)
    |--utils(公共方法)
    |--ReadMe.md(加个说明性的文件,告诉团队成员框架需要的环境以及用法)

配置文件

配置文件我们有多种选择:ini、yaml、xml、properties、txt、py等

鉴于我之前写过一篇yaml的博文,我们这里就用yaml吧。

所以我们在config文件夹里创建config.yml文件,在utils里创建一个config.py文件读取配置,内容暂且不管。

简单的对之后的内容勾画一下

  1. 首先我们要把配置抽出来,用yaml文件放配置。所以我们要在config层添加配置文件config.yml,在utils层添加file_reader.py与config.py来管理。——怎样从0开始搭建一个测试框架_1
  2. 然后我们将python自带的logging模块封装了一下,从配置文件读取并设置固定的logger。在utils中创建了log.py。——怎样从0开始搭建一个测试框架_2
  3. 然后封装xlrd模块,读取excel,实现用例的参数化。——怎样从0开始搭建一个测试框架_3
  4. 然后是生成HTML测试报告,这个博主修改了网上原有的HTMLTestRunner,改为中文并美化,然后修改其支持PY3。你可以直接拿去用。——怎样从0开始搭建一个测试框架_4
  5. 然后我们给框架添加了发送邮件报告的能力。在utils中添加了mail.py。——怎样从0开始搭建一个测试框架_5
  6. 然后我们将测试用例用Page-Object思想进行封装,进一步划分test层的子层。——怎样从0开始搭建一个测试框架_6
  7. 接下来为了接口测试封装client类。在utils中添加了client.py。——怎样从0开始搭建一个测试框架_7
  8. 然后添加了一个简单的自定义断言,在utils中添加assertion.py,可用同样的方法自行扩展。——怎样从0开始搭建一个测试框架_8
  9. 接下来我们为了抽取响应结果,用JMESPath封装Extractor,在utils中添加extractor.py。——怎样从0开始搭建一个测试框架_9
  10. 然后是生成器。为我们自动生成固定类型的测试数据。utils下创建了generator.py。——怎样从0开始搭建一个测试框架_10
  11. 最后为了一些项目中的支持方法,如加密、签名等,创建支持库support.py。

怎样从0开始搭建一个测试框架_1——配置

我们先创建一个简单的脚本吧,在test文件夹创建test_baidu.py:

import os
import time
from selenium import webdriver
from selenium.webdriver.common.by import By URL = "http://www.baidu.com" base_path = os.path.dirname(os.path.abspath(__file__)) + '\..' driver_path = os.path.abspath(base_path+'\drivers\chromedriver.exe') locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') driver = webdriver.Chrome(executable_path=driver_path) driver.get(URL) driver.find_element(*locator_kw).send_keys('selenium 灰蓝') driver.find_element(*locator_su).click() time.sleep(2) links = driver.find_elements(*locator_result) for link in links: print(link.text) driver.quit()

脚本打开chrome,输入“selenium 灰蓝”,然后把所有结果中的标题打印出来。

如果想要搜索“Python selenium”,是不是要再创建一个脚本?还是把原来的脚本修改一下?

或者我们可以用unittest来改一下,把两次搜索分别写一个测试方法:

import os
import time
import unittest
from selenium import webdriver from selenium.webdriver.common.by import By class TestBaiDu(unittest.TestCase): URL = "http://www.baidu.com" base_path = os.path.dirname(os.path.abspath(__file__)) + '\..' driver_path = os.path.abspath(base_path+'\drivers\chromedriver.exe') locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def setUp(self): self.driver = webdriver.Chrome(executable_path=self.driver_path) self.driver.get(self.URL) def tearDown(self): self.driver.quit() def test_search_0(self): self.driver.find_element(*self.locator_kw).send_keys('selenium 灰蓝') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: print(link.text) def test_search_1(self): self.driver.find_element(*self.locator_kw).send_keys('Python selenium') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: print(link.text) if __name__ == '__main__': unittest.main()

现在,我们把配置抽出来放到config.yml中:

URL: http://www.baidu.com

为了读取yaml文件,我们需要一个封装YamlReader类,在utils中创建file_reader.py文件:

import yaml
import os


class YamlReader: def __init__(self, yamlf): if os.path.exists(yamlf): self.yamlf = yamlf else: raise FileNotFoundError('文件不存在!') self._data = None @property def data(self): # 如果是第一次调用data,读取yaml文档,否则直接返回之前保存的数据 if not self._data: with open(self.yamlf, 'rb') as f: self._data = list(yaml.safe_load_all(f)) # load后是个generator,用list组织成列表 return self._data

而且我们需要一个Config类来读取配置,config.py:

"""
读取配置。这里配置文件用的yaml,也可用其他如XML,INI等,需在file_reader中添加相应的Reader进行处理。
"""
import os
from utils.file_reader import YamlReader

# 通过当前文件的绝对路径,其父级目录一定是框架的base目录,然后确定各层的绝对路径。如果你的结构不同,可自行修改。 # 之前直接拼接的路径,修改了一下,用现在下面这种方法,可以支持linux和windows等不同的平台,也建议大家多用os.path.split()和os.path.join(),不要直接+'\\xxx\\ss'这样 BASE_PATH = os.path.split(os.path.dirname(os.path.abspath(__file__)))[0] CONFIG_FILE = os.path.join(BASE_PATH, 'config', 'config.yml') DATA_PATH = os.path.join(BASE_PATH, 'data') DRIVER_PATH = os.path.join(BASE_PATH, 'drivers') LOG_PATH = os.path.join(BASE_PATH, 'log') REPORT_PATH = os.path.join(BASE_PATH, 'report') class Config: def __init__(self, config=CONFIG_FILE): self.config = YamlReader(config).data def get(self, element, index=0): """ yaml是可以通过'---'分节的。用YamlReader读取返回的是一个list,第一项是默认的节,如果有多个节,可以传入index来获取。 这样我们其实可以把框架相关的配置放在默认节,其他的关于项目的配置放在其他节中。可以在框架中实现多个项目的测试。 """ return self.config[index].get(element)

修改test_baidu.py:

import time
import unittest
from selenium import webdriver
from selenium.webdriver.common.by import By from utils.config import Config, DRIVER_PATH class TestBaiDu(unittest.TestCase): URL = Config().get('URL') locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def setUp(self): self.driver = webdriver.Chrome(executable_path=DRIVER_PATH + '\chromedriver.exe') self.driver.get(self.URL) def tearDown(self): self.driver.quit() def test_search_0(self): self.driver.find_element(*self.locator_kw).send_keys('selenium 灰蓝') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: print(link.text) def test_search_1(self): self.driver.find_element(*self.locator_kw).send_keys('Python selenium') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: print(link.text) if __name__ == '__main__': unittest.main()

我们已经把配置分离出来了,虽然现在看起来似乎很麻烦,但是想想如果你有50个用例文件甚至更多,一旦项目URL变了,你还要一个个去修改吗?

怎样从0开始搭建一个测试框架_2——日志

接下来我们为我们的框架加上log,在utils中创建一个log.py文件,Python有很方便的logging库,我们对其进行简单的封装,使框架可以很简单地打印日志(输出到控制台以及日志文件)。

import os
import logging
from logging.handlers import TimedRotatingFileHandler
from utils.config import LOG_PATH class Logger(object): def __init__(self, logger_name='framework'): self.logger = logging.getLogger(logger_name) logging.root.setLevel(logging.NOTSET) self.log_file_name = 'test.log' self.backup_count = 5 # 日志输出级别 self.console_output_level = 'WARNING' self.file_output_level = 'DEBUG' # 日志输出格式 self.formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') def get_logger(self): """在logger中添加日志句柄并返回,如果logger已有句柄,则直接返回""" if not self.logger.handlers: # 避免重复日志 console_handler = logging.StreamHandler() console_handler.setFormatter(self.formatter) console_handler.setLevel(self.console_output_level) self.logger.addHandler(console_handler) # 每天重新创建一个日志文件,最多保留backup_count份 file_handler = TimedRotatingFileHandler(filename=os.path.join(LOG_PATH, self.log_file_name), when='D', interval=1, backupCount=self.backup_count, delay=True, encoding='utf-8' ) file_handler.setFormatter(self.formatter) file_handler.setLevel(self.file_output_level) self.logger.addHandler(file_handler) return self.logger logger = Logger().get_logger()

然后修改test_baidu.py,将输出改到log中:

import time
import unittest
from selenium import webdriver
from selenium.webdriver.common.by import By from utils.config import Config, DRIVER_PATH from utils.log import logger class TestBaiDu(unittest.TestCase): URL = Config().get('URL') locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def setUp(self): self.driver = webdriver.Chrome(executable_path=DRIVER_PATH + '\chromedriver.exe') self.driver.get(self.URL) def tearDown(self): self.driver.quit() def test_search_0(self): self.driver.find_element(*self.locator_kw).send_keys('selenium 灰蓝') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) def test_search_1(self): self.driver.find_element(*self.locator_kw).send_keys('Python selenium') self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) if __name__ == '__main__': unittest.main()

执行后,可以看到在log文件夹下创建了test.log文件,打印的信息都输出到了文件中:

2017-07-26 16:00:59,457 - framework - INFO - Python selenium —— 一定要会用selenium的等待,三种..._CSDN博客 2017-07-26 16:00:59,487 - framework - INFO - Selenium - 灰蓝 - CSDN博客 2017-07-26 16:00:59,515 - framework - INFO - ...教你在Windows上搭建Python+Selenium环境 - 灰蓝 - CSDN博客... 2017-07-26 16:00:59,546 - framework - INFO - Python selenium —— 父子、兄弟、相邻节点定位方式详..._CSDN博客 2017-07-26 16:00:59,572 - framework - INFO - Selenium - 灰蓝 - CSDN博客 2017-07-26 16:00:59,595 - framework - INFO - selenium之 时间日期控件的处理 - 灰蓝 - CSDN博客 ...

我们还可以把log的设置放到config中,修改config.yml,将几项重要的设置都写进去:

URL: http://www.baidu.com
log:
    file_name: test.log
    backup: 5 console_level: WARNING file_level: DEBUG pattern: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'

同时修改log.py,读取config,如果config中有,则采用文件中的设置,否则,采用默认设置

"""
日志类。通过读取配置文件,定义日志级别、日志文件名、日志格式等。
一般直接把logger import进去
from utils.log import logger
logger.info('test log')
"""
import os
import logging
from logging.handlers import TimedRotatingFileHandler from utils.config import LOG_PATH, Config class Logger(object): def __init__(self, logger_name='framework'): self.logger = logging.getLogger(logger_name) logging.root.setLevel(logging.NOTSET) c = Config().get('log') self.log_file_name = c.get('file_name') if c and c.get('file_name') else 'test.log' # 日志文件 self.backup_count = c.get('backup') if c and c.get('backup') else 5 # 保留的日志数量 # 日志输出级别 self.console_output_level = c.get('console_level') if c and c.get('console_level') else 'WARNING' self.file_output_level = c.get('file_level') if c and c.get('file_level') else 'DEBUG' # 日志输出格式 pattern = c.get('pattern') if c and c.get('pattern') else '%(asctime)s - %(name)s - %(levelname)s - %(message)s' self.formatter = logging.Formatter(pattern) def get_logger(self): """在logger中添加日志句柄并返回,如果logger已有句柄,则直接返回 我们这里添加两个句柄,一个输出日志到控制台,另一个输出到日志文件。 两个句柄的日志级别不同,在配置文件中可设置。 """ if not self.logger.handlers: # 避免重复日志 console_handler = logging.StreamHandler() console_handler.setFormatter(self.formatter) console_handler.setLevel(self.console_output_level) self.logger.addHandler(console_handler) # 每天重新创建一个日志文件,最多保留backup_count份 file_handler = TimedRotatingFileHandler(filename=os.path.join(LOG_PATH, self.log_file_name), when='D', interval=1, backupCount=self.backup_count, delay=True, encoding='utf-8' ) file_handler.setFormatter(self.formatter) file_handler.setLevel(self.file_output_level) self.logger.addHandler(file_handler) return self.logger logger = Logger().get_logger() 

现在,我们已经可以很方便地输出日志了,并且可以通过配置config.yml来修改log的设置。

怎样从0开始搭建一个测试框架_3——参数化

我们已经把配置分离,并添加了log,接下来我们应该尝试着进行数据分离,进行参数化了。

我们修改file_reader.py文件,添加ExcelReader类,实现读取excel内容的功能:

"""
文件读取。YamlReader读取yaml文件,ExcelReader读取excel。
"""
import yaml
import os
from xlrd import open_workbook class YamlReader: def __init__(self, yamlf): if os.path.exists(yamlf): self.yamlf = yamlf else: raise FileNotFoundError('文件不存在!') self._data = None @property def data(self): # 如果是第一次调用data,读取yaml文档,否则直接返回之前保存的数据 if not self._data: with open(self.yamlf, 'rb') as f: self._data = list(yaml.safe_load_all(f)) # load后是个generator,用list组织成列表 return self._data class SheetTypeError(Exception): pass class ExcelReader: """ 读取excel文件中的内容。返回list。 如: excel中内容为: | A | B | C | | A1 | B1 | C1 | | A2 | B2 | C2 | 如果 print(ExcelReader(excel, title_line=True).data),输出结果: [{A: A1, B: B1, C:C1}, {A:A2, B:B2, C:C2}] 如果 print(ExcelReader(excel, title_line=False).data),输出结果: [[A,B,C], [A1,B1,C1], [A2,B2,C2]] 可以指定sheet,通过index或者name: ExcelReader(excel, sheet=2) ExcelReader(excel, sheet='BaiDuTest') """ def __init__(self, excel, sheet=0, title_line=True): if os.path.exists(excel): self.excel = excel else: raise FileNotFoundError('文件不存在!') self.sheet = sheet self.title_line = title_line self._data = list() @property def data(self): if not self._data: workbook = open_workbook(self.excel) if type(self.sheet) not in [int, str]: raise SheetTypeError('Please pass in <type int> or <type str>, not {0}'.format(type(self.sheet))) elif type(self.sheet) == int: s = workbook.sheet_by_index(self.sheet) else: s = workbook.sheet_by_name(self.sheet) if self.title_line: title = s.row_values(0) # 首行为title for col in range(1, s.nrows): # 依次遍历其余行,与首行组成dict,拼到self._data中 self._data.append(dict(zip(title, s.row_values(col)))) else: for col in range(0, s.nrows): # 遍历所有行,拼到self._data中 self._data.append(s.row_values(col)) return self._data if __name__ == '__main__': y = 'E:\Test_framework\config\config.yml' reader = YamlReader(y) print(reader.data) e = 'E:/Test_framework/data/baidu.xlsx' reader = ExcelReader(e, title_line=True) print(reader.data) 

我们添加title_line参数,用来声明是否在excel表格里有标题行,如果有标题行,返回dict列表,否则返回list列表,如下:

# excel表格如下:
# | title1 | title2 |
# | value1 | value2 |
# | value3 | value4 |

# 如果title_line=True [{"title1": "value1", "title2": "value2"}, {"title1": "value3", "title2": "value4"}] # 如果title_line=False [["title1", "title2"], ["value1", "value2"], ["value3", "value4"]]

在data目录下创建baidu.xlsx,如下:

| search |
| selenium 灰蓝 |
| Python selenium |

然后我们再修改我们可怜的小用例:

import time
import unittest
from selenium import webdriver
from selenium.webdriver.common.by import By from utils.config import Config, DRIVER_PATH, DATA_PATH from utils.log import logger from utils.file_reader import ExcelReader class TestBaiDu(unittest.TestCase): URL = Config().get('URL') excel = DATA_PATH + '/baidu.xlsx' locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def sub_setUp(self): self.driver = webdriver.Chrome(executable_path=DRIVER_PATH + '\chromedriver.exe') self.driver.get(self.URL) def sub_tearDown(self): self.driver.quit() def test_search(self): datas = ExcelReader(self.excel).data for d in datas: with self.subTest(data=d): self.sub_setUp() self.driver.find_element(*self.locator_kw).send_keys(d['search']) self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) self.sub_tearDown() if __name__ == '__main__': unittest.main(verbosity=2)

subTest是PY3 unittest里带的功能,PY2中没有,PY2中要想使用,需要用unittest2库。subTest是没有setUp和tearDown的,所以需要自己手动添加并执行。

现在我们就实现了数据分离,之后如果要搜索“张三”、“李四”,只要在excel里添加行就可以了。subTest参数化也帮助我们少写了很多用例方法,不用一遍遍在Case里copy and paste了。

怎样从0开始搭建一个测试框架_4——报告

这一步我们需要用到并修改HTMLTestRunner.py,它本身是基于PY2的,简单而实用,之前博主对其进行了美化,并且改成了中文(下载链接)。 
现在博主基于此进行了对PY3的修改,增加了对subTest的支持。 
【更新】增加了一个用Echarts做的统计图,见github

  1. StringIO -> io
  2. 去掉decode
  3. 增加addSubTest()

部分修改内容:

# import StringIO  # PY3改成了io库
import io
...
  def startTest(self, test): TestResult.startTest(self, test) # just one buffer for both stdout and stderr # self.outputBuffer = StringIO.StringIO() self.outputBuffer = io.StringIO() ... # 添加addSubTest方法,将有subTest的Case拆分成多个Case,均在报告中输出。这点处理与unittest的TextRunner并不相同,细心的同学可以试验下,看看它是怎么处理的。 def addSubTest(self, test, subtest, err): if err is not None: if getattr(self, 'failfast', False): self.stop() if issubclass(err[0], test.failureException): self.failure_count += 1 errors = self.failures errors.append((subtest, self._exc_info_to_string(err, subtest))) output = self.complete_output() self.result.append((1, test, output + '\nSubTestCase Failed:\n' + str(subtest), self._exc_info_to_string(err, subtest))) if self.verbosity > 1: sys.stderr.write('F ') sys.stderr.write(str(subtest)) sys.stderr.write('\n') else: sys.stderr.write('F') else: self.error_count += 1 errors = self.errors errors.append((subtest, self._exc_info_to_string(err, subtest))) output = self.complete_output() self.result.append( (2, test, output + '\nSubTestCase Error:\n' + str(subtest), self._exc_info_to_string(err, subtest))) if self.verbosity > 1: sys.stderr.write('E ') sys.stderr.write(str(subtest)) sys.stderr.write('\n') else: sys.stderr.write('E') self._mirrorOutput = True else: self.subtestlist.append(subtest) self.subtestlist.append(test) self.success_count += 1 output = self.complete_output() self.result.append((0, test, output + '\nSubTestCase Pass:\n' + str(subtest), '')) if self.verbosity > 1: sys.stderr.write('ok ') sys.stderr.write(str(subtest)) sys.stderr.write('\n') else: sys.stderr.write('.') ... def run(self, test): "Run the given test case or test suite." result = _TestResult(self.verbosity) test(result) self.stopTime = datetime.datetime.now() self.generateReport(test, result) # print >>>sys.stderr '\nTime Elapsed: %s' % (self.stopTime-self.startTime) # PY3的print需处理 print('\nTime Elapsed: %s' % (self.stopTime-self.startTime), file=sys.stderr) return result ... # PY3这里不用decode了,直接处理 # if isinstance(o,str): # # TODO: some problem with 'string_escape': it escape \n and mess up formating # # uo = unicode(o.encode('string_escape')) # # uo = o.decode('latin-1') # uo = o.decode('utf-8') # else: # uo = o # if isinstance(e,str): # # TODO: some problem with 'string_escape': it escape \n and mess up formating # # ue = unicode(e.encode('string_escape')) # # ue = e.decode('latin-1') # ue = e.decode('utf-8') # else: # ue = e script = self.REPORT_TEST_OUTPUT_TMPL % dict( id = tid, # output = saxutils.escape(uo+ue), output = saxutils.escape(o+e), )

以上代码列出大部分主要修改。博主在GitHub上上传了该文件,目前仅是简单做了点修改,没有经过正式的测试,之后可能会进行更多改动,感兴趣的可以star下来,或者自己进一步修改。

传送门

你也自己编写自己的Report Runner,并不很复杂。

将其放在utils目录中,然后我们再次修改test_baidu:

import time
import unittest
from selenium import webdriver
from selenium.webdriver.common.by import By from utils.config import Config, DRIVER_PATH, DATA_PATH, REPORT_PATH from utils.log import logger from utils.file_reader import ExcelReader from utils.HTMLTestRunner import HTMLTestRunner class TestBaiDu(unittest.TestCase): URL = Config().get('URL') excel = DATA_PATH + '/baidu.xlsx' locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def sub_setUp(self): self.driver = webdriver.Chrome(executable_path=DRIVER_PATH + '\chromedriver.exe') self.driver.get(self.URL) def sub_tearDown(self): self.driver.quit() def test_search(self): datas = ExcelReader(self.excel).data for d in datas: with self.subTest(data=d): self.sub_setUp() self.driver.find_element(*self.locator_kw).send_keys(d['search']) self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) self.sub_tearDown() if __name__ == '__main__': report = REPORT_PATH + '\\report.html' with open(report, 'wb') as f: runner = HTMLTestRunner(f, verbosity=2, title='从0搭建测试框架 灰蓝', description='修改html报告') runner.run(TestBaiDu('test_search'))

执行后,可以在report目录下看到有 report.html 文件,我们已经生成测试报告了。

怎样从0开始搭建一个测试框架_5——邮件

我们已经有了日志、有了报告,生成报告之后需要给其他组员看,自然要有发邮件的功能。这块我们要用到smtplib和email库。

在utils中创建mail.py,初始化时传入全部所需数据,message是正文,可不填,path可以传list或者str;receiver支持多人,用”;”隔开就行

"""
邮件类。用来给指定用户发送邮件。可指定多个收件人,可带附件。
"""
import re
import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from socket import gaierror, error from utils.log import logger class Email: def __init__(self, server, sender, password, receiver, title, message=None, path=None): """初始化Email :param title: 邮件标题,必填。 :param message: 邮件正文,非必填。 :param path: 附件路径,可传入list(多附件)或str(单个附件),非必填。 :param server: smtp服务器,必填。 :param sender: 发件人,必填。 :param password: 发件人密码,必填。 :param receiver: 收件人,多收件人用“;”隔开,必填。 """ self.title = title self.message = message self.files = path self.msg = MIMEMultipart('related') self.server = server self.sender = sender self.receiver = receiver self.password = password def _attach_file(self, att_file): """将单个文件添加到附件列表中""" att = MIMEText(open('%s' % att_file, 'rb').read(), 'plain', 'utf-8') att["Content-Type"] = 'application/octet-stream' file_name = re.split(r'[\\|/]', att_file) att["Content-Disposition"] = 'attachment; filename="%s"' % file_name[-1] self.msg.attach(att) logger.info('attach file {}'.format(att_file)) def send(self): self.msg['Subject'] = self.title self.msg['From'] = self.sender self.msg['To'] = self.receiver # 邮件正文 if self.message: self.msg.attach(MIMEText(self.message)) # 添加附件,支持多个附件(传入list),或者单个附件(传入str) if self.files: if isinstance(self.files, list): for f in self.files: self._attach_file(f) elif isinstance(self.files, str): self._attach_file(self.files) # 连接服务器并发送 try: smtp_server = smtplib.SMTP(self.server) # 连接sever except (gaierror and error) as e: logger.exception('发送邮件失败,无法连接到SMTP服务器,检查网络以及SMTP服务器. %s', e) else: try: smtp_server.login(self.sender, self.password) # 登录 except smtplib.SMTPAuthenticationError as e: logger.exception('用户名密码验证失败!%s', e) else: smtp_server.sendmail(self.sender, self.receiver.split(';'), self.msg.as_string()) # 发送邮件 finally: smtp_server.quit() # 断开连接 logger.info('发送邮件"{0}"成功! 收件人:{1}。如果没有收到邮件,请检查垃圾箱,' '同时检查收件人地址是否正确'.format(self.title, self.receiver)) 

之后我们修改用例文件,执行完成后发送邮件:

import time
import unittest
from selenium import webdriver
from selenium.webdriver.common.by import By from utils.config import Config, DRIVER_PATH, DATA_PATH, REPORT_PATH from utils.log import logger from utils.file_reader import ExcelReader from utils.HTMLTestRunner import HTMLTestRunner from utils.mail import Email class TestBaiDu(unittest.TestCase): URL = Config().get('URL') excel = DATA_PATH + '/baidu.xlsx' locator_kw = (By.ID, 'kw') locator_su = (By.ID, 'su') locator_result = (By.XPATH, '//div[contains(@class, "result")]/h3/a') def sub_setUp(self): self.driver = webdriver.Chrome(executable_path=DRIVER_PATH + '\chromedriver.exe') self.driver.get(self.URL) def sub_tearDown(self): self.driver.quit() def test_search(self): datas = ExcelReader(self.excel).data for d in datas: with self.subTest(data=d): self.sub_setUp() self.driver.find_element(*self.locator_kw).send_keys(d['search']) self.driver.find_element(*self.locator_su).click() time.sleep(2) links = self.driver.find_elements(*self.locator_result) for link in links: logger.info(link.text) self.sub_tearDown() if __name__ == '__main__': report = REPORT_PATH + '\\report.html' with open(report, 'wb') as f: runner = HTMLTestRunner(f, verbosity=2, title='从0搭建测试框架 灰蓝', description='修改html报告') runner.run(TestBaiDu('test_search')) e = Email(title='百度搜索测试报告', message='这是今天的测试报告,请查收!', receiver='...', server='...', sender='...', password='...', path=report ) e.send()

执行完成之后可以看到receiver收到了我们的报告。当然,在这块你有可能遇到很多问题,可以根据错误号去网上查询如网易帮助。一般有几种常见的错误:

  1. 账户密码出错
  2. 服务器sever出错,这个可以根据你的发送人的邮箱去网站或邮箱设置中查看到
  3. 邮箱没有开通smtp服务,一般在邮箱设置中
  4. 邮件被拦截,在title、message以及发送的文件中不要带明显乱码、广告倾向的字符
  5. sender跟loginuser不一致的问题,发送人必须是登录用户

怎样从0开始搭建一个测试框架_6——PageObject

针对UI自动化,接下来我们用PO思想进行下封装。

对于不同的项目,不同的页面,我们都需要选择浏览器、打开网址等,我们可以把这些操作抽象出来,让不同的用例去调用,只需要传入不同参数即可,不用一遍遍复制粘贴。

为此,我们对test目录再次进行分层,创建page、common、case、suite四个目录:

test
    |--case(用例文件)
    |--common(跟项目、页面无关的封装)
    |--page(页面)
    |--suite(测试套件,用来组织用例)

我们首先想要封装的选择浏览器、打开网址的类,所以放到common中,创建browser.py:

import time
import os
from selenium import webdriver
from utils.config import DRIVER_PATH, REPORT_PATH # 可根据需要自行扩展 CHROMEDRIVER_PATH = DRIVER_PATH + '\chromedriver.exe' IEDRIVER_PATH = DRIVER_PATH + '\IEDriverServer.exe' PHANTOMJSDRIVER_PATH = DRIVER_PATH + '\phantomjs.exe' TYPES = {'firefox': webdriver.Firefox, 'chrome': webdriver.Chrome, 'ie': webdriver.Ie, 'phantomjs': webdriver.PhantomJS} EXECUTABLE_PATH = {'firefox': 'wires', 'chrome': CHROMEDRIVER_PATH, 'ie': IEDRIVER_PATH, 'phantomjs': PHANTOMJSDRIVER_PATH} class UnSupportBrowserTypeError(Exception): pass class Browser(object): def __init__(self, browser_type='firefox'): self._type = browser_type.lower() if self._type in TYPES: self.browser = TYPES[self._type] else: raise UnSupportBrowserTypeError('仅支持%s!' % ', '.join(TYPES.keys())) self.driver = None def get(self, url, maximize_window=True, implicitly_wait=30): self.driver = self.browser(executable_path=EXECUTABLE_PATH[self._type]) self.driver.get(url) if maximize_window: self.driver.maximize_window() self.driver.implicitly_wait(implicitly_wait) return self def save_screen_shot(self, name='screen_shot'): day = time.strftime('%Y%m%d', time.localtime(time.time())) screenshot_path = REPORT_PATH + '\screenshot_%s' % day if not os.path.exists(screenshot_path): os.makedirs(screenshot_path) tm = time.strftime('%H%M%S', time.localtime(time.time())) screenshot = self.driver.save_screenshot(screenshot_path + '\\%s_%s.png' % (name, tm)) return screenshot def close(self): self.driver.close() def quit(self): self.driver.quit()

这里做了非常简单的封装,可以根据传入的参数选择浏览器的driver去打开对应的浏览器,并且加了一个保存截图的方法,可以保存png截图到report目录下。

我们再封装一个页面类Page:

from test.common.browser import Browser


class Page(Browser): # 更多的封装请自己动手... def __init__(self, page=None, browser_type='firefox'): if page: self.driver = page.driver else: super(Page, self).__init__(browser_type=browser_type) def get_driver(self): return self.driver def find_element(self, *args): return self.driver.find_element(*args) def find_elements(self, *args): return self.driver.find_elements(*args)

我们仅仅封装了几个方法,更多的封装还请读者自己动手,接下来我们需要对页面进行封装,在page目录创建如下两个文件:

baidu_main_page.py:

from selenium.webdriver.common.by import By
from test.common.page import Page


class BaiDuMainPage(Page): loc_search_input = (By.ID, 'kw') loc_search_button = (By.ID, 'su') def search(self, kw): """搜索功能""" self.find_element(*self.loc_search_input).send_keys(kw) self.find_element(*self.loc_search_button).click()

baidu_result_page.py:

from selenium.webdriver.common.by import By
from test.page.baidu_main_page import BaiDuMainPage


class BaiDuResultPage(BaiDuMainPage): loc_result_links = (By.XPATH, '//div[contains(@class, "result")]/h3/a') @property def result_links(self): return self.find_elements(*self.loc_result_links)

一个是封装的百度首页,一个封装百度结果页,这样,我们的测试用例就可以改为:

import time
import unittest
from utils.config import Config, DATA_PATH, REPORT_PATH
from utils.log import logger from utils.file_reader import ExcelReader from utils.HTMLTestRunner import HTMLTestRunner from utils.mail import Email from test.page.baidu_result_page import BaiDuMainPage, BaiDuResultPage class TestBaiDu(unittest.TestCase): URL = Config().get('URL') excel = DATA_PATH + '/baidu.xlsx' def sub_setUp(self): # 初始页面是main page,传入浏览器类型打开浏览器 self.page = BaiDuMainPage(browser_type='chrome').get(self.URL, maximize_window=False) def sub_tearDown(self): self.page.quit() def test_search(self): datas = ExcelReader(self.excel).data for d in datas: with self.subTest(data=d): self.sub_setUp() self.page.search(d['search']) time.sleep(2) self.page = BaiDuResultPage(self.page) # 页面跳转到result page links = self.page.result_links for link in links: logger.info(link.text) self.sub_tearDown() if __name__ == '__main__': report = REPORT_PATH + '\\report.html' with open(report, 'wb') as f: runner = HTMLTestRunner(f, verbosity=2, title='从0搭建测试框架 灰蓝', description='修改html报告') runner.run(TestBaiDu('test_search')) e = Email(title='百度搜索测试报告', message='这是今天的测试报告,请查收!', receiver='...', server='...', sender='...', password='...', path=report ) e.send()

现在,我们已经用PO把用例改写了,这里面还有不少问题,浏览器的设置、基础page的封装、log太少、没有做异常处理等等,这些相信你都可以逐步完善的。

怎样从0开始搭建一个测试框架_7——接口

前面我们都是用的UI自动化的用例来实现的,如果我们想做接口框架怎么办?今天就扩展一下接口测试模块,这里我们需要用到requests库(接口是HTTP类型的,其他类型也有对应的库)

我们先在ReadMe.md中补上新加的依赖库。然后在utils中创建一个client.py的文件,在其中创建一个HTTPClient类:

"""
添加用于接口测试的client,对于HTTP接口添加HTTPClient,发送http请求。
还可以封装TCPClient,用来进行tcp链接,测试socket接口等等。
"""

import requests
from utils.log import logger

METHODS = ['GET', 'POST', 'HEAD', 'TRACE', 'PUT', 'DELETE', 'OPTIONS', 'CONNECT'] class UnSupportMethodException(Exception): """当传入的method的参数不是支持的类型时抛出此异常。""" pass class HTTPClient(object): """ http请求的client。初始化时传入url、method等,可以添加headers和cookies,但没有auth、proxy。 >>> HTTPClient('http://www.baidu.com').send() <Response [200]> """ def __init__(self, url, method='GET', headers=None, cookies=None): """headers: 字典。 例:headers={'Content_Type':'text/html'},cookies也是字典。""" self.url = url self.session = requests.session() self.method = method.upper() if self.method not in METHODS: raise UnSupportMethodException('不支持的method:{0},请检查传入参数!'.format(self.method)) self.set_headers(headers) self.set_cookies(cookies) def set_headers(self, headers): if headers: self.session.headers.update(headers) def set_cookies(self, cookies): if cookies: self.session.cookies.update(cookies) def send(self, params=None, data=None, **kwargs): response = self.session.request(method=self.method, url=self.url, params=params, data=data, **kwargs) response.encoding = 'utf-8' logger.debug('{0} {1}'.format(self.method, self.url)) logger.debug('请求成功: {0}\n{1}'.format(response, response.text)) return response 

接下来写个用例,但是我们接口的用例跟UI混在一起总是不好,所以我们可以在test下创建一个interface的目录,里面创建test_baidu_http.py的用例文件。

这里你也可以在test下分成API和UI两层,分别在其中再进行分层,看情况而定吧。

test_baidu_http.py:

import unittest
from utils.config import Config, REPORT_PATH
from utils.client import HTTPClient from utils.log import logger from utils.HTMLTestRunner import HTMLTestRunner class TestBaiDuHTTP(unittest.TestCase): URL = Config().get('URL') def setUp(self): self.client = HTTPClient(url=self.URL, method='GET') def test_baidu_http(self): res = self.client.send() logger.debug(res.text) self.assertIn('百度一下,你就知道', res.text) if __name__ == '__main__': report = REPORT_PATH + '\\report.html' with open(report, 'wb') as f: runner = HTMLTestRunner(f, verbosity=2, title='从0搭建测试框架 灰蓝', description='接口html报告') runner.run(TestBaiDuHTTP('test_baidu_http'))

这里我们加了一句断言,没有断言怎么能叫用例,我们之前写的UI用例,也可以自己动手加上断言。

现在我们的框架既可以做UI测试,也能做接口测试了。如果你的接口类型不是HTTP的,请自己封装对应的Client类。socket库测TCP接口、suds库测SOAP接口,不论你是什么类型的接口,总能找到对应的Python库的。

怎样从0开始搭建一个测试框架_8——断言

上次我们的用例中增加了断言。断言(检查点)这个东西对测试来说很重要。不然你怎么知道一个测试结果是对是错呢。unittest为我们提供了很多很好的断言,但是对于我们的项目可能是不够的。我们需要封装自己的断言方法。

这里我们简单封装一个断言,在utils中创建assertion.py文件,在其中创建断言:

"""
在这里添加各种自定义的断言,断言失败抛出AssertionError就OK。
"""


def assertHTTPCode(response, code_list=None): res_code = response.status_code if not code_list: code_list = [200] if res_code not in code_list: raise AssertionError('响应code不在列表中!') # 抛出AssertionError,unittest会自动判别为用例Failure,不是Error 

这个断言传入响应,以及期望的响应码列表,如果响应码不在列表中,则断言失败。

在test_baidu_http.py中添加此断言:

import unittest
from utils.config import Config, REPORT_PATH
from utils.client import HTTPClient from utils.log import logger from utils.HTMLTestRunner import HTMLTestRunner from utils.assertion import assertHTTPCode class TestBaiDuHTTP(unittest.TestCase): URL = Config().get('URL') def setUp(self): self.client = HTTPClient(url=self.URL, method='GET') def test_baidu_http(self): res = self.client.send() logger.debug(res.text) assertHTTPCode(res, [400]) self.assertIn('百度一下,你就知道', res.text) if __name__ == '__main__': report = REPORT_PATH + '\\report.html' with open(report, 'wb') as f: runner = HTMLTestRunner(f, verbosity=2, title='从0搭建测试框架 灰蓝', description='接口html报告') runner.run(TestBaiDuHTTP('test_baidu_http'))

我们添加断言,响应码在[400]中,执行会发现fail掉了。

在assertion.py中你可以添加更多更丰富的断言,响应断言、日志断言、数据库断言等等,请自行封装。

怎样从0开始搭建一个测试框架_9——抽取器

对接口测试来说,很多时候,我们的用例不是一次请求就OK了的,而是多个请求复合的,我们第二个请求可能会用到第一个请求返回值中的数据,这就要我们再次进行封装,做一个抽取器,从结果中抽取部分信息。 
这里我们会用到JMESPath库,这是一个让我们通过类似于xpath或点分法来定位json中的节点的库

别忘了我们先在ReadMe.md中添加上依赖的库。

我们在utils中创建extractor.py文件,实现对响应中数据的抽取

"""抽取器,从响应结果中抽取部分数据"""

import json
import jmespath


class JMESPathExtractor(object): """ 用JMESPath实现的抽取器,对于json格式数据实现简单方式的抽取。 """ def extract(self, query=None, body=None): try: return jmespath.search(query, json.loads(body)) except Exception as e: raise ValueError("Invalid query: " + query + " : " + str(e)) if __name__ == '__main__': from utils.client import HTTPClient res = HTTPClient(url='http://wthrcdn.etouch.cn/weather_mini?citykey=101010100').send() print(res.text) # {"data": { # "yesterday": {"date": "17日星期四", "high": "高温 31℃", "fx": "东南风", "low": "低温 22℃", "fl": "<![CDATA[<3级]]>", # "type": "多云"}, # "city": "北京", # "aqi": "91", # "forecast": [ # {"date": "18日星期五", "high": "高温 28℃", "fengli": "<![CDATA[<3级]]>", "low": "低温 22℃", "fengxiang": "东北风", # "type": "多云"}, # {"date": "19日星期六", "high": "高温 29℃", "fengli": "<![CDATA[<3级]]>", "low": "低温 22℃", "fengxiang": "东风", # "type": "雷阵雨"}, # {"date": "20日星期天", "high": "高温 29℃", "fengli": "<![CDATA[<3级]]>", "low": "低温 23℃", "fengxiang": "东南风", # "type": "阴"}, # {"date": "21日星期一", "high": "高温 30℃", "fengli": "<![CDATA[<3级]]>", "low": "低温 24℃", "fengxiang": "西南风", # "type": "晴"}, # {"date": "22日星期二", "high": "高温 29℃", "fengli": "<![CDATA[<3级]]>", "low": "低温 24℃", "fengxiang": "北风", # "type": "雷阵雨"} # ], # "ganmao": "各项气象条件适宜,无明显降温过程,发生感冒机率较低。", "wendu": "25" # }, # "status": 1000, # "desc": "OK"} j = JMESPathExtractor() j_1 = j.extract(query='data.forecast[1].date', body=res.text) j_2 = j.extract(query='data.ganmao', body=res.text) print(j_1, j_2) # 结果: # 19日星期六 各项气象条件适宜,无明显降温过程,发生感冒机率较低。

这样我们就完成了对JSON格式的抽取器,如果返回结果是JSON串,我们可以通过这个抽取器找到我们想要的数据,再进行下一步的操作,或者用来做断言。

这里仅仅完成了对JSON格式响应的抽取,之后读者可以自己添加XML格式、普通字符串格式、Header的抽取器,逐步进行完善。

怎样从0开始搭建一个测试框架_10——数据生成器

有时候接口或UI上传入的数据需要符合指定的格式,我们在参数化的过程中又不愿意在excel中一遍遍去构造这样的数据,这时我们可以加入生成器来为我们产生符合某些固定格式的数据。 
这里我推荐一个挺有意思的库,Faker,能够为你产生各种假数据

别忘了在ReadMe.md中添上你要用的库。

在utils中创建一个generator.py,用来生成数据

"""一些生成器方法,生成随机数,手机号,以及连续数字等"""
import random
from faker import Factory

fake = Factory().create('zh_CN') def random_phone_number(): """随机手机号""" return fake.phone_number() def random_name(): """随机姓名""" return fake.name() def random_address(): """随机地址""" return fake.address() def random_email(): """随机email""" return fake.email() def random_ipv4(): """随机IPV4地址""" return fake.ipv4() def random_str(min_chars=0, max_chars=8): """长度在最大值与最小值之间的随机字符串""" return fake.pystr(min_chars=min_chars, max_chars=max_chars) def factory_generate_ids(starting_id=1, increment=1): """ 返回一个生成器函数,调用这个函数产生生成器,从starting_id开始,步长为increment。 """ def generate_started_ids(): val = starting_id local_increment = increment while True: yield val val += local_increment return generate_started_ids def factory_choice_generator(values): """ 返回一个生成器函数,调用这个函数产生生成器,从给定的list中随机取一项。 """ def choice_generator(): my_list = list(values) # rand = random.Random() while True: yield random.choice(my_list) return choice_generator if __name__ == '__main__': print(random_phone_number()) print(random_name()) print(random_address()) print(random_email()) print(random_ipv4()) print(random_str(min_chars=6, max_chars=8)) id_gen = factory_generate_ids(starting_id=0, increment=2)() for i in range(5): print(next(id_gen)) choices = ['John', 'Sam', 'Lily', 'Rose'] choice_gen = factory_choice_generator(choices)() for i in range(5): print(next(choice_gen)) 

你还可以添加各种各样的生成器,比如指定长度中文、英文、特殊字符的字符串,指定格式的json串等等,可以省去很多构造测试数据的烦恼。

怎样从0开始搭建一个测试框架_11——支持方法

框架到这里已经很不错了,后面就需要各位自己去完善了。比如有时候请求需要加密、签名,还有一些支持方法,可以在utils中建个support.py放进去。

在utils中创建一个support.py文件,里面可以放需要的一些支持方法,我们示例一个加密和签名的方法:

"""一些支持方法,比如加密"""
import hashlib
from utils.log import logger


class EncryptError(Exception): pass def sign(sign_dict, private_key=None, encrypt_way='MD5'): """传入待签名的字典,返回签名后字符串 1.字典排序 2.拼接,用&连接,最后拼接上私钥 3.MD5加密""" dict_keys = sign_dict.keys() dict_keys.sort() string = '' for key in dict_keys: if sign_dict[key] is None: pass else: string += '{0}={1}&'.format(key, sign_dict[key]) string = string[0:len(string) - 1] string = string.replace(' ', '') return encrypt(string, salt=private_key, encrypt_way=encrypt_way) def encrypt(string, salt='', encrypt_way='MD5'): u"""根据输入的string与加密盐,按照encrypt方式进行加密,并返回加密后的字符串""" string += salt if encrypt_way.upper() == 'MD5': hash_string = hashlib.md5() elif encrypt_way.upper() == 'SHA1': hash_string = hashlib.sha1() else: logger.exception(EncryptError('请输入正确的加密方式,目前仅支持 MD5 或 SHA1')) return False hash_string.update(string.encode()) return hash_string.hexdigest() if __name__ == '__main__': print(encrypt('100000307111111'))

根据你实际情况的不同,在其中添加其他支持方法。

猜你喜欢

转载自www.cnblogs.com/tester-l/p/9566665.html