我在讲解 Appium 的时候有一篇文章使用到了unittest单元测试框架,从那篇文章就可以看出来这个框架给测试带来的便利。今天做一次比较全面系统的介绍。配以大量的脚本示例,希望可以帮助到更多的朋友。
单元测试就是用一个函数(方法)去验证另一个函数(方法)是否正确。
unittest单元测试框架主要完成以下三件事:
提供用例组织与执行:当测试用例只有几条的时候可以不考虑用例的组织,但是当测试用例数量较多时,此时就需要考虑用例的规范与组织问题了。unittest单元测试框架就是用来解决这个问题的。
提供丰富的比较方法:既然是测试,就有一个预期结果和实际结果的比较问题。比较就是通过断言来实现,unittest单元测试框架提供了丰富的断言方法。
提供丰富的日志:每一个失败用例我们都希望知道失败的原因,所有用例执行结束我们有希望知道整体执行情况,比如总体执行时间,失败用例数,成功用例数。unittest单元测试框架为我们提供了这些数据。
比如我们有一个方法是计算两数之和,我们输入几组数值求和,如果结果与预期结果5相同,那我们就认为这个结果是我们想要的:
import unittest
class Count(object):
def __init__(self,a,b):
self.a = a
self.b = b
# 计算加法
def add(self):
return self.a + self.b
# 单元测试,测试 add()方法
class TestCount(unittest.TestCase):
def setUp(self):
print("Test Start")
def test_add(self):
# 这里参数3,4是我们写死的,实际是可能变化不固定的。比如将读取到的表格内的数据当做这两个参数。
s = Count(3,4)
# 这里的比较对象5就是我们的预期结果,与之不同即为 Fail。
self.assertEqual(s.add(),5)
def tearDown(self):
print("Test end")
if __name__ == '__main__':
unittest.main()
这里我们将待测方法和单元测试写在了一个.py文件里,这样对于日后想要修改带来一定的不便,所以我们常把待测方法和单元测试分开写。
待测试方法:
calculator.py
import unittest
class Count(object):
def __init__(self,a,b):
self.a = a
self.b = b
# 计算加法
def add(self):
return self.a + self.b
单元测试:
test.py
from calculator import Count
# 单元测试必须要引入unittest模块
import unittest
# 测试方法必须要继承自unittest.TestCase
class TestCount(unittest.TestCase):
def setUp(self):
print("Test Start测试开始")
def test_add(self):
s = Count(3,4)
self.assertEqual(s.add(),5)
def tearDown(self):
print("Test end测试结束")
if __name__ == '__main__':
unittest.main()
注意:count.py 和 test.py 要在同一路径下才能 import。
这里要特别说明一点,测试方法必须要以“test_”开头,否则unittest.TestCase不识别,无法进行验证。如果测试方法没有以“test_”开头,则会报错,比如我们将方法名 test_add改为 testadd,执行结果如下:
补充说明:一个.py文件有两种使用方式:直接使用和模块调用。上面的calculator.py文件就是被模块调用,test.py 就是直接使用。脚本最后的 if 语句
if __name__ == '__main__':
unittest.main()
表示当前文件只能直接使用,不能模块调用。在这里把这两行代码去掉也不会影响运行结果。
单元测试的重要概念
1. TestCase
一个TestCase的实例就是一个测试用例。一个测试用例要包括测试前准备环境的搭建(setUp),执行测试代码(run),以及测试后环境的还原(tearDown)。一个测试用例是一个完整的测试单元,通过运行这个测试单元,可以对某一个功能进行验证。
2. TestSuite
对于某一个功能模块的验证可能需要多个测试用例,多个测试用例集合在一起执行验证某一个功能,这样就是一个TestSuite。通过addTest()方法将 TestCase 加载到 TestSuite()中。
3. TestRunner
TestRunner可以使用图形界面、文本界面,或返回一个特殊的值等方式来表示测试执行的结果。TextTestRunner提供的 run()方法来执行 test suite/test case。
4.TestFixture
一个测试用例环境的搭建和销毁就是一个 fixture。
下面我们举例说明,对上面的 test.py 文件进行修改:
在实际操作这个示例的时候遇到一些问题,晚些时候补充!
断言方法
方法 | 检查 |
---|---|
assertEqual(a,b) | a==b |
assertNotEqual(a,b) | a!=b |
assertTrue(x) | bool(x) is True |
assertFalse(x) | bool(x) is False |
assertIs(a,b) | a is b |
assertIsNot(a,b) | a is not b |
assertIn(a,b) | a in b |
assertNotIn(a,b) | a not in b |
举例说明断言的用法:
count.py
def is_prime(n):
if n <= 1:
return False
for i in range(2, n):
if n % i == 0:
print("i ====哈啊哈哈哈=== %s,%s" % (i, n))
print(len(range(2, n)))
return False
print("i ====哈啊哈哈wo哈=== %s,%s" % (i, n))
print(len(range(2, n)))
return True
test2.py
from count import is_prime
import unittest
class Test(unittest.TestCase):
def setUp(self):
print("测试开始")
def tearDown(self):
print("测试结束")
def test_case(self):
self.assertTrue(is_prime(9),msg="is Not Prime")
if __name__ == "__main__":
unittest.main()
组织单元测试用例
当我们新增被测功能和相应的测试用例后,再来看看 unittest 是如何扩展和组织新增的测试用例的。
我们现在对calculator.py文件新增一个函数:
calculator.py
import unittest
class Count(object):
def __init__(self,a,b):
self.a = a
self.b = b
# 计算加法
def add(self):
return self.a + self.b
# 计算减法
def sub(self):
return self.a - self.b
接下来再修改test.py文件:
from calculator import Count
import unittest
class TestCount(unittest.TestCase):
def setUp(self):
print("测试开始")
def tearDown(self):
print("测试结束")
def test_add(self):
s = Count(2,1)
self.assertEqual(s.add(), 3)
print("用例1")
def test_add2(self):
s = Count(2,3)
self.assertEqual(s.add(), 5)
print("用例2")
class TestSub(unittest.TestCase):
def setUp(self):
print("测试减法开始")
def tearDown(self):
print("测试减法结束")
def test_sub(self):
s = Count(4, 1)
self.assertEqual(s.sub(), 3, msg="4 - 1 != 2")
def test_sub2(self):
s = Count(3, 1)
self.assertEqual(s.sub(), 2)
if __name__ == '__main__':
print("到这了")
# 构造测试集
suite = unittest.TestSuite()
suite.addTest(TestCount.test_add())
suite.addTest(TestCount.test_add2())
suite.addTest(TestSub.test_sub())
suite.addTest(TestSub.test_sub2())
# 执行测试
runner = unittest.TextTestRunner()
runner.run(suite)
这个脚本执行的结果是四个函数全都 pass:
现在我们对测试脚本稍加修改,使得结果有成功有失败:
test.py
from calculator import Count
import unittest
class TestCount(unittest.TestCase):
def setUp(self):
print("测试开始")
def tearDown(self):
print("测试结束")
def test_add(self):
s = Count(2,1)
self.assertEqual(s.add(), 3)
print("用例1")
def test_add2(self):
s = Count(2,3)
self.assertEqual(s.add(), 15)
print("用例2")
class TestSub(unittest.TestCase):
def setUp(self):
print("测试减法开始")
def tearDown(self):
print("测试减法结束")
def test_sub(self):
s = Count(4, 1)
self.assertEqual(s.sub(), 2, msg="错误原因:4 - 1 != 2")
def test_sub2(self):
s = Count(3, 1)
self.assertEqual(s.sub(), 2)
if __name__ == '__main__':
print("到这了")
# 构造测试集
suite = unittest.TestSuite()
suite.addTest(TestCount.test_add())
suite.addTest(TestCount.test_add2())
suite.addTest(TestSub.test_sub())
suite.addTest(TestSub.test_sub2())
# 执行测试
runner = unittest.TextTestRunner()
runner.run(suite)
执行结果如下:
从结果我们能够看出来,一共4个用例,其中2个测试通过,2个未能通过,未通过的函数和原因都已经列出。
通过观察上面的脚本还是有重复代码,我们可以继续修改:
from calculator import Count
import unittest
class MyTest(unittest.TestCase):
def setUp(self):
print("测试开始")
def tearDown(self):
print("测试结束")
class TestCount(MyTest):
def test_add(self):
s = Count(2,1)
self.assertEqual(s.add(), 3)
print("用例1")
def test_add2(self):
s = Count(2,3)
self.assertEqual(s.add(), 5)
print("用例2")
class TestSub(MyTest):
def setUp(self):
print("测试减法开始")
def test_sub(self):
s = Count(4, 1)
self.assertEqual(s.sub(), 3, msg="错误原因:4 - 1 != 2")
def test_sub2(self):
s = Count(3, 1)
self.assertEqual(s.sub(), 2)
if __name__ == '__main__':
print("到这了")
# 构造测试集
suite = unittest.TestSuite()
suite.addTest(TestCount.test_add())
suite.addTest(TestCount.test_add2())
suite.addTest(TestSub.test_sub())
suite.addTest(TestSub.test_sub2())
# 执行测试
runner = unittest.TextTestRunner()
runner.run(suite)
上面脚本类test_add和TestSub都继承 MyTest,而 MyTest 又继承unittest.TestCase,所以这两个类也就继承了unittest.TestCase。这样封装的前提是,这两个类都必须在 setUp 和 tearDown 中干的事情是一样的。
我在写这些脚本的时候曾思考过一个问题。就拿我们公司目前已有的测试用例(已经有接近400个)来说,如果都写在 test.py 一个文件里,那这个文件该有多冗余,日后维护起来该何等麻烦,有没有一个更好的办法来组织这些测试用例呢?别说,还真有!其实接下来要介绍的内容在我介绍 Appium 的时候已经使用该方法组织用例了。
首先我们分析一下上面的 test.py 文件不好在哪里。add()和 sub()两个方法分别实现两个不同的功能,为了验证这两个方法,那就需要两个类来实现,如果有更多的功能需要验证,那就需要更多的类,所以我们把这些类都拆分开。每一个函数(方法)作为一个单元测试文件。
testadd.py
from calculator import Count
import unittest
class TestAdd(unittest.TestCase):
def setUp(self):
print()
def tearDown(self):
print()
def test_add(self):
# Count类有两个参数,创建对象的时候要有两个参数。
s = Count(2,4)
self.assertEqual(s.add(), 6, msg="实际结果和预期不符")
def test_add2(self):
s = Count(1, 4)
self.assertEqual(s.add(), 5)
if __name__ == '_main__':
unittest.main()
testsub.py
from calculator import Count
import unittest
class TestSub(unittest.TestCase):
def setUp(self):
print()
def tearDown(self):
print()
def test_sub(self):
# Count类有两个参数,创建对象的时候要有两个参数。
s = Count(10,4)
self.assertEqual(s.sub(), 5, msg="实际结果和预期不符")
def test_sub2(self):
s = Count(19, 14)
self.assertEqual(s.sub(), 5)
if __name__ == '_main__':
unittest.main()
如果只是这样做改变,那么我们验证如果需要验证 add()和 sub()两个方法,就需要分两次执行 testadd.py和 testsub.py 两个文件。现在我们继续组织这两个脚本,使得可以一次执行这两个文件。
test.py
import unittest
# 导入测试文件
import testadd, testsub
# 构造测试集
suite = unittest.TestSuite()
suite.addTest(testadd.TestAdd("test_add"))
suite.addTest(testadd.TestAdd("test_add2"))
suite.addTest(testsub.TestSub("test_sub"))
suite.addTest(testsub.TestSub("test_sub2"))
if __name__ == '__main__':
# 执行测试
runner = unittest.TextTestRunner()
runner.run(suite)
上面这种组织用例的方法要比之前简洁一些,但是当用例更多的时候,我们还需要通过 addTest()方法将新增的用例添加到 test.py 文件中。我们讲解一个可以自动添加的方法。这就是 TestLoader 类中提供的一个 discover()方法。
TestLoader 负责根据各种标准加载测试用例,并将他们返回给测试套件。正常情况下,不需要创建这个类的实例。unittest 提供了可以共享的 defaultTestLoader 类,可以使用其子类和方法创建实例,discover()方法就是这个类中的一个方法之一。
test.py
import unittest
test_dir = './'
discover = unittest.defaultTestLoader.discover(test_dir,pattern='test*.py')
if __name__ == '__main__':
# 执行测试
runner = unittest.TextTestRunner()
runner.run(discover)
这次的test.py可能就是 终极组织用例的方法了。现在我们介绍一下 discover()方法中参数的意思:
test_dir:需要加载的单元测试文件的路径。因为我这里 test.py文件和和各个测试用例在同一路径下,所以
test_dir = './'
。如果不是在同一路径下,就填写绝对路径,比如我的路径就应该是test_dir = /Users/guxuecheng/Desktop/unittest
脚本patten 是一个正则表达式,
pattern='test*.py'
是指加载所有 test 开头的.py 文件,*表示任意多个字符。
discover()方法会自动的根据测试目录(test_dir)匹配查找测试用例文件(test*.py),并将查找到的测试用例组装到测试套件 suite 中,因此可以通过run()方法执行 discover。
粗暴的解释一下最后一段代码的意思:
通俗的理解name == 'main':假如你叫小明.py,在朋友眼中,你是小明(name == '小明');在你自己眼中,你是你自己(name == 'main')。
if name == 'main'的意思是:当.py文件被直接运行时,if name == 'main'之下的代码块将被运行;当.py文件以模块形式被导入时,if name == 'main'之下的代码块不被运行。
在网上看到了一个博主的文章,给我提供了一个新的思路,感觉很不错,在此感谢博主灰蓝,将文章搬到这篇文章里,方便日后翻阅:
先准备一些待测方法,这些方法没有组织到一个类里,也没有初始化参数,很简练:
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()
组织 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_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格式的测试报告了。
进阶——用HTMLTestRunner输出漂亮的HTML报告
我们能够输出txt格式的文本执行报告了,但是文本报告太过简陋,是不是想要更加高大上的HTML报告?但unittest自己可没有带HTML报告,我们只能求助于外部的库了。
官方原版:http://tungwaiyip.info/software/HTMLTestRunner.html
我修改后的: https://pan.baidu.com/s/1kV64YZ9
修改我们的 test_suite.py:
# -*- coding: utf-8 -*-
import unittest
from test_mathfunc import TestMathFunc
from HtmlTestRunner import HTMLTestRunner
if __name__ == '__main__':
suite = unittest.TestSuite()
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestMathFunc))
with open('HTMLReport.html','w') as f:
runner = HTMLTestRunner(stream=f,
report_title='Test Report',
descriptions='Report Details',
verbosity=2,
output='report',
)
runner.run(suite)
总结一下:
- unittest是Python自带的单元测试框架,我们可以用其来作为我们自动化测试框架的用例组织执行框架。
- unittest的流程:写好TestCase,然后由TestLoader加载TestCase到TestSuite,然后由TextTestRunner来运行TestSuite,运行的结果保存在TextTestResult中,我们通过命令行或者unittest.main()执行时,main会调用TextTestRunner中的run来执行,或者我们可以直接通过TextTestRunner来执行用例。
- 一个class继承unittest.TestCase即是一个TestCase,其中以 test 开头的方法在load时被加载为一个真正的TestCase。
- verbosity参数可以控制执行结果的输出,0 是简单报告、1 是一般报告、2 是详细报告。
- 可以通过addTest和addTests向suite中添加case或suite,可以用TestLoader的loadTestsFrom__()方法。
- 用 setUp()、tearDown()、setUpClass()以及 tearDownClass()可以在用例执行前布置环境,以及在用例执行后清理环境
- 我们可以通过skip,skipIf,skipUnless装饰器跳过某个case,或者用TestCase.skipTest方法。
- 参数中加stream,可以将报告输出到文件:可以用TextTestRunner输出txt报告,以及可以用HTMLTestRunner输出html报告。