关于Python中单元测试的介绍

196 阅读15分钟

单元测试是一种测试软件的方法,它着眼于最小的可测试的代码片断,称为单元,被测试为正确的操作。通过单元测试,我们能够验证代码的每一部分,包括可能不会暴露给用户的辅助功能,都能正确地工作,并符合预期。

这个想法是,我们独立地检查我们程序的每一小部分,以确保它的工作。这与回归测试和集成测试形成鲜明对比,后者测试程序的不同部分是否能很好地协同工作,是否符合预期。

在这篇文章中,你将发现如何使用两个流行的单元测试框架在Python中实现单元测试,即内置的PyUnit框架和PyTest框架。

完成本教程后,你将知道。

  • Python中的单元测试库,如PyUnit和PyTest
  • 通过使用单元测试来检查预期的函数行为

让我们开始吧!

概述

本教程分为五个部分,它们是:

  • 什么是单元测试,为什么它们很重要?
  • 什么是测试驱动开发(TDD)?
  • 使用Python的内置PyUnit框架
  • 使用PyTest库
  • 实践中的单元测试

什么是单元测试,为什么它们很重要?

还记得在学校里做数学题时,先完成不同的算术程序,然后再把它们结合起来得到正确答案吗?想象一下,你会如何检查以确保每一步所做的计算是正确的,你没有犯任何粗心的错误或写错任何东西。

现在,把这个想法延伸到代码中去吧我们不希望经常翻看我们的代码来静态地验证它的正确性,那么你将如何创建一个测试来确保下面这段代码实际返回矩形的面积?

def calculate_area_rectangle(width, height):
    return width * height

我们可以用一些测试实例来运行这段代码,看看它是否返回了预期的输出。

这就是单元测试的概念!单元测试是检查代码的单一组件的测试,通常是模块化的函数,并确保它的执行情况符合预期。

单元测试是回归测试的一个重要部分,以确保在我们对代码进行修改后,代码仍能发挥预期的功能,有助于确保代码的稳定性。在对代码进行修改后,我们可以运行之前创建的单元测试,以确保代码库中其他部分的现有功能没有受到我们修改的影响。

单元测试的另一个关键好处是,它们有助于轻松隔离错误。想象一下,运行整个项目并收到一连串的错误。我们将如何去调试我们的代码?

这就是单元测试的作用,我们可以分析单元测试的输出,看看我们的代码中是否有任何组件出现了错误,然后从那里开始调试。这并不是说单元测试总是能够帮助我们找到错误,但是在我们开始关注集成测试中的组件集成之前,它允许一个更方便的起点。

在文章的其余部分,我们将通过测试这个矩形类中的函数来展示如何进行单元测试

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def get_area(self):
        return self.width * self.height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

现在我们已经有了单元测试的动机,让我们来探讨究竟如何使用单元测试作为我们开发管道的一部分,以及如何在Python中实现它们

测试驱动的开发

测试对于好的软件开发是如此重要,甚至有一个基于测试的软件开发过程,这就是测试驱动开发(TDD)。由Robert C. Martin提出的TDD的三个规则是

  • 你不允许写任何生产代码,除非是为了让一个失败的单元测试通过。
  • 你不允许写任何超过足够失败的单元测试;而编译失败就是失败。
  • 你不允许写任何超过足以通过一个失败的单元测试的生产代码。

TDD的关键思想是,我们的软件开发围绕着我们所创建的一套单元测试进行,这使得单元测试成为TDD软件开发过程的核心。通过这种方式,你可以保证你开发的每个组件都有一个测试。

TDD也偏向于有更小的测试,这意味着测试更具体,每次测试的组件更少。这有助于追踪错误,小的测试也更容易阅读和理解,因为在一次运行中,有更少的组件在发挥作用。

这并不意味着你必须在你的项目中使用TDD。但你可以考虑将其作为一种方法,同时开发你的代码和测试。

使用Python内置的PyUnit框架

你可能想知道,既然Python和其他语言提供了assert 关键字,为什么我们还需要单元测试框架?单元测试框架有助于实现测试过程的自动化,并允许我们用不同的参数对同一个函数运行多个测试,检查预期的异常,以及其他许多测试。

