测试驱动开发 (TDD) 是近年来备受关注的概念。这是一种将测试直接融入日常编码的做法,而不是事后才考虑。
在这篇文章中,我将介绍 TDD 的核心概念。
整个过程非常容易掌握,用不了多久你就会想知道以前是如何完成任何事情的!TDD 可以带来巨大的收益——即提高代码质量,同时清晰并专注于您要实现的目标以及实现目标的方式。TDD 还可以与敏捷开发无缝协作,并且可以在结对编程时得到最佳利用,正如您稍后将看到的那样。
在本教程中,我将介绍 TDD 的核心概念,并使用 nosetests 单元测试包提供 python 示例。我将另外提供一些在 Python 中也可用的替代包。
什么是测试驱动开发?
TDD,在其最基本的术语中,是通过首先编写测试来实现代码的过程,看到它们失败,然后编写代码使测试通过。然后,您可以在编写代码使其再次通过之前,通过适当更改测试以期望获得附加功能的结果来构建此开发代码。
您可以看到 TDD 是一个非常大的循环,您的代码会根据需要经历尽可能多的测试、编写和开发迭代,直到功能完成。在编写代码之前实施这些测试会让人自然而然地倾向于首先考虑您的问题。在开始构建测试时,您必须考虑设计代码的方式。这个方法会返回什么?如果我们在这里遇到异常怎么办?等等。
以这种方式进行开发意味着您要考虑通过代码的不同路径,并根据需要用测试覆盖这些路径。这种方法可以让您摆脱许多开发人员(包括我自己)掉入的陷阱:深入研究一个问题并专门为您需要处理的第一个解决方案编写代码。
该过程可以定义为:
写一个失败的单元测试
使单元测试通过
重构
根据需要对每个功能重复此过程。
敏捷开发与测试驱动开发
TDD 与敏捷开发的理念和原则完美契合,专注于以真正的质量而不是数量为产品提供增量更新。单元测试提供的对您的各个代码单元的信心意味着您满足交付质量的要求,同时消除生产环境中的问题。
然而,TDD 在结对编程时发挥了作用。在您认为合适的情况下结对工作时,能够混合您的开发工作流程,这很好。例如,一个人可以编写单元测试,看到它通过,然后让另一个开发人员编写代码使测试通过。
可以根据需要每次、每半天或每天切换角色。这意味着双方都参与进来,专注于他们正在做的事情,并在每个阶段检查对方的工作。这在任何意义上都意味着这种方法的胜利,我想你会同意。
TDD 还构成了行为驱动开发过程的一个组成部分,这也是预先编写测试,但采用验收测试的形式。这些确保功能从头到尾都按照您期望的方式运行。
单元测试的语法
我们在 Python 单元测试中使用的主要方法是:
assert: base assert 允许你编写自己的断言
assertEqual(a, b):检查a和b是否相等
assertNotEqual(a, b):检查a和b不相等
assertIn(a, b):检查a项目中的b
assertNotIn(a, b):检查a不在项目中b
assertFalse(a):检查值a是False
assertTrue(a):检查值a是True
assertIsInstance(a, TYPE):检查a类型TYPE
assertRaises(ERROR, a, args):检查何时a调用 with args,它会引发ERROR
我们当然可以使用更多方法,您可以查看这些方法——请参阅Python 单元测试文档——但根据我的经验,上面列出的方法是最常用的方法。我们将在下面的示例中使用这些。
安装和使用 Python 的鼻子
在开始下面的练习之前,您需要安装nosetest测试运行程序包。runner的安装nosetest很简单,遵循标准pip安装模式。使用 virtualenv 处理您的项目通常也是一个好主意,它可以将您用于不同项目的所有包分开。如果您不熟悉 pip 或 virtualenv,您可以在这里找到关于它们的文档:VirtualEnv、PIP。
pip 安装就像运行此行一样简单:
pip install nose
安装后,您可以执行单个测试文件。
nosetests example_unit_test.py
或者在一个文件夹中执行一套测试。
nosetests /path/to/tests
您需要遵循的唯一标准是开始每个测试的方法,以test_确保 nosetest runner 可以找到您的测试!
选项
您可能希望记住的一些有用的命令行选项包括:
-v:提供更详细的输出,包括正在执行的测试的名称。
-sor -nocapture:允许打印语句的输出,这些语句通常在执行测试时被捕获和隐藏。对调试很有用。
--nologcapture:允许输出日志信息。
--rednose:一个可选的插件,可以在这里下载,但为测试提供彩色输出。
--tags=TAGS:允许您在特定测试之上放置一个@TAG以仅执行这些测试,而不是整个测试套件。
示例问题和测试驱动方法
我们将通过一个非常简单的示例来介绍 Python 中的单元测试和 TDD 的概念。我们将编写一个非常简单的计算器类,其中包含加法、减法和其他您期望的简单方法。
按照 TDD 方法,假设我们有一个add函数需求,它将确定两个数字的总和并返回输出。让我们为此编写一个失败的测试。
在一个空项目中,创建两个 Python 包app和test. 为了使它们成为 Python 包(从而支持稍后在测试中导入文件),在每个目录中创建一个名为__init__.py的空文件。这是 Python 的项目标准结构,必须遵循以允许跨目录结构导入项。为了更好地理解这种结构,您可以参考Python 包文档。使用以下内容在测试目录中创建名为test_calculator.py的文件。
1 import unittest 2 3 class TddInPythonExample(unittest.TestCase): 4 def test_calculator_add_method_returns_correct_result(self): 5 calc = Calculator() 6 result = calc.add(2,2) 7 self.assertEqual(4, result)
编写测试相当简单。
unittest首先,我们从 Python 标准库中导入标准模块。
接下来,我们需要一个类来包含不同的测试用例。
最后,测试本身需要一个方法,唯一的要求是它以“test_”开头命名,以便运行器可以拾取并执行它,我们将在稍后介绍nosetest。
有了结构,我们就可以编写测试代码了。我们初始化我们的计算器,以便我们可以在其上执行方法。在此之后,我们可以调用add我们希望测试的方法,并将其值存储在变量中result。完成后,我们就可以使用 unittest 的assertEqual方法来确保我们的计算器add方法按预期运行。
现在您将使用nosetest运行器来执行测试。如果愿意,您可以使用标准运行程序执行测试unittest,方法是将以下代码块添加到测试文件的末尾。
1 if __name__ == '__main__': 2 unittest.main()
这将允许您使用执行 Python 文件的标准方式python test_calculator.py来运行测试。但是,对于本教程,您将使用 nosetests 运行程序,它具有一些不错的功能,例如针对目录执行 nose 测试并运行所有测试。
1 $ nosetests test_calculator.py 2 E 3 ====================================================================== 4 ERROR: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample) 5 ---------------------------------------------------------------------- 6 Traceback (most recent call last): 7 File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 6, in test_calculator_add_method_returns_correct_result 8 calc = Calculator() 9 NameError: global name 'Calculator' is not defined 10 ---------------------------------------------------------------------- 11 Ran 1 test in 0.001s 12 FAILED (errors=1)
从 nosetest 给我们的输出中,我们可以看出问题与我们没有导入有关Calculator。那是因为我们还没有创建它!因此,让我们在app目录下的一个名为calculator.pyCalculator的文件中定义我们的并导入它:
应用程序/calculator.py
1 class Calculator(object): 2 def add(self, x, y): 3 pass
test_calculator.py
1 import unittest 2 from app.calculator import Calculator 3 4 class TddInPythonExample(unittest.TestCase): 5 def test_calculator_add_method_returns_correct_result(self): 6 calc = Calculator() 7 result = calc.add(2,2) 8 self.assertEqual(4, result) 9 10 if __name__ == '__main__': 11 unittest.main()
现在我们已经Calculator定义好了,现在让我们看看 nosetest 向我们指示了什么:
1 $ nosetests test_calculator.py 2 F 3 ====================================================================== 4 FAIL: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample) 5 ---------------------------------------------------------------------- 6 Traceback (most recent call last): 7 File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 9, in test_calculator_add_method_returns_correct_result 8 self.assertEqual(4, result) 9 AssertionError: 4 != None 10 ---------------------------------------------------------------------- 11 Ran 1 test in 0.001s 12 FAILED (failures=1)
所以,很明显,我们的add方法返回了错误的值,因为它目前没有做任何事情。方便地,nosetest 为我们提供了测试中的违规行,然后我们可以确认我们需要更改的内容。让我们修复该方法,看看我们的测试现在是否通过:
calculator.py
1 class Calculator(object): 2 def add(self, x, y): 3 return x+y
输出
1 $ nosetests test_calculator.py 2 . 3 ---------------------------------------------------------------------- 4 Ran 1 test in 0.000s 5 OK
成功!我们已经定义了我们的add方法,它按预期工作。但是,围绕此方法还有更多工作要做,以确保我们已正确测试它。
我们陷入了只测试我们目前感兴趣的案例的陷阱。
如果有人要添加数字以外的任何东西会发生什么?Python 实际上允许添加字符串和其他类型,但在我们的例子中,对于我们的计算器,只允许添加数字是有意义的。让我们为这种情况添加另一个失败测试,使用该assertRaises方法来测试此处是否引发异常:
test_calculator.py
1 import unittest 2 from app.calculator import Calculator 3 4 class TddInPythonExample(unittest.TestCase): 5 def setUp(self): 6 self.calc = Calculator() 7 def test_calculator_add_method_returns_correct_result(self): 8 result = self.calc.add(2, 2) 9 self.assertEqual(4, result) 10 def test_calculator_returns_error_message_if_both_args_not_numbers(self): 11 self.assertRaises(ValueError, self.calc.add, 'two', 'three') 12 13 if __name__ == '__main__': 14 unittest.main()
您可以在上面看到我们添加了测试,现在正在检查是否ValueError在我们传入字符串时引发 a 。我们还可以为其他类型添加更多检查,但现在,我们将保持简单。您可能还会注意到我们已经使用了该setup()方法。这使我们能够在每个测试用例之前就绪。因此,由于我们需要我们的Calculator对象在两个测试用例中都可用,因此在方法中初始化它是有意义的setUp。现在让我们看看 nosetest 向我们表明了什么:
1 $ nosetests test_calculator.py 2 .F 3 ====================================================================== 4 FAIL: test_calculator_returns_error_message_if_both_args_not_numbers (test.test_calculator.TddInPythonExample) 5 ---------------------------------------------------------------------- 6 Traceback (most recent call last): 7 File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 15, in test_calculator_returns_error_message_if_both_args_not_numbers 8 self.assertRaises(ValueError, self.calc.add, 'two', 'three') 9 AssertionError: ValueError not raised 10 ---------------------------------------------------------------------- 11 Ran 2 tests in 0.001s 12 FAILED (failures=1)
显然,nosetests向我们表明我们没有在ValueError预期的时间提高。现在我们有了一个新的失败测试,我们可以编写解决方案使其通过。
1 class Calculator(object): 2 def add(self, x, y): 3 number_types = (int, float, complex) 4 if isinstance(x, number_types) and isinstance(y, number_types): 5 return x + y 6 else: 7 raise ValueError
从上面的代码中,您可以看到我们做了一个小的补充来检查值的类型以及它们是否符合我们的要求。解决此问题的一种方法可能意味着您遵循duck typing并简单地尝试将其用作数字,并尝试/排除在其他情况下会引发的错误。以上是一些边缘情况,意味着我们必须在继续之前进行检查。如前所述,字符串可以用加号连接,所以我们只想允许数字。使用该isinstance方法可以让我们确保提供的值只能是数字。
为了完成测试,我们可以添加几个不同的案例。由于有两个变量,这意味着两者都可能不是数字。添加测试用例以覆盖所有场景。
1 import unittest 2 from app.calculator import Calculator 3 4 class TddInPythonExample(unittest.TestCase): 5 def setUp(self): 6 self.calc = Calculator() 7 def test_calculator_add_method_returns_correct_result(self): 8 result = self.calc.add(2, 2) 9 self.assertEqual(4, result) 10 def test_calculator_returns_error_message_if_both_args_not_numbers(self): 11 self.assertRaises(ValueError, self.calc.add, 'two', 'three') 12 def test_calculator_returns_error_message_if_x_arg_not_number(self): 13 self.assertRaises(ValueError, self.calc.add, 'two', 3) 14 def test_calculator_returns_error_message_if_y_arg_not_number(self): 15 self.assertRaises(ValueError, self.calc.add, 2, 'three') 16 17 18 if __name__ == '__main__': 19 unittest.main()
当我们现在运行所有这些测试时,我们可以确认该方法符合我们的要求!
1 $ nosetests test_calculator.py 2 .... 3 ---------------------------------------------------------------------- 4 Ran 4 tests in 0.001s 5 OK
其他单元测试包
测试
这是一个类似于 的测试运行器nosetest,它使用相同的约定,这意味着您可以在两者中的任何一个中执行测试。一个很好的特性pytest是它在底部的单独区域中捕获您的测试输出,这意味着您可以快速看到打印到命令行的任何内容(见下文)。我发现pytest在执行单个测试时很有用,而不是一组测试。
要安装pytest运行器,请按照与安装nosetest. 只需执行$ pip install pytest,它就会获取最新版本并将其安装到您的机器上。pytestpytest 允许您通过简单地在顶级项目文件夹中发出命令来运行测试。
1 pytest 2 ===================================================== test session starts ====================================================== 3 platform linux -- Python 3.9.12, pytest-7.2.1, pluggy-1.0.0 4 rootdir: /home/vaati/Desktop/TDD/test 5 collected 4 items 6 7 test_calculator.py .... [100%] 8 9 ====================================================== 4 passed in 0.01s =======================================================
pytest下面显示了从测试或代码中打印时的输出示例。这对于快速调试测试和查看它正在处理的一些数据很有用。注意:您只会在测试中出现错误或失败时显示代码输出,否则pytest会抑制任何输出。
1 pytest 2 ===================================================== test session starts ====================================================== 3 platform linux -- Python 3.9.12, pytest-7.2.1, pluggy-1.0.0 4 rootdir: /home/vaati/Desktop/TDD/test 5 collected 4 items 6 7 test_calculator.py F... [100%] 8 9 =========================================================== FAILURES =========================================================== 10 _____________________________ TddInPythonExample.test_calculator_add_method_returns_correct_result _____________________________ 11 12 self = <test.test_calculator.TddInPythonExample testMethod=test_calculator_add_method_returns_correct_result> 13 14 def test_calculator_add_method_returns_correct_result(self): 15 result = self.calc.add(2,2) 16 > self.assertEqual(4,result) 17 E AssertionError: 4 != 0 18 19 test_calculator.py:9: AssertionError 20 ----------------------------------------------------- Captured stdout call ----------------------------------------------------- 21 X is: 2 22 Y is: 2 23 Result is: 0 24 =================================================== short test summary info ==================================================== 25 FAILED test_calculator.py::TddInPythonExample::test_calculator_add_method_returns_correct_result - AssertionError: 4 != 0 26 ================================================= 1 failed, 3 passed in 0.02s ==================================================
单元测试
unittest我们用来创建测试的Python 内置包实际上可以自行执行并提供良好的输出。如果您不想安装任何外部包并将所有内容都保留在标准库中,这将很有用。要使用它,只需将以下块添加到测试文件的末尾。
1 if __name__ == '__main__': 2 unittest.main()
使用python calculator_tests.py执行测试。这是您可以预期的输出:
1 $ python test/test_calculator.py 2 .... 3 ---------------------------------------------------------------------- 4 Ran 4 tests in 0.004s 5 OK
使用 PDB 调试代码
通常在遵循 TDD 时,您会遇到代码问题,并且您的测试会失败。在某些情况下,当您的测试确实失败时,为什么会发生这种情况并不是很明显。在这种情况下,有必要对您的代码应用一些调试技术,以准确了解代码如何操纵数据,而不是获得您期望的准确响应或结果。
幸运的是,当您发现自己处于这样的位置时,您可以采用多种方法来了解代码的作用并纠正问题以使您的测试通过。最简单的方法,也是许多初学者在第一次编写 Python 代码时使用的方法,就是print在代码的特定位置添加语句,然后在运行测试时查看它们的输出。
使用打印语句进行调试
如果您故意更改我们的计算器代码以使其失败,您可以了解调试代码的工作方式。add更改方法中的代码app/calculator.py以减去两个值。
1 class Calculator(object): 2 def add(self, x, y): 3 number_types = (int,float, complex) 4 if isinstance(x, number_types) and isinstance(y, number_types): 5 return x - y 6 else: 7 raise ValueError
当您现在运行测试时,检查您的add方法在添加 2 加 2 时是否正确返回 4 的测试失败了,因为它现在返回 0。要检查它是如何得出这个结论的,您可以添加一些打印语句来检查它是否正确正确接收这两个值,然后检查输出。这将导致您得出结论,将两个数字相加的逻辑是不正确的。将以下打印语句添加到app/calculator.py中的代码。
1 class Calculator(object): 2 def add(self,x,y): 3 number_types = (int,float,complex) 4 if isinstance(x, number_types) and isinstance(y, number_types): 5 print('X is: {}'.format(x)) 6 print('Y is: {}'.format(y)) 7 result = x - y 8 print('Result is: {}'.format(result)) 9 return result 10 else: 11 raise ValueError
现在,当您nosetest针对测试执行时,它会很好地向您显示失败测试的捕获输出,让您有机会了解问题并修复代码以使用加法而不是减法。
1 $ nosetests test/test_calculator.py 2 F... 3 ====================================================================== 4 FAIL: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample) 5 ---------------------------------------------------------------------- 6 Traceback (most recent call last): 7 File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 11, in test_calculator_add_method_returns_correct_result 8 self.assertEqual(4, result) 9 AssertionError: 4 != 0 10 -------------------- >> begin captured stdout << --------------------- 11 X is: 2 12 Y is: 2 13 Result is: 0 14 --------------------- >> end captured stdout << ---------------------- 15 ---------------------------------------------------------------------- 16 Ran 4 tests in 0.002s 17 FAILED (failures=1)
使用 PDB 进行高级调试
当您开始编写更高级的代码时,单靠 print 语句是不够的,或者开始变得烦人,到处写,以后必须清理。随着编写代码时需要调试的过程变得司空见惯,工具也在不断发展,使调试 Python 代码变得更加容易和更具交互性。
最常用的工具之一是pdb(或 Python Debugger)。该工具包含在标准库中,只需在您希望停止程序执行并进入的位置添加一行pdb,通常称为“断点”。在 add 方法中使用我们失败的代码,尝试在减去两个值之前添加以下行。
1 class Calculator(object): 2 def add(self, x, y): 3 number_types = (int, float, complex) 4 if isinstance(x, number_types) and isinstance(y, number_types): 5 import pdb; pdb.set_trace() 6 return x - y 7 else: 8 raise ValueError
如果使用 usingnosetest执行测试,请务必使用-s标志执行,该标志告诉您nosetest不要捕获标准输出,否则您的测试将挂起而不会给您提示pdb。使用标准unittest转轮pytest不需要这样的步骤。
代码pdb片段就位后,当您现在执行测试时,代码的执行将在您放置该行的位置中断pdb,从而允许您与当前在执行位置加载的代码和变量进行交互。当执行第一次停止并给出提示时pdb,尝试键入list以查看您在代码中的位置以及当前所在的行。
1 $ nosetests -s 2 > /Users/user/PycharmProjects/tdd_in_python/app/calculator.py(7)add() 3 -> return x - y 4 (Pdb) list 5 2 def add(self, x, y): 6 3 number_types = (int, float, complex) 7 4 8 5 if isinstance(x, number_types) and isinstance(y, number_types): 9 6 import pdb; pdb.set_trace() 10 7 -> return x - y 11 8 else: 12 9 raise ValueError 13 [EOF] 14 (Pdb)
您可以与您的代码进行交互,就好像您在 Python 提示符中一样,因此此时尝试评估x和变量中的内容。y
1 (Pdb) x 2 2 3 (Pdb) y 4 2
您可以根据需要继续“玩”代码以找出问题所在。您可以help随时键入以获取命令列表,但以下是您可能需要的核心集:
n: 前进到下一行执行。
list:在当前执行位置的两边显示五行,以查看与当前执行点相关的代码。
args:列出当前执行点涉及的变量。
continue: 运行代码直至完成。
jump <line number>:运行代码直到指定的行号。
quit/ exit:停止pdb。
结论
测试驱动开发是一个实践起来既有趣又对生产代码质量非常有益的过程。它可以灵活地应用于从有许多团队成员的大型项目到小型独立项目的任何事物,这意味着它是一种向您的团队提倡的绝佳方法。
无论是结对编程还是自己开发,让失败的测试通过的过程都非常令人满意。如果您曾经争论过测试不是必需的,希望本文已经影响了您对未来项目的方法。
- 选项
- test_calculator.py
- calculator.py
- test_calculator.py
- 测试
- 单元测试
- 使用打印语句进行调试
- 使用 PDB 进行高级调试
发表评论