聊聊 Python 的單元測試框架(三):pytest

  • 2019 年 10 月 8 日
  • 筆記

作者:HelloGitHub-Prodesire

出處:HelloGitHub

文中涉及的示例程式碼,已同步更新到 HelloGitHub-Team 倉庫

一、介紹

本篇文章是《聊聊 Python 的單元測試框架》的第三篇,前兩篇分別介紹了標準庫 unittest 和第三方單元測試框架 nose。作為本系列的最後一篇,壓軸出場的是Python 世界中最火的第三方單元測試框架:pytest。

pytest 項目地址:https://github.com/pytest-dev/pytest

它有如下主要特性:

  • assert[1] 斷言失敗時輸出詳細資訊(再也不用去記憶 self.assert* 名稱了)
  • 自動發現[2] 測試模組和函數
  • 模組化夾具[3] 用以管理各類測試資源
  • unittest 完全兼容,對 nose 基本兼容[4]
  • 非常豐富的插件體系,有超過 315 款第三方插件[5],社區繁榮

和前面介紹 unittestnose 一樣,我們將從如下幾個方面介紹 pytest 的特性。

二、用例編寫

nose 一樣,pytest 支援函數、測試類形式的測試用例。最大的不同點是,你可以盡情地使用 assert 語句進行斷言,絲毫不用擔心它會在 noseunittest 中產生的缺失詳細上下文資訊的問題。

比如下面的測試示例中,故意使得 test_upper 中斷言不通過:

import pytest    def test_upper():      assert 'foo'.upper() == 'FOO1'    class TestClass:      def test_one(self):          x = "this"          assert "h" in x        def test_two(self):          x = "hello"          with pytest.raises(TypeError):              x + []

而當使用 pytest 去執行用例時,它會輸出詳細的(且是多種顏色)上下文資訊:

=================================== test session starts ===================================  platform darwin -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0  rootdir: /Users/prodesire/projects/tests, inifile:  plugins: cov-2.6.0  collected 3 items    test.py F..                                                                         [100%]    ======================================== FAILURES =========================================  _______________________________________ test_upper ________________________________________        def test_upper():  >       assert 'foo'.upper() == 'FOO1'  E       AssertionError: assert 'FOO' == 'FOO1'  E         - FOO  E         + FOO1  E         ?    +    test.py:4: AssertionError  =========================== 1 failed, 2 passed in 0.08 seconds ============================

不難看到,pytest 既輸出了測試程式碼上下文,也輸出了被測變數值的資訊。相比於 noseunittestpytest 允許用戶使用更簡單的方式編寫測試用例,又能得到一個更豐富和友好的測試結果。

三、用例發現和執行

unittestnose 所支援的用例發現和執行能力,pytest 均支援。pytest 支援用例自動(遞歸)發現:

  • 默認發現當前目錄下所有符合 test_*.py*_test.py 的測試用例文件中,以 test 開頭的測試函數或以 Test 開頭的測試類中的以 test 開頭的測試方法
    • 使用 pytest 命令
  • nose2 的理念一樣,通過在配置文件[6]中指定特定參數,可配置用例文件、類和函數的名稱模式(模糊匹配)

pytest 也支援執行指定用例:

  • 指定測試文件路徑
    • pytest /path/to/test/file.py
  • 指定測試類
    • pytest /path/to/test/file.py:TestCase
  • 指定測試方法
    • pytest another.test::TestClass::test_method
  • 指定測試函數
    • pytest /path/to/test/file.py:test_function

四、測試夾具(Fixtures)

pytest測試夾具[7]unittestnosenose2的風格迥異,它不但能實現 setUptearDown這種測試前置和清理邏輯,還其他非常多強大的功能。

4.1 聲明和使用

pytest 中的測試夾具更像是測試資源,你只需定義一個夾具,然後就可以在用例中直接使用它。得益於 pytest 的依賴注入機制,你無需通過from xx import xx的形式顯示導入,只需要在測試函數的參數中指定同名參數即可,比如:

import pytest      @pytest.fixture  def smtp_connection():      import smtplib        return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)      def test_ehlo(smtp_connection):      response, msg = smtp_connection.ehlo()      assert response == 250

上述示例中定義了一個測試夾具 smtp_connection,在測試函數 test_ehlo 簽名中定義了同名參數,則 pytest 框架會自動注入該變數。

4.2 共享

pytest 中,同一個測試夾具可被多個測試文件中的多個測試用例共享。只需在包(Package)中定義 conftest.py 文件,並把測試夾具的定義寫在該文件中,則該包內所有模組(Module)的所有測試用例均可使用 conftest.py 中所定義的測試夾具。

