Skip to content

Latest commit

 

History

History
579 lines (397 loc) · 25.6 KB

File metadata and controls

579 lines (397 loc) · 25.6 KB

五、将 unittest 套件转换为 pytest

在上一章中,我们已经看到了灵活的 pytest 体系结构如何创建了一个丰富的插件生态系统,有数百个插件可用。我们了解了查找和安装插件是多么容易,并对一些有趣的插件进行了概述。

现在您已经精通 pytest,您可能会遇到这样的情况:您有一个或多个基于unittest的测试套件,并且希望开始使用 pytest。在本章中,我们将讨论开始这样做的最佳方法,从可能需要很少修改或不需要修改的简单测试套件,到包含多年来有机发展的各种定制的大型内部测试套件。本章中的大部分提示和建议都来自于我在 ESSS(上)迁移我们庞大的unittest风格测试套件时的亲身经历 https://wwww.esss.co ,我工作的地方。

以下是我们将在本章中介绍的内容:

  • 使用 pytest 作为测试运行程序
  • 使用unittest2pytest转换资产
  • 处理安装和拆卸
  • 管理测试层次结构
  • 重构测试实用程序
  • 迁移策略

使用 pytest 作为测试运行程序

令人惊讶的是,许多人都不知道 pytest 可以开箱即用地运行unittest套件,而无需任何修改。

例如:

class Test(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.temp_dir = Path(tempfile.mkdtemp())
        cls.filepath = cls.temp_dir / "data.csv"
        cls.filepath.write_text(DATA.strip())

    @classmethod
    def tearDownClass(cls):
        shutil.rmtree(cls.temp_dir)

    def setUp(self):
        self.grids = list(iter_grids_from_csv(self.filepath))

    def test_read_properties(self):
        self.assertEqual(self.grids[0], GridData("Main Grid", 48, 44))
        self.assertEqual(self.grids[1], GridData("2nd Grid", 24, 21))
        self.assertEqual(self.grids[2], GridData("3rd Grid", 24, 48))

    def test_invalid_path(self):
        with self.assertRaises(IOError):
            list(iter_grids_from_csv(Path("invalid file")))

    @unittest.expectedFailure
    def test_write_properties(self):
        self.fail("not implemented yet")

我们可以使用unittestrunner 运行此功能:

..x
----------------------------------------------------------------------
Ran 3 tests in 0.005s

OK (expected failures=1)

但最酷的是,pytest 也运行此测试,没有任何修改:

λ pytest test_simple.py
======================== test session starts ========================
...
collected 3 items

test_simple.py ..x                                             [100%]

================ 2 passed, 1 xfailed in 0.11 seconds ================

这使得开始使用 pytest 作为测试运行程序变得非常容易,这带来了几个好处:

  • 您可以使用插件(例如,pytest-xdist)来加速测试套件。
  • 您可以使用几个命令行选项:-k用于选择测试,--pdb用于在出现错误时跳转到调试器,--lf仅运行最后失败的测试,等等。
  • 您可以停止编写self.assert*方法,使用普通asserts。pytest 很乐意提供丰富的失败信息,即使是对于基于unittest的子类也是如此。

为完整起见,以下是开箱即用支持的unittest习惯用法和功能:

  • setUp和tearDown用于功能级别setup/teardown
  • setUpClass和tearDownClass用于班级级别setup/teardown
  • 模块级setup/teardown的setUpModule和tearDownModule
  • skip、skipIf、skipUnless和expectedFailure修饰符,用于函数和类
  • TestCase.skipTest强制跳过内部测试

目前不支持以下习惯用法:

Surprises with pytest-xdist If you decide to use pytest-xdist in your test suite, be aware that it runs tests in an arbitrary order: each worker will run tests as they finish other tests, so the order the in which the tests are executed is not predictable. Because the default unittest runner runs tests sequentially and always in the same order, often this will bring to light concurrency problems to your test suite—for example, tests trying to create a temporary directory with the same name. You should see this as an opportunity to fix the underlying concurrency problems, as they should not be part of the test suite anyway.

unittest 子类中的 Pytest 特性

虽然在运行基于unittest的测试时,其设计不支持所有功能,但仍支持一些 pytest 习惯用法:

  • 普通断言:当子类化unittest.TestCase时,pytest 断言内省同样有效
  • 标记:标记可正常应用于unittest测试方法和类别。处理标记的插件在大多数情况下都应该正常工作(例如,pytest-timeout标记)
  • 自动使用夹具:在正常执行unittest测试方法时,会创建/销毁模块或conftest.py文件中定义的自动使用夹具,包括类范围自动使用夹具的unittest子类
  • 命令行中的测试选择:-k和-m应正常工作

其他 pytest 功能不适用于unittest,尤其是:

  • 夹具:unittest测试方法不能要求夹具。Pytest 使用unittest自己的结果收集器来执行测试,它不支持向测试函数传递参数
  • 参数化:这不受支持,原因与夹具类似:我们需要传递参数化值,目前不可能

不依赖固定装置的插件可以正常工作,例如pytest-timeout或pytest-randomly。

使用 Unitest2ByTest 转换资产

一旦您将测试运行程序更改为 pytest,您就可以利用编写简单的断言语句而不是self.assert*方法。

转换所有的方法调用既枯燥又容易出错,这就是为什么存在unittest2pytest工具的原因。它将所有的self.assert*方法调用转换为普通断言,还将self.assertRaises调用转换为适当的 pytest 习惯用法。

使用pip安装:

λ pip install unittest2pytest

安装后,您现在可以在所需的文件上执行它:

λ unittest2pytest test_simple2.py
RefactoringTool: Refactored test_simple2.py
--- test_simple2.py (original)
+++ test_simple2.py (refactored)
@@ -5,6 +5,7 @@
 import unittest
 from collections import namedtuple
 from pathlib import Path
+import pytest

 DATA = """
 Main Grid,48,44
@@ -49,12 +50,12 @@
 self.grids = list(iter_grids_from_csv(self.filepath))

 def test_read_properties(self):
-        self.assertEqual(self.grids[0], GridData("Main Grid", 48, 44))
-        self.assertEqual(self.grids[1], GridData("2nd Grid", 24, 21))
-        self.assertEqual(self.grids[2], GridData("3rd Grid", 24, 48))
+        assert self.grids[0] == GridData("Main Grid", 48, 44)
+        assert self.grids[1] == GridData("2nd Grid", 24, 21)
+        assert self.grids[2] == GridData("3rd Grid", 24, 48)

 def test_invalid_path(self):
-        with self.assertRaises(IOError):
+        with pytest.raises(IOError):
 list(iter_grids_from_csv(Path("invalid file")))

 @unittest.expectedFailure
RefactoringTool: Files that need to be modified:
RefactoringTool: test_simple2.py

默认情况下,它不会接触文件,只显示可能应用的更改的差异。要实际应用更改,请通过-wn(--write和--nobackups。

注意,在前面的示例中,它正确地替换了self.assert*调用self.assertRaises,并添加了pytest导入。它并没有改变我们测试类的子类,因为这可能会产生其他后果,这取决于您使用的实际子类,所以unittest2pytest不考虑这一点。

更新后的文件与以前一样运行:

λ pytest test_simple2.py
======================== test session starts ========================
...
collected 3 items

test_simple2.py ..x                                            [100%]

================ 2 passed, 1 xfailed in 0.10 seconds ================

采用 pytest 作为运行程序并能够使用简单的断言语句是一个经常被低估的伟大胜利:不再需要一直键入self.assert...是一种解放

At the time of writing, unittest2pytest doesn't handle the self.fail("not implemented yet") statement of the last test yet. So, we need to replace it manually with assert 0, "not implemented yet". Perhaps you would like to submit a PR to improve the project? (https://github.com/pytest-dev/unittest2pytest).

处理设置/拆卸

为了将TestCase子类完全转换为 pytest 样式,我们需要将unittest替换为 pytest 的习惯用法。在上一节中,我们已经看到了如何通过使用unittest2pytest使用self.assert*方法来实现这一点。但是我们能对setUp和tearDown方法做些什么呢?

正如我们之前所了解的,autouse fixture 在TestCase子类中工作得很好,因此它们是替代setUp和tearDown方法的自然方式。让我们使用上一节中的示例。

转换assert语句后,首先要删除unittest.TestCase子类:

class Test(unittest.TestCase):
    ...

这将成为以下内容:

class Test:
    ...

接下来,我们需要将setup/teardown方法转换为夹具等价物:

    @classmethod
    def setUpClass(cls):
        cls.temp_dir = Path(tempfile.mkdtemp())
        cls.filepath = cls.temp_dir / "data.csv"
        cls.filepath.write_text(DATA.strip())

    @classmethod
    def tearDownClass(cls):
        shutil.rmtree(cls.temp_dir)

因此,类范围的setUpClass和tearDownClass方法将成为单个类范围的固定装置:

    @classmethod
    @pytest.fixture(scope='class', autouse=True)
    def _setup_class(cls):
        temp_dir = Path(tempfile.mkdtemp())
        cls.filepath = temp_dir / "data.csv"
        cls.filepath.write_text(DATA.strip())
        yield
        shutil.rmtree(temp_dir)

多亏了yield语句,我们可以轻松地在夹具本身中编写拆卸代码,正如我们已经了解到的。

以下是一些观察结果:

  • Pytest 不在乎我们如何称呼我们的设备,所以我们还是保留旧的setUpClass名称为好。我们选择将其改为setup_class,目的有两个:避免混淆此代码的读者,因为它似乎仍然是TestCase子类,而使用_前缀表示此夹具不应用作普通 pytest 夹具。

  • 我们将temp_dir更改为局部变量,因为我们不再需要将其保留在cls中。以前,我们必须这样做,因为我们需要在tearDownClass期间访问cls.temp_dir,但现在我们可以将其保留为局部变量,并在yield语句之后访问它。这是使用yield分离设置和拆卸代码的一个美妙之处:您不需要保留上下文变量;它们被自然地保留为函数的局部变量。

我们采用与setUp方法相同的方法:

    def setUp(self):
        self.grids = list(iter_grids_from_csv(self.filepath))

这将成为以下内容:

    @pytest.fixture(autouse=True)
    def _setup(self):
        self.grids = list(iter_grids_from_csv(self.filepath))

这种技术非常有用,因为您可以通过一组最小的更改获得一个纯 pytest 类。此外,正如我们前面所做的那样,为 fixture 使用命名约定有助于向读者传达 fixture 正在转换旧的setup/teardown习惯用法。

既然这个类是一个合适的 pytest 类,您就可以自由地使用 fixture 和参数化了。

管理测试层次结构

正如我们所看到的,通常需要在大型测试套件中共享功能。因为unittest是基于子类TestCase的,所以通常会在TestCase子类中添加额外的功能。例如,如果我们需要测试需要数据库的应用程序逻辑,我们可能会在开始时添加功能,并直接连接到TestCase子类中的数据库:

class Test(unittest.TestCase):

    def setUp(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    def tearDown(self):
        self.session.close()
        os.remove(self.db_file)

    def create_temporary_db(self):
        ...

    def connect_db(self, db_file):
        ...

    def create_table(self, table_name, **fields):
        ...

    def check_row(self, table_name, **query):
        ...

    def test1(self):
        self.create_table("weapons", name=str, type=str, dmg=int)
        ...

这对于单个测试模块很有效,但通常情况下,我们需要在稍后的某个时间在另一个测试模块中使用此功能。unittest模块不包含共享通用setup/teardown代码的内置条款,因此大多数人自然而然地会在一个超类中提取所需的功能,然后在需要时从中提取一个子类:

# content of testing.py
class DataBaseTesting(unittest.TestCase):

    def setUp(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    def tearDown(self):
        self.session.close()
        os.remove(self.db_file)

    def create_temporary_db(self):
        ...

    def connect_db(self, db_file):
        ...

    def create_table(self, table_name, **fields):
        ...

    def check_row(self, table_name, **query):
        ...

# content of test_database2.py
from . import testing

class Test(testing.DataBaseTesting):

    def test1(self):
        self.create_table("weapons", name=str, type=str, dmg=int)
        ...

超类通常不仅包含setup/teardown代码,还经常包含调用self.assert*执行普通检查的实用函数(如上例中的check_row。

继续我们的示例:稍后,我们需要在另一个测试模块中使用完全不同的功能,例如,测试 GUI 应用程序。我们现在更明智了,并且怀疑在其他几个测试模块中需要 GUI 相关的功能,因此我们首先创建一个具有我们直接需要的功能的超类:

class GUITesting(unittest.TestCase):

    def setUp(self):
        self.app = self.create_app()

    def tearDown(self):
        self.app.close_all_windows()

    def mouse_click(self, window, button):
        ...

    def enter_text(self, window, text):
        ...

将setup/teardown和测试功能移动到超类的方法OK有效且易于理解。

当我们在同一个测试模块中需要两个不相关的功能时,问题就来了。在这种情况下,我们别无选择,只能求助于多重继承。假设我们需要测试一个连接到数据库的对话框;我们需要编写如下代码:

from . import testing

class Test(testing.DataBaseTesting, testing.GUITesting):

    def setUp(self):
 testing.DataBaseTesting.setUp(self)
 testing.GUITesting.setUp(self)

    def tearDown(self):
 testing.GUITesting.setUp(self)
 testing.DataBaseTesting.setUp(self)

多重继承通常会降低代码的可读性,使代码更难理解。在这里,它还有一个额外的恶化,我们需要以正确的顺序明确地称之为setUp和tearDown

需要注意的另一点是,setUp和tearDown在unittest框架中是可选的,因此,如果某个类不需要任何拆卸代码,则通常不声明tearDown方法。如果这个类包含后来移动到超类的功能,那么许多子类可能也不会声明tearDown方法。问题是,稍后在多重继承场景中,您改进了超类,需要添加一个tearDown方法,因为您现在必须检查所有子类并确保它们调用超类的tearDown方法。

因此,假设我们发现自己处于前面的情况,我们希望开始使用与TestCase测试不兼容的 pytest 功能。我们如何重构我们的实用程序类,以便能够从 pytest 中自然地使用它们,并保持现有的基于unittest的测试正常工作?

使用 fixture 重用测试代码

我们应该做的第一件事是将所需的功能提取到定义良好的 fixture 中,并将它们放入conftest.py文件中。继续我们的示例,我们可以创建db_testing和gui_testing夹具:

class DataBaseFixture:

    def __init__(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    def teardown(self):
        self.session.close()
        os.remove(self.db_file)

    def create_temporary_db(self):
        ...

    def connect_db(self, db_file):
        ...

    ...

@pytest.fixture
def db_testing():
    fixture = DataBaseFixture()
    yield fixture
    fixture.teardown()

class GUIFixture:

    def __init__(self):
        self.app = self.create_app()

    def teardown(self):
        self.app.close_all_windows()

    def mouse_click(self, window, button):
        ...

    def enter_text(self, window, text):
        ...

@pytest.fixture
def gui_testing():
    fixture = GUIFixture()
    yield fixture
    fixture.teardown()

现在,您可以开始使用普通 pytest 样式编写新的测试,并使用db_testing和gui_testing夹具,这非常好,因为它打开了在新测试中使用 pytest 特性的大门。但这里最酷的事情是,我们现在可以更改DataBaseTesting和GUITesting以重用 fixture 提供的功能,这样我们就不会破坏现有代码:

class DataBaseTesting(unittest.TestCase):

    @pytest.fixture(autouse=True)
    def _setup(self, db_testing):
 self._db_testing = db_testing

    def create_temporary_db(self):
        return self._db_testing.create_temporary_db()

    def connect_db(self, db_file):
        return self._db_testing.connect_db(db_file)

    ...

class GUITesting(unittest.TestCase):

    @pytest.fixture(autouse=True)
 def _setup(self, gui_testing):
 self._gui_testing = gui_testing

    def mouse_click(self, window, button):
        return self._gui_testing.mouse_click(window, button)

    ...

我们的DatabaseTesting和GUITesting类通过声明一个 autouse_setupfixture 来获取 fixture 值,这是我们在本章早期学习的技巧。我们可以取消tearDown方法,因为夹具在每次测试后会自行清理,实用方法成为夹具中实现的方法的简单代理。

作为奖励积分,GUIFixture和DataBaseFixture也可以使用其他 pytest 夹具。例如,我们可以删除DataBaseTesting.create_temporary_db()并使用内置tmpdir夹具为我们创建临时数据库文件:

class DataBaseFixture:

    def __init__(self, tmpdir):
        self.db_file = str(tmpdir / "file.db")
        self.session = self.connect_db(self.db_file)

    def teardown(self):
        self.session.close()

    ...

@pytest.fixture
def db_testing(tmpdir):
    fixture = DataBaseFixture(tmpdir)
    yield fixture
    fixture.teardown()

使用其他夹具可以大大简化现有的测试实用程序代码。

值得强调的是,这种重构将不需要对现有测试进行任何更改。在这里,夹具的一个好处再次变得显而易见:夹具需求的变化不会影响使用该夹具的测试。

重构测试实用程序

在上一节中,我们了解了测试套件如何利用子类来共享测试功能,以及如何在保持现有测试正常工作的同时将它们重构为装置。

通过unittest套件中的超类共享测试功能的替代方法是编写单独的实用程序类,并在测试中使用它们。回到我们的示例,我们需要有与数据库相关的设施,下面是一种以unittest友好的方式实现它的方法,无需使用超类:

# content of testing.py
class DataBaseTesting:

    def __init__(self, test_case):        
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)
        self.test_case = test_case
        test_case.addCleanup(self.teardown)

    def teardown(self):
        self.session.close()
        os.remove(self.db_file)

    ...

    def check_row(self, table_name, **query):
        row = self.session.find(table_name, **query)
        self.test_case.assertIsNotNone(row)
        ...

# content of test_1.py
from testing import DataBaseTesting

class Test(unittest.TestCase):

    def test_1(self):
        db_testing = DataBaseTesting(self)
        db_testing.create_table("weapons", name=str, type=str, dmg=int)
        db_testing.check_row("weapons", name="zweihander")
        ...

在这种方法中,我们在一个类中分离测试功能,该类接收当前TestCase实例作为其第一个参数,然后根据需要接收任何其他参数。

TestCase实例有两个用途:为类提供对各种self.assert*函数的访问,并作为向TestCase.addCleanup(注册清理函数的一种方式 https://docs.python.org/3/library/unittest.html#unittest.TestCase.addCleanup 。TestCase.addCleanup注册每次测试完成后将调用的函数,无论它们是否成功。我认为它们是一种优于 Ty4TeA/ORDT5TY 函数的替代,因为它们允许创建资源并立即登记清理。在setUp期间创建所有资源并在tearDown期间释放它们的缺点是,如果在setUp方法期间引发任何异常,则根本不会调用tearDown,泄漏资源和状态,这可能会影响后续的测试。

如果您的unittest套件将此方法用于测试设施,那么好消息是您可以轻松地将此功能转换/重用到 pytest。

由于此方法与装置的工作方式非常相似,因此可以将类稍微更改为装置:

# content of testing.py
class DataBaseFixture:

    def __init__(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    ...

    def check_row(self, table_name, **query):
        row = self.session.find(table_name, **query)
        assert row is not None

# content of conftest.py
@pytest.fixture
def db_testing():
    from .testing import DataBaseFixture
    result = DataBaseFixture()
    yield result
    result.teardown()

我们摆脱了对TestCase实例的依赖,因为我们的 fixture 现在负责调用teardown(),并且我们可以自由地使用普通断言而不是Test.assert*方法。

为了保持现有套件正常工作,我们只需要创建一个精简的子类来处理与TestCase子类一起使用时的清理:

# content of testing.py
class DataBaseTesting(DataBaseFixture):

    def __init__(self, test_case):
        super().__init__()
        test_case.addCleanup(self.teardown) 

通过这个小的重构,我们现在可以在新的测试中使用原生 pytest 装置,同时保持现有测试与以前完全一样工作。

虽然这种方法工作得很好,但一个问题是,不幸的是,我们无法在DataBaseFixture类中使用其他 pytest 固定装置(例如tmpdir,而不破坏TestCase子类中DataBaseTesting用法的兼容性。

迁移策略

能够开箱即用地运行基于unittest的测试无疑是一项非常强大的功能,因为它允许您作为一名运行者立即开始使用 pytest。

最终,您需要决定如何处理现有的基于unittest的测试。您可以选择以下几种方法:

  • 转换所有内容:如果您的测试套件相对较小,您可能会决定一次性转换所有测试。这样做的好处是,您不必为了保持现有的unittest套件正常工作而做出妥协,而且更容易被其他人审查,因为您的 pull 请求将有一个单一的主题。
  • 随用转换:您可以根据需要决定转换测试和功能。当您需要添加新测试或更改现有测试时,您可以利用前面章节中的技术将测试和/或重构功能转换为装置。如果您不想花时间预先转换所有内容,同时缓慢但肯定地为拥有一个只支持 pytest 的测试套件铺平道路,那么这是一个很好的方法。
  • 仅新测试:您可能决定永远不要接触现有的unittest套件,只使用 pytest 样式编写新测试。如果您有数千个可能永远不需要进行维护的测试,那么这种方法是合理的,但是您必须让前面部分中显示的混合方法无限期地工作。

Choose which migration strategy to use based on the time budget you have and the test suite's size. 

总结

我们已经讨论了一些关于如何在各种大小的基于unittest的套件中使用 pytest 的策略和技巧。我们首先讨论了如何使用 pytest 作为测试运行程序,以及哪些特性可以与TestCase测试配合使用。我们研究了如何使用unittest2pytest工具将self.assert*方法转换为简单的断言语句,并充分利用 pytest 内省特性。然后,我们学习了一些关于如何在测试类中将基于unittest的setUp/tearDown代码迁移到 pytest 样式、管理测试层次结构中的功能扩展以及通用实用程序的技术。最后,我们在本章结尾概述了您可以为各种大小的测试套件采取的可能迁移策略。

在下一章中,我们将看到我们在本书中所学到的知识的快速总结,并讨论我们还可能学到什么。