1. 程式人生 > >4、pytest 中文文件--pytest-fixtures:明確的、模組化的和可擴充套件的

4、pytest 中文文件--pytest-fixtures:明確的、模組化的和可擴充套件的

目錄

  • 1. fixture:作為形參使用
  • 2. fixture:一個典型的依賴注入的實踐
  • 3. conftest.py:共享fixture例項
  • 4. 共享測試資料
  • 5. 作用域:在跨類的、模組的或整個測試會話的用例中,共享fixture例項
    • 5.1. package作用域(實驗性的)
  • 6. fixture的例項化順序
  • 7. fixture的清理操作
    • 7.1. 使用yield代替return
    • 7.2. 使用with
      寫法
    • 7.3. 使用addfinalizer方法
  • 8. fixture可以訪問測試請求的上下文
  • 9. fixture返回工廠函式
  • 10. fixture的引數化
  • 11. 在引數化的fixture中標記用例
  • 12. 模組化:fixture使用其它的fixture
  • 13. 高效的利用fixture例項
  • 14. 在類、模組和專案級別上使用fixture例項
  • 15. 自動使用fixture
  • 16. 在不同的層級上覆寫fixture
    • 16.1. 在資料夾(conftest.py)層級覆寫fixture
    • 16.2. 在模組層級覆寫fixture
    • 16.3. 在用例引數中覆寫fixture
    • 16.4. 引數化的fixture覆寫非引數化的fixture,反之亦然

pytest fixtures的目的是提供一個固定的基線,使測試可以在此基礎上可靠地、重複地執行;對比xUnit經典的setup/teardown形式,它在以下方面有了明顯的改進:

  • fixture擁有一個明確的名稱,通過宣告使其能夠在函式、類、模組,甚至整個測試會話中被啟用使用;
  • fixture以一種模組化的方式實現。因為每一個fixture的名字都能觸發一個fixture函式,而這個函式本身又能呼叫其它的fixture
  • fixture的管理從簡單的單元測試擴充套件到複雜的功能測試,允許通過配置和元件選項引數化fixture和測試用例,或者跨功能、類、模組,甚至整個測試會話複用fixture

此外,pytest繼續支援經典的xUnit風格的測試。你可以根據自己的喜好,混合使用兩種風格,或者逐漸過渡到新的風格。你也可以從已有的unittest.TestCase或者nose專案中執行測試;

1. fixture:作為形參使用

測試用例可以接收fixture的名字作為入參,其實參是對應的fixture函式的返回值。通過@pytest.fixture裝飾器可以註冊一個fixture

我們來看一個簡單的測試模組,它包含一個fixture和一個使用它的測試用例:

# src/chapter-4/test_smtpsimple.py

import pytest


@pytest.fixture
def smtp_connection():
    import smtplib

    return smtplib.SMTP("smtp.163.com", 25, timeout=5)


def test_ehlo(smtp_connection):
    response, _ = smtp_connection.ehlo()
    assert response == 250
    assert 0  # 為了展示,強制置為失敗

這裡,test_ehlo有一個形參smtp_connection,和上面定義的fixture函式同名;

執行:

$ pipenv run pytest -q src/chapter-4/test_smtpsimple.py 
F                                                                 [100%]
=============================== FAILURES ================================
_______________________________ test_ehlo _______________________________

smtp_connection = <smtplib.SMTP object at 0x105992d68>

    def test_ehlo(smtp_connection):
        response, _ = smtp_connection.ehlo()
        assert response == 250
>       assert 0  # 為了展示,強制置為失敗
E       assert 0

src/chapter-4/test_smtpsimple.py:35: AssertionError
1 failed in 0.17s

執行的過程如下:

  • pytest收集到測試用例test_ehlo,其有一個形參smtp_connectionpytest查詢到一個同名的已經註冊的fixture
  • 執行smtp_connection()建立一個smtp_connection例項<smtplib.SMTP object at 0x105992d68>作為test_ehlo的實參;
  • 執行test_ehlo(<smtplib.SMTP object at 0x105992d68>)

如果你不小心拼寫出錯,或者呼叫了一個未註冊的fixture,你會得到一個fixture <...> not found的錯誤,並告訴你目前所有可用的fixture,如下:

$ pipenv run pytest -q src/chapter-4/test_smtpsimple.py 
E                                                                 [100%]
================================ ERRORS =================================
______________________ ERROR at setup of test_ehlo ______________________
file /Users/yaomeng/Private/Projects/pytest-chinese-doc/src/chapter-4/test_smtpsimple.py, line 32
  def test_ehlo(smtp_connectio):
E       fixture 'smtp_connectio' not found
>       available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, smtp_connection, smtp_connection_package, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
>       use 'pytest --fixtures [testpath]' for help on them.

