本文属于文档摘要(中英文对照看,有些内容确实翻译得很生硬,有些词也是不翻译更通俗易懂),同时也会加上一些个人的理解,纯当学习了哈。

单元测试的重要性

完整版本: 单元测试是软件开发过程中的一个基本实践,它的重要性体现在以下几个方面:

  1. 早期错误检测:单元测试允许开发者在代码开发的早期阶段发现和修复错误。这有助于减少错误在软件生命周期中的传播,从而节省了在后期发现和修复这些错误所需的时间和成本。
  2. 提高代码质量:通过编写单元测试,开发者被迫更仔细地思考他们的代码结构和逻辑。这种思考过程往往能导致代码质量的提高。
  3. 设计改善:编写单元测试可以促使开发者设计出更加模块化和松耦合的代码,因为这样的代码更易于测试。
  4. 文档化:单元测试可以作为一种形式的文档,展示函数或方法应该如何被使用,以及它们预期的行为。这有助于其他开发者理解代码的意图和功能。
  5. 持续集成和部署:单元测试是持续集成(CI)和持续部署(CD)过程的关键组成部分。自动化的单元测试可以在代码更改后立即运行,确保新的代码更改没有破坏现有的功能。
  6. 重构安全网:当需要进行代码重构以改善性能或可维护性时,单元测试提供了一个安全网,确保重构过程没有引入新的错误。
  7. 信心和速度:有了可靠的单元测试套件,开发者可以更有信心地进行代码更改,因为他们可以快速验证这些更改是否导致了预期的结果。
  8. 团队协作:单元测试有助于团队协作,因为它们为代码的质量设置了一个明确的基准。这使得代码审查更加高效,因为审查者可以专注于代码的设计和结构,而不是基本的正确性。 总之,单元测试是确保软件质量、可靠性和可维护性的关键实践。它们为开发者提供了一种方法,可以在不依赖于手动测试或全面集成测试的情况下,频繁且快速地验证代码的正确性。

简要版本:

单元测试是确保代码质量、可靠性和可维护性的关键实践。它能早期发现和修复错误,提高代码质量,促进更好的设计,并作为文档和持续集成的安全网,增强开发者信心,加快开发速度。

以上总结内容生成自智谱清言。

我个人的观点:单元测试拆分成单元和测试,单元意味着被测的对象要足够小,这样才能被称为一个单元,这一定程度上也就是上面提到的2,3,也是 SOLID 原则的部分体现;而测试意味着你可以在代码发布到生产环境之前,或者是更早,就能提前运行你的代码,而且是按照你所设想的方式,无论是异常的方式,亦或是正常的方式,这无疑也能提升我们对代码质量的信心。在《重构》一书中,作者提到了:

每当我要进行重构的时候,第一个步骤永远相同:我得确保即将修改的代码拥有一组可靠的测试。 … 进行重构时,我需要依赖测试。我将测试视为 bug 检测器。 … 把我想要 达成的目标写两遍——代码里写一遍,测试里再写一遍——我就得犯两遍同样的错误才能骗过检测器。

所以单元测试其实是伴随业务代码一整个生命周期的。

  • 刚开始创建代码的时候,写 UT 为了自行测试程序逻辑是否符合预期
  • 重构时,UT 能够确保重构后的结果没有影响到程序,即重构前后,程序在外部观测下的行为没有发生改变。
  • 协作时,UT 也能够作为一种让其他人了解某段程序意图的渠道。

unittest — Unit testing framework

基本概念

最最最基本的一个示例

# 首先要导入unittest 的包,这样才能使用它。
import unittest

# 继承unittest.TestCase,这样就能使用该父类的方法
#  比如assertTrue、assertEqual、assertRaises
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)

# `unittest.main()`提供了一个测试脚本的命令行接口
#   比如 添加 -v 参数使 unittest.main() 显示更为详细的信息,生成如以下形式的输出:
if __name__ == '__main__':
    unittest.main()