PyUnit是Python内置的单元测试框架,是相应的Java的JUnit测试框架的Python版本。要开始建立一个测试文件,我们需要导入unittest 库来使用PyUnit。

import unittest

然后,我们就可以开始编写第一个单元测试了。PyUnit中的单元测试被结构化为unittest.TestCase 类的子类,我们可以覆盖runTest() 方法来执行我们自己的单元测试,使用unittest.TestCase 中的不同断言函数来检查条件。

class TestGetAreaRectangle(unittest.TestCase):
    def runTest(self):
        rectangle = Rectangle(2, 3)
        self.assertEqual(rectangle.get_area(), 6, "incorrect area")

这就是我们的第一个单元测试!它检查rectangle.get_area() 方法是否返回宽度=2,长度=3的矩形的正确面积。我们使用self.assertEqual ,而不是简单地使用assert ,以便让unittest 库允许运行器积累所有的测试案例,并产生一份报告。

使用unittest.TestCase 中的不同断言函数也使我们有更好的能力来测试不同的行为,如self.assertRaises(exception) ,它允许我们检查某个代码块是否产生预期的异常。

为了运行单元测试,我们在程序中调用了unittest.main()

...
unittest.main()

由于该代码对这种情况返回预期的输出,所以它返回测试运行成功,输出。

.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK

完整的代码如下。

import unittest

# Our code to be tested
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def get_area(self):
        return self.width * self.height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

# The test based on unittest module
class TestGetAreaRectangle(unittest.TestCase):
    def runTest(self):
        rectangle = Rectangle(2, 3)
        self.assertEqual(rectangle.get_area(), 6, "incorrect area")

# run the test
unittest.main()

**注意:**虽然在上面,我们的业务逻辑Rectangle 类和我们的测试代码TestGetAreaRectangle 是放在一起的,但实际上你可以把它们放在不同的文件中,并把业务逻辑import 到你的测试代码中。这可以帮助你更好地管理代码。

我们也可以将多个单元测试嵌套在一个unittest.TestCase 的子类中,通过在新的子类中用 "test" 前缀命名方法,例如。

class TestGetAreaRectangle(unittest.TestCase):
    def test_normal_case(self):
        rectangle = Rectangle(2, 3)
        self.assertEqual(rectangle.get_area(), 6, "incorrect area")

    def test_negative_case(self): 
        """expect -1 as output to denote error when looking at negative area"""
        rectangle = Rectangle(-1, 2)
        self.assertEqual(rectangle.get_area(), -1, "incorrect negative output")

运行这个将给我们带来第一个错误。

F.
======================================================================
FAIL: test_negative_case (__main__.TestGetAreaRectangle)
expect -1 as output to denote error when looking at negative area
----------------------------------------------------------------------
Traceback (most recent call last):
 	File "<ipython-input-96-59b1047bb08a>", line 9, in test_negative_case
 		self.assertEqual(rectangle.get_area(), -1, "incorrect negative output")
AssertionError: -2 != -1 : incorrect negative output
----------------------------------------------------------------------
Ran 2 tests in 0.003s

FAILED (failures=1)

我们可以看到失败的单元测试,也就是输出中突出显示的test_negative_case ,以及stderr信息,因为get_area() 并没有像我们在测试中预期的那样返回-1。

unittest.TestCase类中定义了许多不同种类的断言函数。TestCase类中定义了许多不同的断言函数,我们可以使用,例如

def test_geq(self):
  """tests if value is greater than or equal to a particular target"""
  self.assertGreaterEqual(self.rectangle.get_area(), -1)

我们甚至可以检查在执行过程中是否抛出了一个特定的异常

def test_assert_raises(self): 
  """using assertRaises to detect if an expected error is raised when running a particular block of code"""
  with self.assertRaises(ZeroDivisionError):
    a = 1 / 0

现在,我们来看看建立我们的测试。如果我们有一些代码需要在运行每个测试之前进行设置,会怎么样?那么,我们可以覆盖 unittest.TestCase 中的 setUp 方法。

class TestGetAreaRectangleWithSetUp(unittest.TestCase):
  def setUp(self):
    self.rectangle = Rectangle(0, 0)

  def test_normal_case(self):
    self.rectangle.set_width(2)
    self.rectangle.set_height(3)
    self.assertEqual(self.rectangle.get_area(), 6, "incorrect area")

  def test_negative_case(self): 
    """expect -1 as output to denote error when looking at negative area"""
    self.rectangle.set_width(-1)
    self.rectangle.set_height(2)
    self.assertEqual(self.rectangle.get_area(), -1, "incorrect negative output")