/Users/yaomeng/Private/Projects/pytest-chinese-doc/src/chapter-4/test_smtpsimple.py:32
1 error in 0.02s

注意:

你也可以使用如下呼叫方式:

pytest --fixtures [testpath]

它會幫助你顯示所有可用的 fixture;

但是,對於_開頭的fixture,需要加上-v選項;

2. fixture:一個典型的依賴注入的實踐

fixture允許測試用例可以輕鬆的接收和處理特定的需要預初始化操作的應用物件,而不用過分關心匯入/設定/清理的細節;這是一個典型的依賴注入的實踐,其中,fixture扮演者注入者(injector)的角色,而測試用例扮演者消費者(client)的角色;

以上一章的例子來說明:test_ehlo測試用例需要一個smtp_connection的連線物件來做測試,它只關心這個連線是否有效和可達,並不關心它的建立過程。smtp_connectiontest_ehlo來說,就是一個需要預初始化操作的應用物件,而這個預處理操作是在fixture中完成的;簡而言之,test_ehlo說:“我需要一個SMTP連線物件。”,然後,pytest就給了它一個,就這麼簡單。

關於依賴注入的解釋,可以看看Stackflow上這個問題的高票回答如何向一個5歲的孩子解釋依賴注入?

When you go and get things out of the refrigerator for yourself, you can cause problems. You might leave the door open, you might get something Mommy or Daddy doesn't want you to have. You might even be looking for something we don't even have or which has expired.

What you should be doing is stating a need, "I need something to drink with lunch," and then we will make sure you have something when you sit down to eat.

更詳細的資料可以看看維基百科Dependency injection

3. conftest.py:共享fixture例項

如果你想在多個測試模組中共享同一個fixture例項,那麼你可以把這個fixture移動到conftest.py檔案中。在測試模組中你不需要手動的匯入它,pytest會自動發現,fixture的查詢的順序是:測試類、測試模組、conftest.py、最後是內建和第三方的外掛;

你還可以利用conftest.py檔案的這個特性為每個目錄實現一個本地化的外掛

4. 共享測試資料

如果你想多個測試共享同樣的測試資料檔案,我們有兩個好方法實現這個:

  • 把這些資料載入到fixture中,測試中再使用這些fixture
  • 把這些資料檔案放到tests資料夾中,一些第三方的外掛能幫助你管理這方面的測試,例如:pytest-datadirpytest-datafiles

5. 作用域:在跨類的、模組的或整個測試會話的用例中,共享fixture例項

需要使用到網路接入的fixture往往依賴於網路的連通性,並且建立過程一般都非常耗時;

我們來擴充套件一下上述示例(src/chapter-4/test_smtpsimple.py):在@pytest.fixture裝飾器中新增scope='module'引數,使每個測試模組只調用一次smtp_connection(預設每個用例都會呼叫一次),這樣模組中的所有測試用例將會共享同一個fixture例項;其中,scope引數可能的值都有:function(預設值)、classmodulepackagesession

首先,我們把smtp_connection()提取到conftest.py檔案中:

# src/chapter-4/conftest.py


import pytest
import smtplib


@pytest.fixture(scope='module')
def smtp_connection():
  return smtplib.SMTP("smtp.163.com", 25, timeout=5)

然後,在相同的目錄下,新建一個測試模組test_module.py,將smtp_connection作為形參傳入每個測試用例,它們共享同一個smtp_connection()的返回值:

# src/chapter-4/test_module.py


def test_ehlo(smtp_connection):
    response, _ = smtp_connection.ehlo()
    assert response == 250
    smtp_connection.extra_attr = 'test'
    assert 0  # 為了展示,強制置為失敗


def test_noop(smtp_connection):
    response, _ = smtp_connection.noop()
    assert response == 250
    assert smtp_connection.extra_attr == 0  # 為了展示,強制置為失敗

最後,讓我們來執行這個測試模組:

pipenv run pytest -q src/chapter-4/test_module.py 
FF                                                                [100%]
=============================== FAILURES ================================
_______________________________ test_ehlo _______________________________

smtp_connection = <smtplib.SMTP object at 0x107193c50>

    def test_ehlo(smtp_connection):
        response, _ = smtp_connection.ehlo()
        assert response == 250
        smtp_connection.extra_attr = 'test'
>       assert 0  # 為了展示,強制置為失敗
E       assert 0

src/chapter-4/test_module.py:27: AssertionError
_______________________________ test_noop _______________________________

smtp_connection = <smtplib.SMTP object at 0x107193c50>

    def test_noop(smtp_connection):
        response, _ = smtp_connection.noop()
        assert response == 250
>       assert smtp_connection.extra_attr == 0
E       AssertionError: assert 'test' == 0
E        +  where 'test' = <smtplib.SMTP object at 0x107193c50>.extra_attr

