Migrating from pytest to ProTest
This guide helps you migrate existing pytest test suites to ProTest. It covers the key conceptual differences and provides practical translation patterns.
Philosophy Shift
| pytest | ProTest |
|---|---|
Convention-based (discover test_*.py) |
Explicit structure (Suites, Sessions) |
| Global fixtures via conftest.py | Explicit binding with suite.bind() |
| Implicit dependency injection | Explicit DI with Annotated[T, Use(fixture)] |
Parametrize with @pytest.mark.parametrize |
Parametrize with ForEach + From |
Scope via @pytest.fixture(scope=...) |
Scope via binding: session.bind() / suite.bind() |
Quick Reference
Test Definition
# pytest
def test_something():
assert 1 + 1 == 2
# ProTest
@suite.test()
def test_something():
assert 1 + 1 == 2
# ProTest async (native support)
@suite.test()
async def test_something_async():
result = await fetch_data()
assert result is not None
Fixtures
# pytest - conftest.py
@pytest.fixture
def user():
return User(name="test")
@pytest.fixture
def db():
conn = connect()
yield conn
conn.close()
# pytest - test file
def test_user_in_db(user, db):
db.save(user)
assert db.find(user.id)
# ProTest
from protest import fixture, Use
from typing import Annotated
@fixture()
def user():
return User(name="test")
@fixture()
def db():
conn = connect()
yield conn # teardown after yield
conn.close()
# Bind to suite
suite.bind(user)
suite.bind(db)
# Explicit injection
@suite.test()
def test_user_in_db(
u: Annotated[User, Use(user)],
database: Annotated[Connection, Use(db)],
):
database.save(u)
assert database.find(u.id)
Factories (Parametric Fixtures)
# pytest - fixture with params
@pytest.fixture(params=["admin", "user", "guest"])
def role(request):
return create_role(request.param)
# pytest - factory pattern
@pytest.fixture
def create_user():
def _create(name, role="user"):
return User(name=name, role=role)
return _create
def test_user(create_user):
admin = create_user("Alice", role="admin")
# ProTest - factory pattern (native)
from protest import factory, FixtureFactory, Use
@factory() # Creates FixtureFactory, not raw value
def user(name: str = "Test", role: str = "user"):
return User(name=name, role=role)
suite.bind(user)
@suite.test()
async def test_user(
user_factory: Annotated[FixtureFactory[User], Use(user)],
):
# Factory is async - call it to create instances
admin = await user_factory(name="Alice", role="admin")
guest = await user_factory(role="guest")
Parametrization
# pytest
@pytest.mark.parametrize("x,y,expected", [
(1, 2, 3),
(2, 3, 5),
(10, 20, 30),
])
def test_add(x, y, expected):
assert x + y == expected
# pytest with ids
@pytest.mark.parametrize("input_file", [
"data.json",
"data.xml",
"data.csv",
], ids=lambda x: x.split(".")[1])
def test_parse(input_file):
parse(input_file)
# ProTest
from protest import ForEach, From
ADD_CASES = ForEach(
[(1, 2, 3), (2, 3, 5), (10, 20, 30)],
ids=lambda t: f"{t[0]}+{t[1]}={t[2]}"
)
@suite.test()
def test_add(case: Annotated[tuple, From(ADD_CASES)]):
x, y, expected = case
assert x + y == expected
# Or with named parameters
INPUT_FILES = ForEach(
["data.json", "data.xml", "data.csv"],
ids=lambda x: x.split(".")[1]
)
@suite.test()
def test_parse(input_file: Annotated[str, From(INPUT_FILES)]):
parse(input_file)
Fixture Scopes
# pytest
@pytest.fixture(scope="session")
def expensive_resource():
return load_heavy_data()
@pytest.fixture(scope="module")
def per_module_db():
return create_db()
@pytest.fixture # default: function scope
def per_test_data():
return fresh_data()
# ProTest - scope determined by binding
from protest import fixture, ProTestSession, ProTestSuite
session = ProTestSession()
suite = ProTestSuite("MyTests")
@fixture()
def expensive_resource():
return load_heavy_data()
@fixture()
def per_suite_db():
return create_db()
@fixture() # default: TEST scope (no binding needed)
def per_test_data():
return fresh_data()
# Scope is determined by WHERE you bind:
session.bind(expensive_resource) # → SESSION scope
suite.bind(per_suite_db) # → SUITE scope (equivalent to module)
# per_test_data not bound # → TEST scope (default)
Built-in Fixtures
# pytest
def test_with_tmp(tmp_path):
(tmp_path / "file.txt").write_text("hello")
def test_with_caplog(caplog):
logging.warning("test")
assert "test" in caplog.text
# pytest-mock plugin
def test_with_mock(mocker):
m = mocker.patch("mymodule.func")
m.return_value = 42
# ProTest - built-ins
from protest import tmp_path, caplog, mocker, Mocker, Use
from protest.entities import LogCapture
from pathlib import Path
@suite.test()
def test_with_tmp(tmp: Annotated[Path, Use(tmp_path)]):
(tmp / "file.txt").write_text("hello")
@suite.test()
def test_with_caplog(logs: Annotated[LogCapture, Use(caplog)]):
logging.warning("test")
assert "test" in logs.text
@suite.test()
def test_with_mock(m: Annotated[Mocker, Use(mocker)]):
mock = m.patch("mymodule.func")
mock.return_value = 42
Skipping and Markers
# pytest
@pytest.mark.skip(reason="not implemented")
def test_todo():
pass
@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
def test_unix_only():
pass
@pytest.mark.skipif(
not HAS_REDIS,
reason="Redis not available"
)
def test_with_redis(redis_client):
pass
@pytest.mark.slow
def test_slow_operation():
pass
# ProTest
@suite.test(skip="not implemented")
def test_todo():
pass
# Runtime skip condition with fixtures
@fixture()
def has_redis():
return shutil.which("redis-server") is not None
suite.bind(has_redis)
@suite.test(
skip=lambda has_redis: not has_redis,
skip_reason="Redis not available",
)
def test_with_redis(
available: Annotated[bool, Use(has_redis)],
client: Annotated[RedisClient, Use(redis_client)],
):
pass
# Tags for filtering
@suite.test(tags=["slow"])
def test_slow_operation():
pass
# Run filtered: protest run module:session --tags slow
# Exclude: protest run module:session --tags "not slow"
Test Organization
# pytest - file-based
# tests/
# conftest.py
# test_users.py
# test_orders.py
# integration/
# conftest.py
# test_api.py
# ProTest - explicit suites
from protest import ProTestSession, ProTestSuite
# session.py
session = ProTestSession()
# Unit tests
users_suite = ProTestSuite("Users", tags=["unit"])
orders_suite = ProTestSuite("Orders", tags=["unit"])
# Integration tests
integration_suite = ProTestSuite("Integration", tags=["integration"])
api_suite = ProTestSuite("API", tags=["api"])
integration_suite.add_suite(api_suite) # nested
# Register suites
session.add_suite(users_suite)
session.add_suite(orders_suite)
session.add_suite(integration_suite)
Async Tests
# pytest - requires pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_async_operation():
result = await fetch_data()
assert result
# pytest - async fixtures need plugin
@pytest.fixture
async def async_client():
async with aiohttp.ClientSession() as session:
yield session
# ProTest - native async support
@suite.test()
async def test_async_operation():
result = await fetch_data()
assert result
# Async fixtures work naturally
@fixture()
async def async_client():
async with aiohttp.ClientSession() as session:
yield session
Common Patterns
Before: pytest with many fixtures
# conftest.py
@pytest.fixture
def company():
return Company(name="Acme")
@pytest.fixture
def user(company):
return User(company=company)
@pytest.fixture
def order(user):
return Order(user=user)
# test_orders.py
def test_order_total(order):
order.add_item(Product(price=100))
assert order.total == 100
After: ProTest with factories
# fixtures.py
@factory()
async def company(name: str = "Acme"):
return Company(name=name)
@factory()
async def user(
company_factory: Annotated[FixtureFactory[Company], Use(company)],
role: str = "member",
):
c = await company_factory()
return User(company=c, role=role)
@factory()
async def order(
user_factory: Annotated[FixtureFactory[User], Use(user)],
):
u = await user_factory()
return Order(user=u)
# suite.py
suite.bind(company)
suite.bind(user)
suite.bind(order)
@suite.test()
async def test_order_total(
order_factory: Annotated[FixtureFactory[Order], Use(order)],
):
o = await order_factory()
o.add_item(Product(price=100))
assert o.total == 100
MagicMock vs mocker
# OK in ProTest - creating a mock to pass as argument
from unittest.mock import MagicMock
@suite.test()
def test_with_mock_arg():
mock_service = MagicMock()
mock_service.process.return_value = "ok"
result = handler(service=mock_service) # Passing mock
assert result == "ok"
# Better for patching - use mocker built-in
@suite.test()
def test_with_patch(m: Annotated[Mocker, Use(mocker)]):
mock = m.patch("myapp.external_service.call")
mock.return_value = "ok"
result = handler() # Uses patched module
assert result == "ok"
Running Tests
# pytest
pytest tests/
pytest tests/test_users.py
pytest tests/test_users.py::test_create_user
pytest -k "user and not slow"
pytest --lf # last failed
# ProTest
protest run myapp.tests.session:session
protest run myapp.tests.session:session --suite Users
protest run myapp.tests.session:session --tags "unit and not slow"
protest run myapp.tests.session:session --lf
Migration Checklist
- Create a session file with
ProTestSession() - Convert fixtures to ProTest syntax (
@fixture(),@factory()) - Create suites to group related tests
- Bind fixtures to suites with appropriate scopes
- Convert test functions with explicit
Annotated[T, Use(fixture)] - Replace
@pytest.mark.parametrizewithForEach+From - Replace built-ins (
tmp_path,caplog,mocker) - Update CI/CD to use
protest runcommand