在上面的代码例子中,我们覆盖了unittest.TestCasesetUp() 方法,用我们自己的setUp() 方法初始化了一个Rectangle 对象。这个setUp() 方法是在每个单元测试之前运行的,当多个测试依靠同一段代码来设置测试时,这个方法有助于避免代码的重复。这与JUnit中的@Before 装饰器类似。

同样,有一个tearDown() 方法,我们也可以覆盖它,以便在每个测试后执行代码。

为了每个TestCase类只运行一次该方法,我们也可以使用setUpClass方法,如下。

class TestGetAreaRectangleWithSetUp(unittest.TestCase):
  @classmethod
  def setUpClass(self):
    self.rectangle = Rectangle(0, 0)

上述代码对每个TestCase只运行一次,而不是像setUp那样每次测试运行一次。

为了帮助我们组织测试并选择我们要运行的测试集,我们可以将测试案例聚合到测试套件中,这有助于将应该一起执行的测试分组到一个对象中。

...
# loads all unit tests from TestGetAreaRectangle into a test suite
calculate_area_suite = unittest.TestLoader() \
                       .loadTestsFromTestCase(TestGetAreaRectangleWithSetUp)

在这里,我们还介绍了另一种在PyUnit中运行测试的方法,即使用unittest.TextTestRunner 类,它允许我们运行特定的测试套件。

runner = unittest.TextTestRunner()
runner.run(calculate_area_suite)

这与从命令行运行文件并调用unittest.main() 的输出相同。

把所有的东西放在一起,这就是单元测试的完整脚本的样子。

class TestGetAreaRectangleWithSetUp(unittest.TestCase):

  @classmethod
  def setUpClass(self):
    #this method is only run once for the entire class rather than being run for each test which is done for setUp()
    self.rectangle = Rectangle(0, 0)

  def test_normal_case(self):
    self.rectangle.set_width(2)
    self.rectangle.set_height(3)
    self.assertEqual(self.rectangle.get_area(), 6, "incorrect area")

  def test_geq(self):
    """tests if value is greater than or equal to a particular target"""
    self.assertGreaterEqual(self.rectangle.get_area(), -1)

  def test_assert_raises(self): 
    """using assertRaises to detect if an expected error is raised when running a particular block of code"""
    with self.assertRaises(ZeroDivisionError):
      a = 1 / 0

这只是你能用PyUnit做的事情的冰山一角。我们还可以编写测试,寻找与重合表达式相匹配的异常信息,或只运行一次的setUp/tearDown 方法(setUpClass ),例如。

使用PyTest

PyTest是内置unittest模块的一个替代品。要开始使用PyTest,你首先需要安装它,你可以用以下方法来做

pip install pytest

要编写测试,你只需要编写名字前缀为"test"的函数,PyTest的测试发现程序就可以找到你的测试,例如。

def test_normal_case(self):
    rectangle = Rectangle(2, 3)
    assert rectangle.get_area() == 6, "incorrect area"

你会注意到,PyTest使用了Python内置的assert 关键字,而不是像PyUnit那样使用自己的一套断言函数,这可能会使它稍微方便一些,因为我们可以避免搜索到不同的断言函数。

完整的代码如下。

# Our code to be tested
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def get_area(self):
        return self.width * self.height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

# The test function to be executed by PyTest
def test_normal_case():
    rectangle = Rectangle(2, 3)
    assert rectangle.get_area() == 6, "incorrect area"

在将其保存到一个文件test_file.py ,我们可以通过运行PyTest单元测试。

python -m pytest test_file.py

这就给了我们输出。

=================== test session starts ====================
platform darwin -- Python 3.9.9, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/MLM
plugins: anyio-3.4.0, typeguard-2.13.2
collected 1 item

test_file.py .                                       [100%]

==================== 1 passed in 0.01s =====================

你可能注意到,在PyUnit中,我们需要通过运行器或调用unittest.main() 来调用测试程序。但在PyTest中,我们只需将文件传递给模块。PyTest模块将收集所有以test 为前缀定义的函数,并逐一调用它们。然后它将验证是否有任何异常由assert 语句引发。让测试与业务逻辑保持一致可能会更方便。