src/chapter-4/test_module.py:33: AssertionError
2 failed in 0.72s

可以看到:

  • 兩個測試用例使用的smtp_connection例項都是<smtplib.SMTP object at 0x107193c50>,說明smtp_connection只被呼叫了一次;
  • 在前一個用例test_ehlo中修改smtp_connection例項(上述例子中,為smtp_connection新增extra_attr屬性),也會反映到test_noop用例中;

如果你期望擁有一個會話級別作用域的fixture,可以簡單的將其宣告為:

@pytest.fixture(scope='session')
def smtp_connection():
  return smtplib.SMTP("smtp.163.com", 25, timeout=5)

注意:

pytest每次只快取一個fixture例項,當使用引數化的fixture時,pytest可能會在宣告的作用域內多次呼叫這個fixture

5.1. package作用域(實驗性的)

在 pytest 3.7 的版本中,正式引入了package作用域。

package作用域的fixture會作用於包內的每一個測試用例:

首先,我們在src/chapter-4目錄下建立如下的組織:

chapter-4/
└── package_expr
    ├── __init__.py
    ├── test_module1.py
    └── test_module2.py

然後,在src/chapter-4/conftest.py中宣告一個package作用域的fixture

@pytest.fixture(scope='package')
def smtp_connection_package():
    return smtplib.SMTP("smtp.163.com", 25, timeout=5)

接著,在src/chapter-4/package_expr/test_module1.py中新增如下測試用例:

def test_ehlo_in_module1(smtp_connection_package):
    response, _ = smtp_connection_package.ehlo()
    assert response == 250
    assert 0  # 為了展示,強制置為失敗


def test_noop_in_module1(smtp_connection_package):
    response, _ = smtp_connection_package.noop()
    assert response == 250
    assert 0  # 為了展示,強制置為失敗

同樣,在src/chapter-4/package_expr/test_module2.py中新增如下測試用例:

def test_ehlo_in_module2(smtp_connection_package):
    response, _ = smtp_connection_package.ehlo()
    assert response == 250
    assert 0  # 為了展示,強制置為失敗

最後,執行src/chapter-4/package_expr下所有的測試用例:

$ pipenv run pytest -q src/chapter-4/package_expr/
FFF                                                               [100%]
=============================== FAILURES ================================
_________________________ test_ehlo_in_module1 __________________________

smtp_connection_package = <smtplib.SMTP object at 0x1028fec50>

    def test_ehlo_in_module1(smtp_connection_package):
        response, _ = smtp_connection_package.ehlo()
        assert response == 250
>       assert 0  # 為了展示,強制置為失敗
E       assert 0

src/chapter-4/package_expr/test_module1.py:26: AssertionError
_________________________ test_noop_in_module1 __________________________

smtp_connection_package = <smtplib.SMTP object at 0x1028fec50>

    def test_noop_in_module1(smtp_connection_package):
        response, _ = smtp_connection_package.noop()
        assert response == 250
>       assert 0
E       assert 0

src/chapter-4/package_expr/test_module1.py:32: AssertionError
_________________________ test_ehlo_in_module2 __________________________

smtp_connection_package = <smtplib.SMTP object at 0x1028fec50>

    def test_ehlo_in_module2(smtp_connection_package):
        response, _ = smtp_connection_package.ehlo()
        assert response == 250
>       assert 0  # 為了展示,強制置為失敗
E       assert 0

src/chapter-4/package_expr/test_module2.py:26: AssertionError
3 failed in 0.45s

可以看到:

  • 雖然這三個用例在不同的模組中,但是使用相同的fixture例項,即<smtplib.SMTP object at 0x1028fec50>

注意:

  • chapter-4/package_expr可以不包含__init__.py檔案,因為pytest發現測試用例的規則沒有強制這一點;同樣,package_expr/的命名也不需要符合test_*或者*_test的規則;

  • 這個功能標記為實驗性的,如果在其實際應用中發現嚴重的bug,那麼這個功能很可能被移除;

6. fixture的例項化順序

多個fixture的例項化順序,遵循以下原則:

  • 高級別作用域的(例如:session)先於低級別的作用域的(例如:class或者function)例項化;
  • 相同級別作用域的,其例項化順序遵循它們在測試用例中被宣告的順序(也就是形參的順序),或者fixture之間的相互呼叫關係;
  • 使能autousefixture,先於其同級別的其它fixture例項化;

我們來看一個具體的例子:

# src/chapter-4/test_order.py

import pytest

order = []


@pytest.fixture(scope="session")
def s1():
    order.append("s1")


@pytest.fixture(scope="module")
def m1():
    order.append("m1")


@pytest.fixture
def f1(f3):
    order.append("f1")


