Pyunit测试框架

1. 基本概念

unittest是python自带的单元测试框架,有时候又被称为"PyUnit",是python版本的JUint实现。

该框架的作者是 Kent Beck和Erich Gamma,感谢祖师爷赏饭吃。

在学习使用unittest库之前,我们需要了解一下unittest库的一些重要概念:

  • test fixture: 代表了用例执行前的准备工作和用例执行之后的清理工作。比如在用例执行前创建临时文件和文件夹,又或者启动1个server进程等;

  • test case: 测试用例,这个相信大家都不陌生。是测试的最小单位,一般检查一组输入的响应(输出)是否符合预期。unittest模块提供了TestCase类来帮助我们创建测试用例;

  • test suite: 经常被翻译成"测试套件",也有人称为"测试套",是测试用例或测试套件的集合,一般用来把需要一起执行的用例组合到一起;

  • test runner: 用来执行测试用例并输出测试结果的组件。可以是图形界面或命令行界面;

总之

  • test fixture的功能可以理解成是初始化和清理测试数据及环境
  • test case是测试用例
  • test suite是用例集合
  • test runner的作用是运行用例并返回结果

 

2. 基本用法

我们通过最简单的例子来看一下unittest的基本用法,下面的代码测试了3个python字符串方法,基本上满足了大部分情况下的测试需求

import unittest

class TestStringMethods(unittest.TestCase):

    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world'])
        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)

if __name__ == '__main__':
    unittest.main()

解释一下关键点

  • 可以通过继承unittest.TestCase类来定义我们自己的测试用例,1个测试用例类下面可以有多个测试方法(test)或者叫做测试点

  • 记住这个套路:测试用例中方法名以test开头的方法才是测试方法,比如上面的例子里定义了3个以test开头的方法,分别是test_uppertest_isuppertest_split。非测试方法是不会被test runner执行的

  • 断言是测试用例的核心。我们使用assertEqual()来判断预期结果,用assertTrue()assertFalse来做是非判断,以及用assertRaises()来判断预期的异常是否有被抛出。这些unittest提供的以assert开头的方法就是断言,一般情况下,每个测试方法里都必须有断言

  • 最后, unittest.main提供了最简单的运行用例的方式。当我们从命令行运行上面的代码时,我们可以看到如下的输出

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

除了使用unittest.main,还有其他的方式可以运行测试用例,比如把最后2行替换为

suite = unittest.TestLoader().loadTestsFromTestCase(TestStringMethods)
unittest.TextTestRunner(verbosity=2).run(suite)

运行用例,结果将会如下所示

test_isupper (__main__.TestStringMethods) ... ok
test_split (__main__.TestStringMethods) ... ok
test_upper (__main__.TestStringMethods) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK


3. 实例: 测试弱密码

 

背景

考虑这样一个测试弱密码的实例,这个我们在pytest相关教程中也有过描述。

我们需要判断用户的密码中包含简单密码,规则是这样的,密码必须至少6位,满足6位的话判断用户的密码不是password123或者password之类的弱密码。

对于如下的测试数据,我们要如何使用unittest来进行相关测试呢?

[
  {"name":"jack","password":"Iloverose"},
  {"name":"rose","password":"Ilovejack"},
  {"name":"tom","password":"password123"}
]

Test fixture

前文我们也说过,text fixture的主要功能是初始化测试数据或环境以及清理测试数据或环境。

考虑上面的例子,对我们而已,在用例执行之前初始化上面的测试数据是有必要的,我们可以把上面的数据用python的数据结构来表示,比较合适的数据结构是python的字典。这样做有如下的好处

  • 统一初始化一些需要在多个用例之间共享的数据
  • 可以在初始化的时候做一些数据的处理工作,比如过滤一些无效数据等

Test fixture最简单的实现方式是通过自定义下面的2个方法:

  • TestCase.setUp方法在每个测试方法运行之前都会运行一次,适合为每个用例都初始化一遍数据
  • TestCase.tearDown方法在每个测试方法运行之后都会运行一次,适合为每个用例都清理一遍数据