PyTest也支持将函数分组在类中,但类应该用前缀"Test"(大写的T)来命名,例如:

class TestGetAreaRectangle:
    def test_normal_case(self):
        rectangle = Rectangle(2, 3)
        assert rectangle.get_area() == 6, "incorrect area"
    def test_negative_case(self): 
        """expect -1 as output to denote error when looking at negative area"""
        rectangle = Rectangle(-1, 2)
        assert rectangle.get_area() == -1, "incorrect negative output"

用PyTest运行这个将产生以下输出。

=================== test session starts ====================
platform darwin -- Python 3.9.9, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/MLM
plugins: anyio-3.4.0, typeguard-2.13.2
collected 2 items

test_code.py .F                                      [100%]

========================= FAILURES =========================
_________ TestGetAreaRectangle.test_negative_case __________

self = <test_code.TestGetAreaRectangle object at 0x10f5b3fd0>

    def test_negative_case(self):
        """expect -1 as output to denote error when looking at negative area"""
        rectangle = Rectangle(-1, 2)
>       assert rectangle.get_area() == -1, "incorrect negative output"
E       AssertionError: incorrect negative output
E       assert -2 == -1
E        +  where -2 = <bound method Rectangle.get_area of <test_code.Rectangle object at 0x10f5b3df0>>()
E        +    where <bound method Rectangle.get_area of <test_code.Rectangle object at 0x10f5b3df0>> = <test_code.Rectangle object at 0x10f5b3df0>.get_area

unittest5.py:24: AssertionError
================= short test summary info ==================
FAILED test_code.py::TestGetAreaRectangle::test_negative_case
=============== 1 failed, 1 passed in 0.12s ================

完整的代码如下。

# Our code to be tested
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def get_area(self):
        return self.width * self.height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

# The test functions to be executed by PyTest
class TestGetAreaRectangle:
    def test_normal_case(self):
        rectangle = Rectangle(2, 3)
        assert rectangle.get_area() == 6, "incorrect area"
    def test_negative_case(self):
        """expect -1 as output to denote error when looking at negative area"""
        rectangle = Rectangle(-1, 2)
        assert rectangle.get_area() == -1, "incorrect negative output"

为了实现我们测试的设置和拆除代码,PyTest有一个非常灵活的夹具系统,其中夹具是有返回值的函数。PyTest的夹具系统允许跨类、模块、包或会话共享夹具,以及可以调用其他夹具作为参数的夹具。

在这里,我们包括一个对PyTest的夹具系统的简单介绍。

@pytest.fixture
def rectangle():
    return Rectangle(0, 0)

def test_negative_case(rectangle): 
    print (rectangle.width)
    rectangle.set_width(-1)
    rectangle.set_height(2)
    assert rectangle.get_area() == -1, "incorrect negative output"

上面的代码引入了rectangle作为一个夹具,PyTest将test_negative_case 的参数列表中的矩形与该夹具相匹配,并为test_negative_case ,提供自己的矩形函数的输出集合,并且对其他每一个测试都是如此。然而,请注意,每个测试可以请求一次以上的固定装置,对于每个测试,固定装置只运行一次,结果被缓存。这意味着在单个测试的运行过程中,对该夹具的所有引用都是引用同一个返回值(如果返回值是一个引用类型,这一点很重要)。

完整的代码如下。

import pytest

# Our code to be tested
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def get_area(self):
        return self.width * self.height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

@pytest.fixture
def rectangle():
    return Rectangle(0, 0)

def test_negative_case(rectangle):
    print (rectangle.width)
    rectangle.set_width(-1)
    rectangle.set_height(2)
    assert rectangle.get_area() == -1, "incorrect negative output"

和PyUnit一样,PyTest也有很多其他功能,可以让你建立更全面、更高级的单元测试。

实践中的单元测试

现在,我们将探讨单元测试的操作。在我们的例子中,我们将测试一个使用pandas_datareader 从雅虎财经获取股票数据的函数,并在PyUnit中进行测试。

import pandas_datareader.data as web

def get_stock_data(ticker):
    """pull data from stooq"""
    df = web.DataReader(ticker, "yahoo")
    return df