@pytest.fixture
def f3():
    order.append("f3")


@pytest.fixture(autouse=True)
def a1():
    order.append("a1")


@pytest.fixture
def f2():
    order.append("f2")


def test_order(f1, m1, f2, s1):
    assert order == ["s1", "m1", "a1", "f3", "f1", "f2"]
  • s1擁有最高階的作用域(session),即使在測試用例test_order中最後被宣告,它也是第一個被例項化的(參照第一條原則)
  • m1擁有僅次於session級別的作用域(module),所以它是第二個被例項化的(參照第一條原則)
  • f1 f2 f3 a1同屬於function級別的作用域:

    • test_order(f1, m1, f2, s1)形參的宣告順序中,可以看出,f1f2先例項化(參照第二條原則)
    • f1的定義中又顯式的呼叫了f3,所以f3f1先例項化(參照第二條原則)
    • a1的定義中使能了autouse標記,所以它會在同級別的fixture之前例項化,這裡也就是在f3 f1 f2之前例項化(參照第三條原則)
  • 所以這個例子fixture例項化的順序為:s1 m1 a1 f3 f1 f2

注意:

  • 除了autousefixture,需要測試用例顯示宣告(形參),不宣告的不會被例項化;

  • 多個相同作用域的autouse fixture,其例項化順序遵循fixture函式名的排序;

7. fixture的清理操作

我們期望在fixture退出作用域之前,執行某些清理性操作(例如,關閉伺服器的連線等);

我們有以下幾種形式,實現這個功能:

7.1. 使用yield代替return

fixture函式中的return關鍵字替換成yield,則yield之後的程式碼,就是我們要的清理操作;

我們來宣告一個包含清理操作的smtp_connection

# src/chapter-4/conftest.py

@pytest.fixture()
def smtp_connection_yield():
    smtp_connection = smtplib.SMTP("smtp.163.com", 25, timeout=5)
    yield smtp_connection
    print("關閉SMTP連線")
    smtp_connection.close()

再新增一個使用它的測試用例:

# src/chapter-4/test_smtpsimple.py

def test_ehlo_yield(smtp_connection_yield):
    response, _ = smtp_connection_yield.ehlo()
    assert response == 250
    assert 0  # 為了展示,強制置為失敗

現在,我們來執行它:

λ pipenv run pytest -q -s --tb=no src/chapter-4/test_smtpsimple.py::test_ehlo_yield
F關閉SMTP連線

1 failed in 0.18s

我們可以看到在test_ehlo_yield執行完後,又執行了yield後面的程式碼;

7.2. 使用with寫法

對於支援with寫法的物件,我們也可以隱式的執行它的清理操作;

例如,上面的smtp_connection_yield也可以這樣寫:

@pytest.fixture()
def smtp_connection_yield():
    with smtplib.SMTP("smtp.163.com", 25, timeout=5) as smtp_connection:
        yield smtp_connection

7.3. 使用addfinalizer方法

fixture函式能夠接收一個request的引數,表示測試請求的上下文;我們可以使用request.addfinalizer方法為fixture新增清理函式;

例如,上面的smtp_connection_yield也可以這樣寫:

@pytest.fixture()
def smtp_connection_fin(request):
    smtp_connection = smtplib.SMTP("smtp.163.com", 25, timeout=5)

    def fin():
        smtp_connection.close()

    request.addfinalizer(fin)
    return smtp_connection

注意:

yield之前或者addfinalizer註冊之前程式碼發生錯誤退出的,都不會再執行後續的清理操作

8. fixture可以訪問測試請求的上下文

fixture函式可以接收一個request的引數,表示測試用例、類、模組,甚至測試會話的上下文環境;

我們可以擴充套件上面的smtp_connection_yield,讓其根據不同的測試模組使用不同的伺服器:

# src/chapter-4/conftest.py

@pytest.fixture(scope='module')
def smtp_connection_request(request):
    server, port = getattr(request.module, 'smtp_server', ("smtp.163.com", 25))
    with smtplib.SMTP(server, port, timeout=5) as smtp_connection:
        yield smtp_connection
        print("斷開 %s:%d" % (server, port))

在測試模組中指定smtp_server

# src/chapter-4/test_request.py

smtp_server = ("mail.python.org", 587)


def test_163(smtp_connection_request):
    response, _ = smtp_connection_request.ehlo()
    assert response == 250

我們來看看效果:

λ pipenv run pytest -q -s src/chapter-4/test_request.py
.斷開 mail.python.org:587

1 passed in 4.03s

9. fixture返回工廠函式

如果你需要在一個測試用例中,多次使用同一個fixture例項,相對於直接返回資料,更好的方法是返回一個產生資料的工廠函式;

