Python Testing with pytest
Tags
技术
Author
Date
Jun 3, 2023 → Jun 18, 2023
Summary
偏应用的书籍,本身没有太多思考,但是比官方文档写的好很多了
Times
URL
Status
Show
Show
偏应用的书籍,本身没有太多思考,但是比官方文档写的好很多了。第一遍速读,知道有哪些特性or方法,在脑海中留个印象,然后用的时候再来查阅吧。
CH1: Getting Started with pytestCH2:Writing Test FunctionsCH3: pytest FixturesCH4:Builtin FixturesCH5: Parametrization
CH1: Getting Started with pytest
小结:这一章,将了基本使用,即如何运行pytest文件;然后讲了 -v 和 —tb=no这两个参数的作用;然后将了pytest是如何查找test code的;最后讲了pytest的输出的几种类型。
如何运行pytest:
- pytest: With no arguments, pytest searches the local directory and subdirectories for tests.
- pytest <filename>: Runs the tests in one file
- pytest <filename> <filename> ...: Runs the tests in multiple named files
- pytest<dirname>: Starts in a particular directory (or more than one) and recursively searches for tests
两个关键参数:
- The
-v
or--verbose
command-line flag is used to reveal more verbose output.
- The
--tb=no
command-line flag is used to to turn off tracebacks. 通常用在运行一个文件夹下所有测试代码的时候,看起来简洁一些
在通过 pytest 测试的时候,会自动查找测试文件下的测试方法,称为 test discovery。如果在 pytest 后面没有参数,则默认会 从当前工作目录下查找,如果指定了文件 或者文件夹,则从指定中查找。为了保证测试方法可以被 pytest 找到,一般有如下约定俗成的命名规定:
- Test files should be named
test_<something>.py
or<something>_test.py
.
- Test methods and functions should be named
test_<something>
.
- Test classes should be named
Test<Something>
.
测试输出的结果解析:
- PASSED (.)—The test ran successfully.
- FAILED (F)—The test did not run successfully.
- SKIPPED (s)—The test was skipped. You can tell pytest to skip a test by using either the
@pytest.mark.skip()
or@pytest.mark.skipif()
decorators
- XFAIL (x)—The test was not supposed to pass, and it ran and failed. You can tell pytest that a test is expected to fail by using the
@pytest.mark.xfail()
decorator
- XPASS (X)—The test was marked with xfail, but it ran and passed.
- ERROR (E)—An exception happened either during the execution of a fixture or hook function, and not during the execution of a test function.
CH2:Writing Test Functions
最常用的做法是 assert <expression>, 这是经过 pytest 重写的 assert,和python原生的assert是不一样的。
此外,还可以使用
if <expression> pytest.fail(”explicted fail”)
来触发Fail,如下所示def test_with_fail(): c1 = Card("sit there", "brian") c2 = Card("do something", "okken") if c1 != c2: pytest.fail("they don't match")
此外,还可以通过编写 Assertion Helper 函数来辅助测试,其实就是写一个函数来封装一些具体的对比对象,然后别的测试函数来调用,例如:
from cards import Card import pytest def assert_identical(c1: Card, c2: Card): __tracebackhide__ = True assert c1 == c2 if c1.id != c2.id: pytest.fail(f"id's don't match. {c1.id} != {c2.id}") def test_identical(): c1 = Card("foo", id=123) c2 = Card("foo", id=123) assert_identical(c1, c2) def test_identical_fail(): c1 = Card("foo", id=123) c2 = Card("foo", id=456) assert_identical(c1, c2)
assert_identical 作为一个函数,因为没有 以test_开头,所以不是测试函数。该函数内部使用
__tracebackhide__ = True
来隐藏 traceback 信息,这样可以保护我们的测试代码和测试数据,避免暴露敏感信息还可以测试期望输出的异常,例如:
def test_no_path_raises(): with pytest.raises(TypeError): cards.CardsDB()
上述代码期望输出一个
TypeError
的异常,如果没有该异常或者是别的异常,则该测试不通过。此外,还可以测试更加具体的异常,例如下述两种方式:# 用正则表达式来匹配返回的异常 def test_raises_with_info(): match_regex = "missing 1 .* positional argument" with pytest.raises(TypeError, match=match_regex): cards.CardsDB() # 用 assert来匹配返回的异常 def test_raises_with_info_alt(): with pytest.raises(TypeError) as exc_info: cards.CardsDB() expected = "missing 1 required positional argument" assert expected in str(exc_info.value)
一些技巧:
- 通过 将类似的一组测试函数封装为class来作为一个组,这样在用的时候可以
pytest path/test_module.py::TestClass
来测试该class内的测试方法
- 通过 pytest -k pattern 来测试名称匹配的测试函数
- 使用
-vv
参数可以进一步增加 pytest 的输出详细程度
- 推荐 "Given-When-Then" 或者 "Arrange-Act-Assert" 这种测试代码结构的方式:
- Given(Arrange):测试数据和环境的准备阶段,也就是测试的前置条件。
- When(Act):测试代码执行的阶段,也就是测试的操作和行为。
- Then(Assert):测试结果的验证阶段,也就是测试的后置条件。
CH3: pytest Fixtures
fixtures是指一种机制,该机制允许从测试函数中分离代码的准备和清理。异常在fixtures 中和在测试函数中是不一样的,在前者中是 Error,而在后者中是Fail。其实也好理解,前者不是真正的测试,而是测试前的数据准备阶段,或者是测试结束后的清理阶段。以下是经典用法:
from pathlib import Path from tempfile import TemporaryDirectory import cards import pytest @pytest.fixture() def cards_db(): with TemporaryDirectory() as db_dir: db_path = Path(db_dir) db = cards.CardsDB(db_path) yield db db.close() def test_empty(cards_db): assert cards_db.count() == 0
cards_db 用 pytest.fixture() 装饰器来装饰,因此,cards_db是一个fixture。然后具体的测试函数如test_empty来使用 这个fixture,具体而言,测试前生成了数据库,且在测试之后不管是否通过都关闭了该数据库。tempfile.TemporaryDirectory用来生成一个临时的文件目录,通常用来测试!
pytest.fixture()装饰的函数不会被 pytest 查找到,因此不是测试用例
fixture的默认作用域为function,即test function开始前执行对应 fixture,结束后再执行对应fixture的后半部分。一个 fixture可以用来给多个 test function准备资源及关闭资源。但是对每个text function都是执行一遍,所以如果资源加载需要较长时间,则会加载关闭多次!
可以通过改变 fixture的作用域来改变fixture启动的次数,仅仅需要用
@pytest.fixture(scope="module")
来装饰 fixture function,以下是一个用例(注,可以通过pytest --setup-show来展示fixture的调用顺序,其中前面的 F M表示对应的fixture的scope):========================== test session starts ========================== collected 2 items test_mod_scope.py SETUP M cards_db ch3/test_mod_scope.py::test_empty (fixtures used: cards_db). ch3/test_mod_scope.py::test_two (fixtures used: cards_db). TEARDOWN M cards_db =========================== 2 passed in 0.03s ===========================
可见,使用了
scope="module"
之后,尽管cards_db被用在了两个test function中,但cards_db只SETUP和TEARDOWN了一次。当然,fixture的作用域还有class, package和session,默认是function。
- scope='function'。Run once per test function. The setup portion is run before each test using the fixture. The teardown portion is run after each test using the fixture.This is the default scope used when no scope parameter is specified.
- scope='class'。Run once per test class, regardless of how many test methods are in the class.
- scope='module'。Run once per module, regardless of how many test functions or methods or other fixtures in the module use it.
- scope='package'。Run once per package, or test directory, regardless of how many test functions or methods or other fixtures in the package use it.
- scope='session'。Run once per session. All test methods and functions using a fixture of session scope share one setup and teardown call.
当 fixture 定义在测试模块中时,它的作用范围默认为 function 范围,且作用范围最多至module(就算显示的指定scope=secession 或 package,也依旧是module)。如果需要让 fixture 在多个测试模块中复用,则可以将 fixture 定义在 conftest.py 文件中,并根据实际情况指定作用范围为 session或者package。
注:conftest.py 不需要手动import,pytest会自动的作用。小范围的fixture可以调用大范围的,繁殖不可以
有时候 fixture太多了,找不到怎么办?可以使用
pytest --fixtures -v
来列出来当前可以作用的所有fixture,还可以在后面加上文件 或者文件夹,来列出所指文件或者文件夹可作用的fixture。当然,也可以查看某个具体的测试函数中的fixture:pytest --fixtures-per-test test_count.py::test_empty
多个不同scope的fixture可以嵌套,达到某一特定的功能:数据库只开一遍,但是每次使用都是相当于新打开的,示例代码如下:
@pytest.fixture(scope="session") def db(): """CardsDB object connected to a temporary database""" with TemporaryDirectory() as db_dir: db_path = Path(db_dir) db_ = cards.CardsDB(db_path) yield db_ db_.close() @pytest.fixture(scope="function") def cards_db(db): """CardsDB object that's empty""" db.delete_all() return db
注意上述两个fixture嵌套了,数据库只打开了一次因为db()的scope是session,而每次调用 cards_db时,都会把打开的数据库清理一遍,这样就相当于用的是刚打开的数据库。
最后 fixture的name也是可以更改的,通过装饰器参数
@pytest.fixture(name="ultimate_answer")
CH4:Builtin Fixtures
这一节主要是看看一些常用的内置的fixture,注意这些fixtures是已近写好了,所以scope什么的都不能再修改了。
临时文件或者文件夹相关:
- tmp_path,会得到一个临时路径,scope=function
- tmp_path_factory,通过调用
mktemp()
得到一个临时路径,scope=secession
- 以上两者创建的路径均为
pathlib.Path
对象
def test_tmp_path(tmp_path): file = tmp_path / "file.txt" file.write_text("Hello") assert file.read_text() == "Hello" def test_tmp_path_factory(tmp_path_factory): path = tmp_path_factory.mktemp("sub") file = path / "file.txt" file.write_text("Hello") assert file.read_text() == "Hello"
捕捉输出:
- capsys,测试命令行应用
import cards def test_version_v2(capsys): cards.cli.version() # 先调用这个查看版本 output = capsys.readouterr().out.rstrip() # 然后捕捉输出 assert output == cards.__version__
capsys.readouterr() 返回了具名元组,分别为
out
和 err
此外,capsys还会临时的屏蔽掉pytest的输出(print也会被屏蔽),来保证命令行的干净。当然,可以使用
-s
或者-capture=no
来关闭屏蔽功能,这样,pytest中输出的内容就会在测试信息后面输出。当然,如果想要一直显示输出,可以有如下:def test_disabled(capsys): with capsys.disabled(): print("\ncapsys disabled print")
一些系统层面的操作
monkeypatch
,用于临时修改对象、字典、环境变量、Python搜索路径或当前目录。不管测试是否通过,之后都会复原- setattr(target, name, value, raising=True)—Sets an attribute
- delattr(target, name, raising=True)—Deletes an attribute
- setitem(dic, name, value)—Sets a dictionary entry
- delitem(dic, name, raising=True)—Deletes a dictionary entry
- setenv(name, value, prepend=None)—Sets an environment variable
- delenv(name, raising=True)—Deletes an environment variable
- syspath_prepend(path)—Prepends path to sys.path, which is Python’s list of import locations
- chdir(path)—Changes the current working directory
可以通过 pytest --fixtures 来查看其他的fixture
CH5: Parametrization
解决的是如何将一个test function应用在多个test case中,其实就是为了避免重复代码。共将了三种方式:
- Parametrizing Functions,@pytest.mark.parametrize()
- Parametrizing Fixtures,@pytest.fixture(params=())
- Parametrizing with pytest_generate_tests, pytest_generate_tests 比较复杂,先跳过
import pytest from cards import Card # Parametrizing Functions @pytest.mark.parametrize( "start_summary, start_state", [ ("write a book", "done"), ("second edition", "in prog"), ("create a course", "todo"), ], ) def test_finish(cards_db, start_summary, start_state): initial_card = Card(summary=start_summary, state=start_state) index = cards_db.add_card(initial_card) cards_db.finish(index) card = cards_db.get_card(index) assert card.state == "done"
# Parametrizing Fixtures @pytest.fixture(params=["done", "in prog", "todo"]) def start_state(request): return request.param def test_finish(cards_db, start_state): c = Card("write a book", state=start_state) index = cards_db.add_card(c) cards_db.finish(index) card = cards_db.get_card(index) assert card.state == "done"
此外,还可以使用 k 参数来选择 test case 的子集。例如, pytest -v -k todo 来选择 test function name 中包含todo的test function;pytest -v -k "todo and not (play or create)” 可以选择 包含 todo 但是不包含paly或create的test function