这个函数通过从雅虎财经网站上抓取某只股票的数据,并返回pandas DataFrame。这可能在多个方面失败,例如,数据读取器可能无法返回任何东西(如果雅虎财经已经停机),或者返回一个缺少列或列中缺少数据的DataFrame(如果来源重组了其网站)。因此,我们应该提供多个测试函数来检查多种失败模式。

import datetime
import unittest

import pandas as pd
import pandas_datareader.data as web

def get_stock_data(ticker):
    """pull data from stooq"""
    df = web.DataReader(ticker, 'yahoo')
    return df

class TestGetStockData(unittest.TestCase):
    @classmethod
    def setUpClass(self):
        """We only want to pull this data once for each TestCase since it is an expensive operation"""
        self.df = get_stock_data('^DJI')

    def test_columns_present(self):
        """ensures that the expected columns are all present"""
        self.assertIn("Open", self.df.columns)
        self.assertIn("High", self.df.columns)
        self.assertIn("Low", self.df.columns)
        self.assertIn("Close", self.df.columns)
        self.assertIn("Volume", self.df.columns)

    def test_non_empty(self):
        """ensures that there is more than one row of data"""
        self.assertNotEqual(len(self.df.index), 0)

    def test_high_low(self):
        """ensure high and low are the highest and lowest in the same row"""
        ohlc = self.df[["Open","High","Low","Close"]]
        highest = ohlc.max(axis=1)
        lowest = ohlc.min(axis=1)
        self.assertTrue(ohlc.le(highest, axis=0).all(axis=None))
        self.assertTrue(ohlc.ge(lowest, axis=0).all(axis=None))

    def test_most_recent_within_week(self):
        """most recent data was collected within the last week"""
        most_recent_date = pd.to_datetime(self.df.index[-1])
        self.assertLessEqual((datetime.datetime.today() - most_recent_date).days, 7)

unittest.main()

我们上面的一系列单元测试检查某些列是否存在(test_columns_present ),数据框架是否非空(test_non_empty ),"高 "和 "低 "列是否真的是同一行的高和低(test_high_low ),以及数据框架中的最新数据是否在过去7天内(test_most_recent_within_week )。

想象一下,你正在做一个消耗股票市场数据的机器学习项目。拥有一个单元测试框架可以帮助你确定你的数据预处理是否按照预期工作。

使用这些单元测试,我们能够确定我们的功能的输出是否有实质性的变化,并且可以成为持续集成(CI)过程的一部分。我们还可以根据需要附加其他单元测试,这取决于我们依赖该功能的功能。

为了完整起见,这里有一个PyTest的等同版本。

import pytest

# scope="class" tears down the fixture only at the end of the last test in the class, so we avoid rerunning this step.
@pytest.fixture(scope="class")
def stock_df():
  # We only want to pull this data once for each TestCase since it is an expensive operation
  df = get_stock_data('^DJI')
  return df

class TestGetStockData:

  def test_columns_present(self, stock_df):
    # ensures that the expected columns are all present
    assert "Open" in stock_df.columns
    assert "High" in stock_df.columns
    assert "Low" in stock_df.columns
    assert "Close" in stock_df.columns
    assert "Volume" in stock_df.columns

  def test_non_empty(self, stock_df):
    # ensures that there is more than one row of data
    assert len(stock_df.index) != 0

  def test_most_recent_within_week(self, stock_df):
    # most recent data was collected within the last week
    most_recent_date = pd.to_datetime(stock_df.index[0])
    assert (datetime.datetime.today() - most_recent_date).days <= 7

构建单元测试可能看起来很耗时和乏味,但它们可以成为任何CI管道的关键部分,并且是早期捕获错误的宝贵工具,以免它们在管道中进一步移动并变得更昂贵的处理。

总结

在这篇文章中,你发现了什么是单元测试,以及如何使用Python中两个流行的库来进行单元测试(PyUnit、PyTest)。你还学习了如何配置单元测试,并看到了数据科学管道中单元测试的一个使用案例。

具体来说,你学到了

  • 什么是单元测试,为什么它是有用的
  • 单元测试如何在测试驱动开发管道中发挥作用
  • 如何使用PyUnit和PyTest在Python中进行单元测试