Освоение PyTest: плагины, фикстуры и JSON-отчеты для автоматизированного тестирования
Подготовка проекта и цели
В этом руководстве показано, как собрать небольшой, но полный проект на PyTest, демонстрирующий расширение фреймворка через конфигурацию, плагины, фикстуры, маркеры и автоматическую генерацию JSON-отчетов. Цель — показать, как PyTest превращается из простого тест-раннера в расширяемую систему для реальных проектов.
Скрипт инициализации проекта
Пример начинается с подготовки окружения: установки PyTest и создания структуры проекта с папками для вычислительной части, утилит приложения и тестов:
import sys, subprocess, os, textwrap, pathlib, json
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "pytest>=8.0"], check=True)
root = pathlib.Path("pytest_advanced_tutorial").absolute()
if root.exists():
import shutil; shutil.rmtree(root)
(root / "calc").mkdir(parents=True)
(root / "app").mkdir()
(root / "tests").mkdir()
Конфигурация PyTest и пользовательский плагин
Мы задаем pytest.ini для установки опций по умолчанию, путей тестов и маркеров. В conftest.py реализован набор функций, похожих на плагин: добавление опции –runslow, подсчет пройденных/ошибочных/пропущенных тестов, фильтрация медленных тестов по умолчанию и фикстуры settings, event_log, temp_json_file и fake_clock.
(root / "pytest.ini").write_text(textwrap.dedent("""
[pytest]
addopts = -q -ra --maxfail=1 -m "not slow"
testpaths = tests
markers =
slow: slow tests (use --runslow to run)
io: tests hitting the file system
api: tests patching external calls
""").strip()+"\n")
(root / "conftest.py").write_text(textwrap.dedent(r'''
import os, time, pytest, json
def pytest_addoption(parser):
parser.addoption("--runslow", action="store_true", help="run slow tests")
def pytest_configure(config):
config.addinivalue_line("markers", "slow: slow tests")
config._summary = {"passed":0,"failed":0,"skipped":0,"slow_ran":0}
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip = pytest.mark.skip(reason="need --runslow to run")
for item in items:
if "slow" in item.keywords: item.add_marker(skip)
def pytest_runtest_logreport(report):
cfg = report.config._summary
if report.when=="call":
if report.passed: cfg["passed"]+=1
elif report.failed: cfg["failed"]+=1
elif report.skipped: cfg["skipped"]+=1
if "slow" in report.keywords and report.passed: cfg["slow_ran"]+=1
def pytest_terminal_summary(terminalreporter, exitstatus, config):
s=config._summary
terminalreporter.write_sep("=", "SESSION SUMMARY (custom plugin)")
terminalreporter.write_line(f"Passed: {s['passed']} | Failed: {s['failed']} | Skipped: {s['skipped']}")
terminalreporter.write_line(f"Slow tests run: {s['slow_ran']}")
terminalreporter.write_line("PyTest finished successfully " if s["failed"]==0 else "Some tests failed ")
@pytest.fixture(scope="session")
def settings(): return {"env":"prod","max_retries":2}
@pytest.fixture(scope="function")
def event_log(): logs=[]; yield logs; print("\\nEVENT LOG:", logs)
@pytest.fixture
def temp_json_file(tmp_path):
p=tmp_path/"data.json"; p.write_text('{"msg":"hi"}'); return p
@pytest.fixture
def fake_clock(monkeypatch):
t={"now":1000.0}; monkeypatch.setattr(time,"time",lambda: t["now"]); return t
'''))
Пакет расчетных функций (calc)
В calc определены простые математические утилиты и класс Vector для демонстрации операций с объектами и сравнений — это удобные цели для юнит-тестов.
(root/"calc"/"__init__.py").write_text(textwrap.dedent('''
from .vector import Vector
def add(a,b): return a+b
def div(a,b):
if b==0: raise ZeroDivisionError("division by zero")
return a/b
def moving_avg(xs,k):
if k<=0 or k>len(xs): raise ValueError("bad window")
out=[]; s=sum(xs[:k]); out.append(s/k)
for i in range(k,len(xs)):
s+=xs[i]-xs[i-k]; out.append(s/k)
return out
'''))
(root/"calc"/"vector.py").write_text(textwrap.dedent('''
class Vector:
__slots__("x","y","z")
def __init__(self,x=0,y=0,z=0): self.x,self.y,self.z=float(x),float(y),float(z)
def __add__(self,o): return Vector(self.x+o.x,self.y+o.y,self.z+o.z)
def __sub__(self,o): return Vector(self.x-o.x,self.y-o.y,self.z-o.z)
def __mul__(self,s): return Vector(self.x*s,self.y*s,self.z*s)
__rmul__=__mul__
def norm(self): return (self.x**2+self.y**2+self.z**2)**0.5
def __eq__(self,o): return abs(self.x-o.x)<1e-9 and abs(self.y-o.y)<1e-9 and abs(self.z-o.z)<1e-9
def __repr__(self): return f"Vector({self.x:.2f},{self.y:.2f},{self.z:.2f})"
'''))
Утилиты приложения и мок API
В app есть помощники для работы с JSON и функция API, которая может работать в оффлайн-режиме для детерминированного тестирования.
(root/"app"/"io_utils.py").write_text(textwrap.dedent('''
import json, pathlib, time
def save_json(path,obj):
path=pathlib.Path(path); path.write_text(json.dumps(obj)); return path
def load_json(path): return json.loads(pathlib.Path(path).read_text())
def timed_operation(fn,*a,**kw):
t0=time.time(); out=fn(*a,**kw); t1=time.time(); return out,t1-t0
'''))
(root/"app"/"api.py").write_text(textwrap.dedent('''
import os, time, random
def fetch_username(uid):
if os.environ.get("API_MODE")=="offline": return f"cached_{uid}"
time.sleep(0.001); return f"user_{uid}_{random.randint(100,999)}"
'''))
Примеры тестов
Тесты демонстрируют параметризацию, xfail, маркеры io/api/slow, tmp_path, capsys, monkeypatch и использование фикстур temp_json_file, event_log, fake_clock.
(root/"tests"/"test_calc.py").write_text(textwrap.dedent('''
import pytest, math
from calc import add,div,moving_avg
from calc.vector import Vector
@pytest.mark.parametrize("a,b,exp",[(1,2,3),(0,0,0),(-1,1,0)])
def test_add(a,b,exp): assert add(a,b)==exp
@pytest.mark.parametrize("a,b,exp",[(6,3,2),(8,2,4)])
def test_div(a,b,exp): assert div(a,b)==exp
@pytest.mark.xfail(raises=ZeroDivisionError)
def test_div_zero(): div(1,0)
def test_avg(): assert moving_avg([1,2,3,4,5],3)==[2,3,4]
def test_vector_ops(): v=Vector(1,2,3)+Vector(4,5,6); assert v==Vector(5,7,9)
'''))
(root/"tests"/"test_io_api.py").write_text(textwrap.dedent('''
import pytest, os
from app.io_utils import save_json,load_json,timed_operation
from app.api import fetch_username
@pytest.mark.io
def test_io(temp_json_file,tmp_path):
d={"x":5}; p=tmp_path/"a.json"; save_json(p,d); assert load_json(p)==d
assert load_json(temp_json_file)=={"msg":"hi"}
def test_timed(capsys):
val,dt=timed_operation(lambda x:x*3,7); print("dt=",dt); out=capsys.readouterr().out
assert "dt=" in out and val==21
@pytest.mark.api
def test_api(monkeypatch):
monkeypatch.setenv("API_MODE","offline")
assert fetch_username(9)=="cached_9"
'''))
(root/"tests"/"test_slow.py").write_text(textwrap.dedent('''
import time, pytest
@pytest.mark.slow
def test_slow(event_log,fake_clock):
event_log.append(f"start@{fake_clock['now']}")
fake_clock["now"]+=3.0
event_log.append(f"end@{fake_clock['now']}")
assert len(event_log)==2
'''))
Запуск и итоговое резюме
В примере тестовый набор запускается дважды: сначала по умолчанию (медленные тесты пропускаются), затем с флагом –runslow. После прогонки создается summary.json с результатами, суммарным счетом тестов и примером event log.
print(" Project created at:", root)
print("\n RUN #1 (default, skips @slow)\n")
r1=subprocess.run([sys.executable,"-m","pytest",str(root)],text=True)
print("\n RUN #2 (--runslow)\n")
r2=subprocess.run([sys.executable,"-m","pytest",str(root),"--runslow"],text=True)
summary_file=root/"summary.json"
summary={
"total_tests":sum("test_" in str(p) for p in root.rglob("test_*.py")),
"runs": ["default","--runslow"],
"results": ["success" if r1.returncode==0 else "fail",
"success" if r2.returncode==0 else "fail"],
"contains_slow_tests": True,
"example_event_log":["start@1000.0","end@1003.0"]
}
summary_file.write_text(json.dumps(summary,indent=2))
print("\n FINAL SUMMARY")
print(json.dumps(summary,indent=2))
print("\n Tutorial completed — all tests & summary generated successfully.")
Практическая польза
Такой подход показывает, как комбинировать конфигурацию, поведение, реализованное в conftest.py, повторно используемые фикстуры и техники детерминирования (monkeypatch, fake clocks) для надежного тестирования. JSON-сводка позволяет интегрировать результаты в CI или аналитические пайплайны и масштабировать процесс при росте проекта.