比如,如果在如下文件結構的 test_1/conftest.py 定義了測試夾具,那麼 test_a.pytest_b.py 可以使用該測試夾具;而 test_c.py 則無法使用。

`-- test_1  |   |-- conftest.py  |   `-- test_a.py  |   `-- test_b.py  `-- test_2      `-- test_c.py

4.3 生效級別

unittestnose 均支援測試前置和清理的生效級別:測試方法、測試類和測試模組。

pytest 的測試夾具同樣支援各類生效級別,且更加豐富。通過在 pytest.fixture[8] 中指定 scope 參數來設置:

  • function —— 函數級,即調用每個測試函數前,均會重新生成 fixture
  • class —— 類級,調用每個測試類前,均會重新生成 fixture
  • module —— 模組級,載入每個測試模組前,均會重新生成 fixture
  • package —— 包級,載入每個包前,均會重新生成 fixture
  • session —— 會話級,運行所有用例前,只生成一次 fixture

當我們指定生效級別為模組級時,示例如下:

import pytest  import smtplib      @pytest.fixture(scope="module")  def smtp_connection():      return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

4.4 測試前置和清理

pytest 的測試夾具也能夠實現測試前置和清理,通過 yield 語句來拆分這兩個邏輯,寫法變得很簡單,如:

import smtplib  import pytest      @pytest.fixture(scope="module")  def smtp_connection():      smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)      yield smtp_connection  # provide the fixture value      print("teardown smtp")      smtp_connection.close()

在上述示例中,yield smtp_connection 及前面的語句相當於測試前置,通過 yield 返回準備好的測試資源 smtp_connection;而後面的語句則會在用例執行結束(確切的說是測試夾具的生效級別的聲明周期結束時)後執行,相當於測試清理。

如果生成測試資源(如示例中的 smtp_connection)的過程支援 with 語句,那麼還可以寫成更加簡單的形式:

@pytest.fixture(scope="module")  def smtp_connection():      with smtplib.SMTP("smtp.gmail.com", 587, timeout=5) as smtp_connection:          yield smtp_connection  # provide the fixture value

pytest 的測試夾具除了文中介紹到的這些功能,還有諸如參數化夾具[9]工廠夾具[10]在夾具中使用夾具[11]等更多高階玩法,詳情請閱讀 ["pytest fixtures: explicit, modular, scalable"](http://pytest.org/en/latest/fixture.html#pytest-fixtures-explicit-modular-scalable ""pytest fixtures: explicit, modular, scalable"")。

五、跳過測試和預計失敗

pytest 除了支援 unittestnosetest 的跳過測試和預計失敗的方式外,還在 pytest.mark 中提供對應方法:

  • 通過 skip[12] 裝飾器或 pytest.skip[13] 函數直接跳過測試
  • 通過 skipif[14]按條件跳過測試
  • 通過 xfail[15] 預計測試失敗

示例如下:

@pytest.mark.skip(reason="no way of currently testing this")  def test_mark_skip():      ...    def test_skip():      if not valid_config():          pytest.skip("unsupported configuration")    @pytest.mark.skipif(sys.version_info < (3, 6), reason="requires python3.6 or higher")  def test_mark_skip_if():      ...    @pytest.mark.xfail  def test_mark_xfail():      ...

關於跳過測試和預計失敗的更多玩法,參見 ["Skip and xfail: dealing with tests that cannot succeed"](http://pytest.org/en/latest/skipping.html#skip-and-xfail-dealing-with-tests-that-cannot-succeed ""Skip and xfail: dealing with tests that cannot succeed"")

六、子測試/參數化測試

pytest 除了支援 unittest 中的 TestCase.subTest,還支援一種更為靈活的子測試編寫方式,也就是 參數化測試,通過 pytest.mark.parametrize 裝飾器實現。

在下面的示例中,定義一個 test_eval 測試函數,通過 pytest.mark.parametrize 裝飾器指定 3 組參數,則將生成 3 個子測試:

@pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])  def test_eval(test_input, expected):      assert eval(test_input) == expected

示例中故意讓最後一組參數導致失敗,運行用例可以看到豐富的測試結果輸出:

========================================= test session starts =========================================  platform darwin -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0  rootdir: /Users/prodesire/projects/tests, inifile:  plugins: cov-2.6.0  collected 3 items    test.py ..F                                                                                     [100%]    ============================================== FAILURES ===============================================  __________________________________________ test_eval[6*9-42] __________________________________________    test_input = '6*9', expected = 42        @pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])      def test_eval(test_input, expected):  >       assert eval(test_input) == expected  E       AssertionError: assert 54 == 42  E        +  where 54 = eval('6*9')    test.py:6: AssertionError  ================================= 1 failed, 2 passed in 0.09 seconds ==================================

若將參數換成 pytest.param,我們還可以有更高階的玩法,比如知道最後一組參數是失敗的,所以將它標記為 xfail:

@pytest.mark.parametrize(      "test_input,expected",      [("3+5", 8), ("2+4", 6), pytest.param("6*9", 42, marks=pytest.mark.xfail)],  )  def test_eval(test_input, expected):      assert eval(test_input) == expected

如果測試函數的多個參數的值希望互相排列組合,我們可以這麼寫:

@pytest.mark.parametrize("x", [0, 1])  @pytest.mark.parametrize("y", [2, 3])  def test_foo(x, y):      pass

上述示例中會分別把 x=0/y=2x=1/y=2x=0/y=3x=1/y=3帶入測試函數,視作四個測試用例來執行。

七、測試結果輸出

pytest 的測試結果輸出相比於 unittestnose 來說更為豐富,其優勢在於:

  • 高亮輸出,通過或不通過會用不同的顏色進行區分
  • 更豐富的上下文資訊,自動輸出程式碼上下文和變數資訊
  • 測試進度展示
  • 測試結果輸出布局更加友好易讀

八、插件體系

pytest插件[16]十分豐富,而且即插即用,作為使用者不需要編寫額外程式碼。關於插件的使用,參見["Installing and Using plugins"](http://pytest.org/en/latest/plugins.html ""Installing and Using plugins"")。

此外,得益於 pytest 良好的架構設計和鉤子機制,其插件編寫也變得容易上手。關於插件的編寫,參見["Writing plugins"](http://pytest.org/en/latest/writing_plugins.html#writing-plugins ""Writing plugins"")。

九、總結

三篇關於 Python 測試框架的介紹到這裡就要收尾了。寫了這麼多,各位看官怕也是看得累了。我們不妨羅列一個橫向對比表,來總結下這些單元測試框架的異同:

unittest

nose

nose2

pytest

自動發現用例

指定(各級別)用例執行

支援 assert 斷言

測試夾具

測試夾具種類

前置和清理

前置和清理

前置和清理

前置、清理、內置各類 fixtures,自定義各類 fixtures

測試夾具生效級別

方法、類、模組

方法、類、模組

方法、類、模組

方法、類、模組、包、會話

支援跳過測試和預計失敗

子測試

測試結果輸出

一般

較好

較好

插件

較豐富

一般

豐富

鉤子

社區生態

作為標準庫,由官方維護

停止維護

維護中,活躍度低

維護中,活躍度高

Python 的單元測試框架看似種類繁多,實則是一代代的進化,有跡可循。抓住其特點,結合使用場景,就能容易的做出選擇。

若你不想安裝或不允許第三方庫,那麼 unittest 是最好也是唯一的選擇。反之,pytest 無疑是最佳選擇,眾多 Python 開源項目(如大名鼎鼎的 requests[17])都是使用 pytest 作為單元測試框架。甚至,連 nose2官方文檔[18]上都建議大家使用 pytest,這得是多大的敬佩呀!

References

[1] assert: https://docs.pytest.org/en/latest/assert.html [2] 自動發現: https://docs.pytest.org/en/latest/goodpractices.html#python-test-discovery [3]模組化夾具: https://docs.pytest.org/en/latest/fixture.html

[4] 基本兼容: http://pytest.org/en/latest/nose.html#unsupported-idioms-known-issues [5]第三方插件: http://plugincompat.herokuapp.com/ [6]配置文件: https://docs.pytest.org/en/latest/example/pythoncollection.html#changing-naming-conventions

[7]測試夾具: https://docs.pytest.org/en/latest/fixture.html [8] pytest.fixture: http://pytest.org/en/latest/reference.html#pytest-fixture [9]參數化夾具: http://pytest.org/en/latest/fixture.html#parametrizing-fixtures

[10]Deprecated: https://nose.readthedocs.io/en/latest/plugins/deprecated.html [11]在夾具中使用夾具: http://pytest.org/en/latest/fixture.html#modularity-using-fixtures-from-a-fixture-function

[12]skip: http://pytest.org/en/latest/skipping.html#skipping-test-functions [13]pytest.skip: http://pytest.org/en/latest/skipping.html#xfail-mark-test-functions-as-expected-to-fail [14]skipif: http://pytest.org/en/latest/skipping.html#id1

[15]xfail: http://pytest.org/en/latest/skipping.html#xfail-mark-test-functions-as-expected-to-fail [16]插件: http://plugincompat.herokuapp.com/

[17]Skip: https://nose.readthedocs.io/en/latest/plugins/skip.html [18]requests: https://github.com/psf/requests