並且,對於工廠函式產生的資料,也可以在fixture中對其管理:

@pytest.fixture
def make_customer_record():

    # 記錄生產的資料
    created_records = []

    # 工廠
    def _make_customer_record(name):
        record = models.Customer(name=name, orders=[])
        created_records.append(record)
        return record

    yield _make_customer_record

    # 銷燬資料
    for record in created_records:
        record.destroy()


def test_customer_records(make_customer_record):
    customer_1 = make_customer_record("Lisa")
    customer_2 = make_customer_record("Mike")
    customer_3 = make_customer_record("Meredith")

10. fixture的引數化

如果你需要在一系列的測試用例的執行中,每輪執行都使用同一個fixture,但是有不同的依賴場景,那麼可以考慮對fixture進行引數化;這種方式適用於對多場景的功能模組進行詳盡的測試;

在之前的章節fixture可以訪問測試請求的上下文中,我們在測試模組中指定不同smtp_server,得到不同的smtp_connection例項;

現在,我們可以通過指定params關鍵字引數建立兩個fixture例項,每個例項供一輪測試使用,所有的測試用例執行兩遍;在fixture的宣告函式中,可以使用request.param獲取當前使用的入參;

# src/chapter-4/test_request.py

@pytest.fixture(scope='module', params=['smtp.163.com', "mail.python.org"])
def smtp_connection_params(request):
    server = request.param
    with smtplib.SMTP(server, 587, timeout=5) as smtp_connection:
        yield smtp_connection

在測試用例中使用這個fixture

# src/chapter-4/test_params.py

def test_parames(smtp_connection_params):
    response, _ = smtp_connection_params.ehlo()
    assert response == 250

執行:

$ pipenv run pytest -q -s src/chapter-4/test_params.py 
.斷開 smtp.163.com:25
.斷開 smtp.126.com:25

2 passed in 0.26s

可以看到:

  • 這個測試用例使用不同的SMTP伺服器,執行了兩次;

在引數化的fixture中,pytest為每個fixture例項自動指定一個測試ID,例如:上述示例中的test_parames[smtp.163.com]test_parames[smtp.126.com]

使用-k選項執行一個指定的用例:

$ pipenv run pytest -q -s -k 163 src/chapter-4/test_params.py 
.斷開 smtp.163.com:25

1 passed, 1 deselected in 0.16s

使用--collect-only可以顯示這些測試ID,而不執行用例:

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_params.py 
src/chapter-4/test_params.py::test_parames[smtp.163.com]
src/chapter-4/test_params.py::test_parames[smtp.126.com]

no tests ran in 0.01s

同時,也可以使用ids關鍵字引數,自定義測試ID

# src/chapter-4/test_ids.py

@pytest.fixture(params=[0, 1], ids=['spam', 'ham'])
def a(request):
    return request.param


def test_a(a):
    pass

執行--collect-only

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_ids.py::test_a 
src/chapter-4/test_ids.py::test_a[spam]
src/chapter-4/test_ids.py::test_a[ham]

no tests ran in 0.01s

我們看到,測試ID為我們指定的值;

數字、字串、布林值和None在測試ID中使用的是它們的字串表示形式:

# src/chapter-4/test_ids.py

def idfn(fixture_value):
    if fixture_value == 0:
        return "eggs"
    elif fixture_value == 1:
        return False
    elif fixture_value == 2:
        return None
    else:
        return fixture_value


@pytest.fixture(params=[0, 1, 2, 3], ids=idfn)
def b(request):
    return request.param


def test_b(b):
    pass

執行--collect-only

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_ids.py::test_b 
src/chapter-4/test_ids.py::test_b[eggs]
src/chapter-4/test_ids.py::test_b[False]
src/chapter-4/test_ids.py::test_b[2]
src/chapter-4/test_ids.py::test_b[3]

no tests ran in 0.01s

可以看到:

  • ids可以接收一個函式,用於生成測試ID
  • 測試ID指定為None時,使用的是params原先對應的值;

注意:

當測試params中包含元組、字典或者物件時,測試ID使用的是fixture函式名+param的下標:

# src/chapter-4/test_ids.py

class C:
    pass


@pytest.fixture(params=[(1, 2), {'d': 1}, C()])
def c(request):
    return request.param


def test_c(c):
    pass

執行--collect-only

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_ids.py::test_c
src/chapter-4/test_ids.py::test_c[c0]
src/chapter-4/test_ids.py::test_c[c1]
src/chapter-4/test_ids.py::test_c[c2]

no tests ran in 0.01s

可以看到,測試IDfixture的函式名(c)加上對應param的下標(從0開始);

如果你不想這樣,可以使用str()方法或者複寫__str__()方法;

11. 在引數化的fixture中標記用例