继承 unittest.TestCase 就创建了一个测试样例,即TestStringMethods就是一个测试样例。 这看起来和我平时所说的测试用例不同,我一般是将一个单独的 test_方法,比如上面的test_split称为一个测试用例。而这里的概念中,测试用例(test case)似乎是一组测试(tests)的集合。换句话说,测试用例与测试的关系是1对 n 的。

上述三个独立的测试是三个类的方法,这些方法的命名都以 test 开头。 这个命名约定告诉测试运行者类的哪些方法表示测试。

每个测试的关键都是:调用assertXXX系列的函数来检查实际输出是否与预期一致。而这一系列的 assertXXX 函数都是 unittest 提供的,而非python原生的 assert 语句。因为原生 assert 语句会抛出AssertionError异常,通常情况下会导致程序终止。而通常在单元测试中,有些异常是期望的,有些异常即使不是期望的,我们也希望测试程序能继续进行下去,收集到所有的测试结果并产生测试报告,所以要使用 assertXXX 语句。

组织测试代码

TestCase 的最简单的子类需要实现一个测试方法(例如一个命名以 test 开头的方法)以执行特定的测试代码:

import unittest

class DefaultWidgetSizeTestCase(unittest.TestCase):
    def test_default_widget_size(self):
        widget = Widget('The widget')
        self.assertEqual(widget.size(), (50, 50))

请注意为了进行测试,我们使用了 TestCase 基类提供的某个 assert* 方法。 如果测试不通过,将会引发一个异常并附带解释性的消息,并且 unittest 会将这个测试用例标记为 failure。 任何其它exceptions都将被视为 errors

setUp: 提取出测试中相同的前置操作

可能同时存在多个前置操作相同的测试,我们可以把测试的前置操作从测试代码中拆解出来,并实现测试前置方法 setUp() 。 在运行测试时,测试框架会自动地为每个单独测试调用前置方法。也就是说每个test_*方法运行前,setUp都会被调用一次。

import unittest

class WidgetTestCase(unittest.TestCase):
    def setUp(self):
        self.widget = Widget('The widget')

    def test_default_widget_size(self):
        self.assertEqual(self.widget.size(), (50,50),
                         'incorrect default size')

    def test_widget_resize(self):
        self.widget.resize(100,150)
        self.assertEqual(self.widget.size(), (100,150),
                         'wrong size after resize')

多个测试运行的顺序由内置字符串排序方法对测试名进行排序的结果决定。 在测试运行时,若 setUp() 方法引发异常,测试框架会认为测试发生了错误,因此测试方法不会被运行。

相似的,我们提供了一个 tearDown() 方法在测试方法运行后进行清理工作。

tearDown: 提取出测试中相同的清理工作

import unittest

class WidgetTestCase(unittest.TestCase):
    def setUp(self):
        self.widget = Widget('The widget')

    def tearDown(self):
        self.widget.dispose()

若 setUp() 成功运行,无论测试方法是否成功,都会运行 tearDown()

这样的一个测试代码运行的环境被称为 test fixture 。一个新的 TestCase 实例作为一个测试脚手架,用于运行各个独立的测试方法。在运行每个测试时,setUp() 、tearDown() 和 __init__() 会被调用一次。

测试套件 Test Suite

==建议根据测试(tests)所测的功能,使用 TestCase 进行分组。==

unittest 为此提供了 test suiteunittest 的 TestSuite 类是一个代表。 通常情况下,调用 unittest.main() 就能正确地找到并执行这个模块下所有用 TestCase 分组的测试。

然而,如果需要自定义测试套件的话,你可以参考以下方法组织测试:

def suite():
    suite = unittest.TestSuite()
    suite.addTest(WidgetTestCase('test_default_widget_size'))
    suite.addTest(WidgetTestCase('test_widget_resize'))
    return suite

if __name__ == '__main__':
    runner = unittest.TextTestRunner()
    runner.run(suite())