代码

新建名为test_password_1.py的文本文件,输入如下内容

import unittest

class PasswordTeseCase(unittest.TestCase):

    def setUp(self):
        print('set up')
        self.test_data = [
            dict(name='jack', password='Iloverose'),
            dict(name='rose', password='Ilovejack'),
            dict(name='tom', password='password123')
        ]

    def test_week_password(self):
        for data in self.test_data:
            passwd = data['password']

            self.assertTrue(len(passwd) >= 6)

            msg = "user %s has a weak password" %(data['name'])
            self.assertTrue(passwd != 'password', msg)
            self.assertTrue(passwd != 'password123', msg)

    def test_dummy(self):
        pass

if __name__ == '__main__':
    unittest.main()

运行

在命令行里输入 python test_password_1.py来运行用例,结果如下

$ python test_password_1.py
set up
.set up
F
======================================================================
FAIL: test_week_password (__main__.PasswordTeseCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_password_1.py", line 21, in test_week_password
    self.assertTrue(passwd != 'password123', msg)
AssertionError: False is not true : user tom has a weak password

----------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

解释一下

  • setUp方法运行了2次,所以打印出了2次'set up',这是因为上面的用例中有2个测试方法(2个方法名以test开头的方法),setUp会在每个测试方法执行之前执行1次

  • 由于用户tom的密码是弱密码password123,所以上面的用例运行失败了,打印出1个"F",有几个F就代表有几个测试用例失败

  • 测试方法运行失败时测试结果里会打印出失败方法的方法名,因此好的测试方法名可以方便我们快速找出失败用例

  • 为了让错误信息更加容易理解,我们经常会自定义断言出错提示消息,比如msg = "user %s has a weak password" %(data['name'])。一旦断言失败,我们一眼就能看出是哪个用户的密码强度不够

亲自动手试一试

假设我们增加1条测试数据,如下所示

[
  {"name":"jack","password":"Iloverose"},
  {"name":"rose","password":"Ilovejack"},
  {"name":"tom","password":"password123"},
  {"name":"jerry","password":"password"}
]

再运行上面的用例,观察一下测试结果是否会有不同?如果没有不同,那是为什么?

 

4. 实例: 读取测试数据并测试弱密码

 

背景

接上一节的弱密码例子,我们的用例尽管运行的不错,但还是有点问题。

假如我们需要增加一些测试数据,那么我们就必须去修改setUp方法,在test_data列表中增加数据,频繁修改代码以适应一些不变的测试场景,这是没有必要的开销,可以想办法去优化。

我们可以把测试数据保存在文件里,通过读取文件的方式,每次动态从测试用例读取数据,这样数据的改变并不会影响用例,用例逻辑相对稳定,维护成本得到一定的降低。

设计测试数据

我们可以把测试数据保存成json格式,json格式的数据在各个语言间有较好的通用性,比较适合复用。

新建user_data.json文件,内容如下

[
  {"name":"jack","password":"Iloverose"},
  {"name":"rose","password":"Ilovejack"},
  {"name":"tom","password":"password123"}
]

使用python的json库解析上面的json文件,可以得到如上节中test_data一致的数据。

代码

新建test_password_2.py,内容如下

import unittest
import json

class PasswordWithJsonTeseCase(unittest.TestCase):
    data_file_path = './user_data.json'

    def setUp(self):
        print('set up')
        self.test_data = json.loads(open(self.data_file_path).read())

    def test_week_password(self):
        for data in self.test_data:
            passwd = data['password']

            self.assertTrue(len(passwd) >= 6)

            msg = "user %s has a weak password" %(data['name'])
            self.assertTrue(passwd != 'password', msg)
            self.assertTrue(passwd != 'password123', msg)

    def test_dummy(self):
        pass

if __name__ == '__main__':
    unittest.main()

跟上一节相比,最大的不同点是现在test_data通过解析json文件的方式来赋值self.test_data = json.loads(open(self.data_file_path).read())

执行测试文件,结果应该与上一节一致。

发现问题

上面的代码有2个测试方法:test_week_passwordtest_dummy。由于setUp会在每个测试方法执行之前执行一次,那么setUp方法会执行2次,相应的json文件也会读取2次。如果测试方法多的话,那么反复读取json文件对性能来说是一个巨大的挑战。

优化

对于上面的测试数据读取场景,我们可以在所有测试方法执行前读取一次数据,毕竟测试数据在所有测试方法执行过程中是保持不变的。

setUpClass()和tearDownClass()

  • setUpClass方法在每个测试用例类执行之前会执行一次,接收该class作为唯一的参数,并且必须使用装饰器classmethod()
  • tearDownClass: 在所有测试方法执行完之后被调用1次,调用方式跟上面的方法类似

重构

下面我们重构代码以达到只读取1次测试数据的目的,新建文件`test_password_3.py,内容如下

import unittest
import json

class WeakPasswordTeseCase(unittest.TestCase):

    @classmethod
    def setUpClass(kls):
        data_file_path = './user_data.json'
        print('before all test methods')
        with open(data_file_path) as f:
            kls.test_data = json.loads(f.read())

    def test_week_password(self):
        for data in self.test_data:
            passwd = data['password']

            self.assertTrue(len(passwd) >= 6)

            msg = "user %s has a weak password" %(data['name'])
            self.assertTrue(passwd != 'password', msg)
            self.assertTrue(passwd != 'password123', msg)

    def test_dummy(self):
        pass

if __name__ == '__main__':
    unittest.main()

有几点需要提及一下

  • 使用open 方法的with模式可以在读取文件后自动关闭文件
  • 在setUpClass方法中可以直接设置变量,比如kls.test_data = json.loads(f.read()),在其他测试方法中可以被访问

 

5. 实例: 找出所有是弱密码的用户

 

背景

当我们的测试数据是下面这些的时候,我们的用例是有问题的。

[
  {"name":"jack","password":"Iloverose"},
  {"name":"rose","password":"Ilovejack"},
  {"name":"tom","password":"password123"},
  {"name":"jerry","password":"password"}
]

我们的用例只能找出tom是弱密码的用户,jerry这个用户会成为漏网之鱼。

为什么

这是因为在unittest中,一旦某个测试方法中的断言失败,后续的断言都不会被执行。

还原一下上面的例子,当用例在断言tom失败后,for循环就退出了,测试方法也执行完毕了,后面jerry这条数据就不会被断言。

怎么办

我们可以重构一下我们的用例,让整个用例只断言1次,断言失败以后就把弱密码的用户打印出来。

代码

修改user_data.json文件,加入一些测试数据,修改后的user_data.json文件应该是

[
  {"name":"jack","password":"Iloverose"},
  {"name":"rose","password":"Ilovejack"},
  {"name":"tom","password":"password123"},
  {"name":"jerry","password":"password"},
  {"name":"fred","password":"123456"},
  {"name":"elma","password":"654321"}
]

新建test_password_4.py文件,内容如下


import unittest
import json

class WeakPasswordTeseCase1(unittest.TestCase):

    @classmethod
    def setUpClass(kls):
        data_file_path = './user_data.json'
        print('before all test methods')
        with open(data_file_path) as f:
            kls.test_data = json.loads(f.read())

    def test_week_password(self):
        res = True
        msg = []
        for data in self.test_data:
            passwd = data['password']
            tmp_res = True

            tmp_res = tmp_res and (len(passwd) >= 6)
            tmp_res = tmp_res and (passwd != 'password')
            tmp_res = tmp_res and (passwd != 'password123')
            if not tmp_res:
                msg.append("user %s has a weak password %s" %(data['name'], data['password']))
            res = res and tmp_res

        self.assertTrue(res, "\n".join(msg))

    def test_dummy(self):
        pass

if __name__ == '__main__':
    unittest.main()

运行及结果

在命令行中运行python test_password_4.py,结果如下

$ python test_password_4.py
before all test methods
.F
======================================================================
FAIL: test_week_password (__main__.WeakPasswordTeseCase1)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_password_4.py", line 27, in test_week_password
    self.assertTrue(res, "\n".join(msg))
AssertionError: user tom has a weak password password123
user jerry has a weak password password

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

我们能学到什么

  • 断言一旦失败之后测试方法就会结束运行,所以一般来说1个测试方法推荐只有1个断言
  • 如果一个测试方法里面必须要有多个断言,那么要确保前面的断言失败之后,后面的断言就算不运行也不会影响测试的范围和结果
  • for循环中的断言一旦失败,for循环就退出了
  • 上面演示的测试用例写法其实具备了一定的数据驱动测试的思想

 

 

6. 命令行接口

 

背景

unittest支持命令行接口,我们可以在命令行里指定运行具体的测试用例。

实例

test_password_1.py中定义了PasswordTeseCase用例,我们可以从命令行中指定只运行该用例。

$ python -m unittest test_password_1.PasswordTeseCase
set up
.set up
F
======================================================================
FAIL: test_week_password (test_password_1.PasswordTeseCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/easonhan/code/testclass.net/src/pyunit/test_password_1.py", line 21, in test_week_password
    self.assertTrue(passwd != 'password123', msg)
AssertionError: False is not true : user tom has a weak password

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

还可以使用-v参数来获得更详细的输出

$ python -m unittest test_password_1.PasswordTeseCase -v
test_dummy (test_password_1.PasswordTeseCase) ... set up
ok
test_week_password (test_password_1.PasswordTeseCase) ... set up
FAIL

======================================================================
FAIL: test_week_password (test_password_1.PasswordTeseCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/easonhan/code/testclass.net/src/pyunit/test_password_1.py", line 21, in test_week_password
    self.assertTrue(passwd != 'password123', msg)
AssertionError: False is not true : user tom has a weak password

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

也可以在命令行中一次指定多个测试用例类,具体的大家可以自己尝试一下。

 

7. 各种断言方法

 

背景

unittest支持各种断言方法。

断言列表

官方文档:参考https://docs.python.org/2/library/unittest.html

方法检查点
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
assertIsNone(x) x is None
assertIsNotNone(x) x is not None
assertIn(a, b) a in b
assertNotIn(a, b) a not in b
assertIsInstance(a, b) isinstance(a, b)
assertNotIsInstance(a, b) not isinstance(a, b)
assertRaises(exc, fun, *args, **kwds) fun(*args, **kwds) raises exc
assertRaisesRegexp(exc, r, fun, *args, **kwds) fun(*args, **kwds) raises exc and the message matches regex r
assertAlmostEqual(a, b) round(a-b, 7) == 0
assertNotAlmostEqual(a, b) round(a-b, 7) != 0
assertGreater(a, b) a > b 2.7
assertGreaterEqual(a, b) a >= b
assertLess(a, b) a < b
assertLessEqual(a, b) a <= b
assertRegexpMatches(s, r) r.search(s)
assertNotRegexpMatches(s, r) not r.search(s)
assertItemsEqual(a, b) sorted(a) == sorted(b) 也支持unhashable对象
assertDictContainsSubset(a, b) a里面所有的键值对都在b中存在

自己动手

大家可以自己动手亲自尝试使用这些断言方法。

 

 

8. 断言异常

 

背景

我们有时候需要断言一些方法会抛出异常,这些异常需要符合我们的预期。

代码

新建test_exception.py文件,内容如下

import unittest

class DivZeroTestCase(unittest.TestCase):

    def test_should_raise_exception(self):
        with self.assertRaises(ZeroDivisionError):
            1 / 0

if __name__ == '__main__':
    unittest.main()

运行及结果

$ python test_exception.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

我们能学到什么

  • 上面的例子断言了当0作为除数的时候会抛出ZeroDivisionError
  • 断言异常是有套路的,使用with语句加assertRaises,assertRaises的参数中传入预期的异常(这些异常可能需要先import进来),在with的子句中放上会抛出异常的语句或表达式

 

 

posted on 2019-03-15 09:58  Ellison2019  阅读(403)  评论(0编辑  收藏  举报

导航