fixtureparams引數中,可以使用pytest.param標記這一輪的所有用例,其用法和在pytest.mark.parametrize中的用法一樣;

# src/chapter-4/test_fixture_marks.py

import pytest


@pytest.fixture(params=[('3+5', 8),
                        pytest.param(('6*9', 42),
                                     marks=pytest.mark.xfail,
                                     id='failed')])
def data_set(request):
    return request.param


def test_data(data_set):
    assert eval(data_set[0]) == data_set[1]

我們使用pytest.param(('6*9', 42), marks=pytest.mark.xfail, id='failed')的形式指定一個request.param入參,其中marks表示當用例使用這個入參時,跳過執行將用例標記為xfail;並且,我們還使用id為此時的用例指定了一個測試ID

$ pipenv run pytest -v src/chapter-4/test_fixture_marks.py::test_data
============================ test session starts ============================
platform darwin -- Python 3.7.3, pytest-5.1.3, py-1.8.0, pluggy-0.13.0 -- /Users/yaomeng/.local/share/virtualenvs/pytest-chinese-doc-EK3zIUmM/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/yaomeng/Private/Projects/pytest-chinese-doc
collected 2 items                                                           

src/chapter-4/test_fixture_marks.py::test_data[data_set0] PASSED      [ 50%]
src/chapter-4/test_fixture_marks.py::test_data[failed] XFAIL          [100%]

======================= 1 passed, 1 xfailed in 0.08s ========================

可以看到:

  • 用例結果是XFAIL,而不是FAILED
  • 測試ID是我們指定的failed,而不是data_set1

我們也可以使用pytest.mark.parametrize實現相同的效果:

# src/chapter-4/test_fixture_marks.py

@pytest.mark.parametrize(
    'test_input, expected',
    [('3+5', 8),
     pytest.param('6*9', 42, marks=pytest.mark.xfail, id='failed')])
def test_data2(test_input, expected):
    assert eval(test_input) == expected

執行:

pipenv run pytest -v src/chapter-4/test_fixture_marks.py::test_data2
============================ test session starts ============================
platform darwin -- Python 3.7.3, pytest-5.1.3, py-1.8.0, pluggy-0.13.0 -- /Users/yaomeng/.local/share/virtualenvs/pytest-chinese-doc-EK3zIUmM/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/yaomeng/Private/Projects/pytest-chinese-doc
collected 2 items                                                           

src/chapter-4/test_fixture_marks.py::test_data2[3+5-8] PASSED         [ 50%]
src/chapter-4/test_fixture_marks.py::test_data2[failed] XFAIL         [100%]

======================= 1 passed, 1 xfailed in 0.07s ========================

12. 模組化:fixture使用其它的fixture

你不僅僅可以在測試用例上使用fixture,還可以在fixture的宣告函式中使用其它的fixture;這有助於模組化的設計你的fixture,可以在多個專案中重複使用框架級別的fixture

一個簡單的例子,我們可以擴充套件之前src/chapter-4/test_params.py的例子,例項一個app物件:

# src/chapter-4/test_appsetup.py

import pytest


class App:
    def __init__(self, smtp_connection):
        self.smtp_connection = smtp_connection


@pytest.fixture(scope='module')
def app(smtp_connection_params):
    return App(smtp_connection_params)


def test_smtp_connection_exists(app):
    assert app.smtp_connection

我們建立一個fixture app並呼叫之前在conftest.py中定義的smtp_connection_params,返回一個App的例項;

執行:

$ pipenv run pytest -v src/chapter-4/test_appsetup.py 
============================ test session starts ============================
platform darwin -- Python 3.7.3, pytest-5.1.3, py-1.8.0, pluggy-0.13.0 -- /Users/yaomeng/.local/share/virtualenvs/pytest-chinese-doc-EK3zIUmM/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/yaomeng/Private/Projects/pytest-chinese-doc
collected 2 items                                                           

src/chapter-4/test_appsetup.py::test_smtp_connection_exists[smtp.163.com] PASSED [ 50%]
src/chapter-4/test_appsetup.py::test_smtp_connection_exists[smtp.126.com] PASSED [100%]

============================= 2 passed in 1.25s =============================

因為app使用了引數化的smtp_connection_params,所以測試用例test_smtp_connection_exists會使用不同的App例項執行兩次,並且,app並不需要關心smtp_connection_params的實現細節;

app的作用域是模組級別的,它又呼叫了smtp_connection_params,也是模組級別的,如果smtp_connection_params是會話級別的作用域,這個例子還是一樣可以正常工作的;這是因為低級別的作用域可以呼叫高級別的作用域,但是高級別的作用域呼叫低級別的作用域會返回一個ScopeMismatch的異常;

13. 高效的利用fixture例項