需要提一嘴的是,addTest/addTests 不仅仅能用于添加 TestCase,也能添加 TestSuite

测试代码和被测试代码是否要放在一起

虽然你可以把测试用例和测试套件放在与被测试代码相同的模块中(比如 widget.py),但将测试代码放在单独的模块中(比如 test_widget.py)有以下优势:

  • 测试模块可以从命令行被独立调用。
  • 更容易在分发的代码中剥离测试代码。
  • 降低没有好理由的情况下修改测试代码以通过测试的诱惑。
  • 测试代码应比被测试代码更少地被修改。
  • 被测试代码可以更容易地被重构。
  • 对用 C 语言写成的模块无论如何都得单独写成一个模块,为什么不保持一致呢?
  • 如果测试策略发生了改变,没有必要修改源代码。

 FunctionTestCase:复用已有的测试代码(不推荐)

一些用户希望直接使用 unittest 运行已有的测试代码,而不需要把已有的每个测试函数转化为一个 TestCase 的子类。

因此, unittest 提供 FunctionTestCase 类。这个 TestCase 的子类可用于打包已有的测试函数,并支持设置前置与后置函数。

假定有一个测试函数:

def testSomething():
    something = makeSomething()
    assert something.name is not None
    # ...

可以创建等价的测试用例如下,其中前置和后置方法是可选的。

testcase = unittest.FunctionTestCase(testSomething,
                                    setUp=makeSomethingDB,
                                    tearDown=deleteSomethingDB)

备注:用 FunctionTestCase 可以快速将现有的测试转换成基于 unittest 的测试,但不推荐你这样做。花点时间继承 TestCase 会让以后重构测试无比轻松

skip* 跳过测试

Unittest 支持跳过单个或整组的测试用例。 要跳过测试只需使用 skip() decorator 或其附带条件的版本,在 setUp() 内部使用 TestCase.skipTest(),或是直接引发 SkipTest

跳过测试的基本用法如下,前三个测试用的是不同的装饰器,最后一个是直接调用self.skipTest:

class MyTestCase(unittest.TestCase):

    @unittest.skip("demonstrating skipping")
    def test_nothing(self):
        self.fail("shouldn't happen")

    @unittest.skipIf(mylib.__version__ < (1, 3),
                     "not supported in this library version")
    def test_format(self):
        # Tests that work for only a certain version of the library.
        pass

    @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
    def test_windows_support(self):
        # windows specific testing code
        pass

    def test_maybe_skipped(self):
        if not external_resource_available():
            self.skipTest("external resource not available")
        # test code that depends on the external resource
        pass

在啰嗦模式(verbose mode)下运行以上测试例子时,程序输出如下:

test_format (__main__.MyTestCase.test_format) ... skipped 'not supported in this library version'
test_nothing (__main__.MyTestCase.test_nothing) ... skipped 'demonstrating skipping'
test_maybe_skipped (__main__.MyTestCase.test_maybe_skipped) ... skipped 'external resource not available'
test_windows_support (__main__.MyTestCase.test_windows_support) ... skipped 'requires Windows'

----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK (skipped=4)

跳过测试类的写法跟跳过测试方法的写法相似:

@unittest.skip("showing class skipping")
class MySkippedTestCase(unittest.TestCase):
    def test_not_run(self):
        pass

expectedFailure:预计的失败

如果测试失败或者在测试函数自身出现错误,则测试成功; 如果测试通过,则认为是测试失败。

可以认为是对测试结果取反。

subTest:区分测试(test)内部的迭代子测试(subtests)

class NumbersTest(unittest.TestCase):

    def test_even(self):
        """
        Test that numbers between 0 and 5 are all even.
        """
        for i in range(0, 6):
            with self.subTest(i=i):
                self.assertEqual(i % 2, 0)

剩下的部分就是对于类和方法的介绍了,有兴趣的可以继续深入了解一下。