在測試期間,pytest只啟用最少個數的fixture例項;如果你擁有一個引數化的fixture,所有使用它的用例會在建立的第一個fixture例項並銷燬後,才會去使用第二個例項;

下面這個例子,使用了兩個引數化的fixture,其中一個是模組級別的作用域,另一個是用例級別的作用域,並且使用print方法打印出它們的setup/teardown流程:

# src/chapter-4/test_minfixture.py

import pytest


@pytest.fixture(scope="module", params=["mod1", "mod2"])
def modarg(request):
    param = request.param
    print("  SETUP modarg", param)
    yield param
    print("  TEARDOWN modarg", param)


@pytest.fixture(scope="function", params=[1, 2])
def otherarg(request):
    param = request.param
    print("  SETUP otherarg", param)
    yield param
    print("  TEARDOWN otherarg", param)


def test_0(otherarg):
    print("  RUN test0 with otherarg", otherarg)


def test_1(modarg):
    print("  RUN test1 with modarg", modarg)


def test_2(otherarg, modarg):
    print("  RUN test2 with otherarg {} and modarg {}".format(otherarg, modarg))

執行:

$ pipenv run pytest -q -s src/chapter-4/test_minfixture.py 
  SETUP otherarg 1
  RUN test0 with otherarg 1
.  TEARDOWN otherarg 1
  SETUP otherarg 2
  RUN test0 with otherarg 2
.  TEARDOWN otherarg 2
  SETUP modarg mod1
  RUN test1 with modarg mod1
.  SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod1
.  TEARDOWN otherarg 1
  SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod1
.  TEARDOWN otherarg 2
  TEARDOWN modarg mod1
  SETUP modarg mod2
  RUN test1 with modarg mod2
.  SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod2
.  TEARDOWN otherarg 1
  SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod2
.  TEARDOWN otherarg 2
  TEARDOWN modarg mod2

8 passed in 0.02s

可以看出:

  • mod1TEARDOWN操作完成後,才開始mod2SETUP操作;
  • 用例test_0獨立完成測試;
  • 用例test_1test_2都使用到了模組級別的modarg,同時test_2也使用到了用例級別的otherarg。它們執行的順序是,test_1先使用mod1,接著test_2使用mod1otherarg 1/otherarg 2,然後test_1使用mod2,最後test_2使用mod2otherarg 1/otherarg 2;也就是說test_1test_2共用相同的modarg例項,最少化的保留fixture的例項個數;

14. 在類、模組和專案級別上使用fixture例項

有時,我們並不需要在測試用例中直接使用fixture例項;例如,我們需要一個空的目錄作為當前用例的工作目錄,但是我們並不關心如何建立這個空目錄;這裡我們可以使用標準的tempfile模組來實現這個功能;

# src/chapter-4/conftest.py

import pytest
import tempfile
import os


@pytest.fixture()
def cleandir():
    newpath = tempfile.mkdtemp()
    os.chdir(newpath)

在測試中使用usefixtures標記宣告使用它:

# src/chapter-4/test_setenv.py

import os
import pytest


@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
    def test_cwd_starts_empty(self):
        assert os.listdir(os.getcwd()) == []
        with open("myfile", "w") as f:
            f.write("hello")

    def test_cwd_again_starts_empty(self):
        assert os.listdir(os.getcwd()) == []

得益於usefixtures標記,測試類TestDirectoryInit中所有的測試用例都可以使用cleandir,這和在每個測試用例中指定cleandir引數是一樣的;

執行:

$ pipenv run pytest -q -s src/chapter-4/test_setenv.py 
..
2 passed in 0.02s

你可以使用如下方式指定多個fixture

@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test():
    ...

你也可以使用如下方式為測試模組指定fixture

pytestmark = pytest.mark.usefixtures("cleandir")

注意:引數的名字必須是pytestmark;

你也可以使用如下方式為整個專案指定fixture

# src/chapter-4/pytest.ini

[pytest]
usefixtures = cleandir

注意:

usefixtures標記不適用於fixture宣告函式;例如:

@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture():
  ...

這並不會返回任何的錯誤或告警,具體討論可以參考#3664

15. 自動使用fixture

有時候,你想在測試用例中自動使用fixture,而不是作為引數使用或者usefixtures標記;設想,我們有一個數據庫相關的fixture,包含begin/rollback/commit的體系結構,現在我們希望通過begin/rollback包裹每個測試用例;

下面,通過列表實現一個虛擬的例子:

# src/chapter-4/test_db_transact.py

import pytest


class DB:
    def __init__(self):
        self.intransaction = []

    def begin(self, name):
        self.intransaction.append(name)

    def rollback(self):
        self.intransaction.pop()


@pytest.fixture(scope="module")
def db():
    return DB()


class TestClass:
    @pytest.fixture(autouse=True)
    def transact(self, request, db):
        db.begin(request.function.__name__)
        yield
        db.rollback()

    def test_method1(self, db):
        assert db.intransaction == ["test_method1"]

    def test_method2(self, db):
        assert db.intransaction == ["test_method2"]

類級別作用域的transact函式中聲明瞭autouse=True,所以TestClass中的所有用例,可以自動呼叫transact而不用顯式的宣告或標記;

執行:

$ pipenv run pytest -q -s src/chapter-4/test_db_transact.py 
..
2 passed in 0.01s

autouse=Truefixture在其它級別作用域中的工作流程:

  • autouse fixture遵循scope關鍵字的定義:如果其含有scope='session',則不管它在哪裡定義的,都將只執行一次;scope='class'表示每個測試類執行一次;
  • 如果在測試模組中定義autouse fixture,那麼這個測試模組所有的用例自動使用它;
  • 如果在conftest.py中定義autouse fixture,那麼它的相同資料夾和子資料夾中的所有測試模組中的用例都將自動使用它;
  • 如果在外掛中定義autouse fixture,那麼所有安裝這個外掛的專案中的所有用例都將自動使用它;

上述的示例中,我們期望只有TestClass的用例自動呼叫fixture transact,這樣我們就不希望transact一直處於啟用的狀態,所以更標準的做法是,將transact宣告在conftest.py中,而不是使用autouse=True

@pytest.fixture
def transact(request, db):
    db.begin()
    yield
    db.rollback()

並且,在TestClass上宣告:

@pytest.mark.usefixtures("transact")
class TestClass:
    def test_method1(self):
        ...

其它類或者用例也想使用的話,同樣需要顯式的宣告usefixtures

16. 在不同的層級上覆寫fixture

在大型的測試中,你可能需要在本地覆蓋專案級別的fixture,以增加可讀性和便於維護;

16.1. 在資料夾(conftest.py)層級覆寫fixture

假設我們有如下的測試專案:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        def test_username(username):
            assert username == 'username'

    subfolder/
        __init__.py

        conftest.py
            # content of tests/subfolder/conftest.py
            import pytest

            @pytest.fixture
            def username(username):
                return 'overridden-' + username

        test_something.py
            # content of tests/subfolder/test_something.py
            def test_username(username):
                assert username == 'overridden-username'

可以看到:

  • 子資料夾conftest.py中的fixture覆蓋了上層資料夾中同名的fixture
  • 子資料夾conftest.py中的fixture可以輕鬆的訪問上層資料夾中同名的fixture

16.2. 在模組層級覆寫fixture

假設我們有如下的測試專案:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-' + username

        def test_username(username):
            assert username == 'overridden-username'

    test_something_else.py
        # content of tests/test_something_else.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-else-' + username

        def test_username(username):
            assert username == 'overridden-else-username'

可以看到:

  • 模組中的fixture覆蓋了conftest.py中同名的fixture
  • 模組中的fixture可以輕鬆的訪問conftest.py中同名的fixture

16.3. 在用例引數中覆寫fixture

假設我們有如下的測試專案:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

        @pytest.fixture
        def other_username(username):
            return 'other-' + username

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.mark.parametrize('username', ['directly-overridden-username'])
        def test_username(username):
            assert username == 'directly-overridden-username'

        @pytest.mark.parametrize('username', ['directly-overridden-username-other'])
        def test_username_other(other_username):
            assert other_username == 'other-directly-overridden-username-other'

可以看到:

  • fixture的值被用例的引數所覆蓋;
  • 儘管用例test_username_other沒有使用username,但是other_username使用到了username,所以也同樣受到了影響;

16.4. 引數化的fixture覆寫非引數化的fixture,反之亦然

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture(params=['one', 'two', 'three'])
        def parametrized_username(request):
            return request.param

        @pytest.fixture
        def non_parametrized_username(request):
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def parametrized_username():
            return 'overridden-username'

        @pytest.fixture(params=['one', 'two', 'three'])
        def non_parametrized_username(request):
            return request.param

        def test_username(parametrized_username):
            assert parametrized_username == 'overridden-username'

        def test_parametrized_username(non_parametrized_username):
            assert non_parametrized_username in ['one', 'two', 'three']

    test_something_else.py
        # content of tests/test_something_else.py
        def test_username(parametrized_username):
            assert parametrized_username in ['one', 'two', 'three']

        def test_username(non_parametrized_username):
            assert non_parametrized_username == 'username'

可以看出:

  • 引數化的fixture和非引數化的fixture同樣可以相互覆蓋;
  • 在模組層級上的覆蓋不會影響其它模組;

GitHub倉庫地址:https://github.com/luizyao/pytest-chinese-doc