diff --git a/docs/best-practices.md b/docs/best-practices.md index 674993f2..578e6368 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -61,10 +61,10 @@ session.add_suite(integration_suite) # DON'T DO THIS - 500 lines of mixed fixtures/suites/tests session = ProTestSession() -@fixture() +@fixture def db(): ... -@fixture() +@fixture def cache(): ... session.bind(db) @@ -164,7 +164,7 @@ di_suite = ProTestSuite( |-------|-----------------|----------| | Session | `session.py` or `fixtures.py` | Database, expensive resources | | Suite | Suite file | Suite-specific setup | -| Test | Near test, `@fixture()` | Test isolation, cheap resources | +| Test | Near test, `@fixture` | Test isolation, cheap resources | ### Session Fixtures: Expensive Resources @@ -189,7 +189,7 @@ from protest import ProTestSuite, fixture api_suite = ProTestSuite("API") -@fixture() +@fixture async def authenticated_client( db: Annotated[Database, Use(database)] ) -> AsyncGenerator[APIClient, None]: @@ -204,8 +204,8 @@ api_suite.bind(authenticated_client) ### Test Fixtures: Fresh Per Test ```python -# Use @fixture() (not scoped) for per-test isolation -@fixture() +# Use @fixture (not scoped) for per-test isolation +@fixture def request_payload() -> dict: return {"name": "test", "value": 42} ``` @@ -284,13 +284,13 @@ ProTestSuite("Misc") # Bad ```python # Name after what they provide -@fixture() +@fixture def database(): ... # Provides a database @factory() def user(): ... # Provides a user (becomes user_factory in tests) -@fixture() +@fixture def api_client(): ... # Provides an API client ``` @@ -435,12 +435,12 @@ def convert_video(input_path: str, output_path: str) -> ConversionResult: ```python # BAD: Should be test-scoped but bound to session -@fixture() +@fixture def request_id(): ... session.bind(request_id) # Fresh value needed per test! # GOOD: Test-scoped (not bound) -@fixture() +@fixture def request_id() -> str: return str(uuid4()) ``` @@ -508,4 +508,4 @@ async def test_draft_cannot_be_archived_directly( - [Fixtures](core-concepts/fixtures.md) - [Factories](core-concepts/factories.md) - [Tags](core-concepts/tags.md) - Tag inheritance and filtering -- [CLI Reference](cli.md) +- [CLI Reference](cli.md) \ No newline at end of file diff --git a/docs/core-concepts/dependency-injection.md b/docs/core-concepts/dependency-injection.md index 3e3d3e30..7b6af1a4 100644 --- a/docs/core-concepts/dependency-injection.md +++ b/docs/core-concepts/dependency-injection.md @@ -12,7 +12,7 @@ from protest import ProTestSession, Use, fixture session = ProTestSession() -@fixture() +@fixture def database(): return Database() @@ -65,11 +65,11 @@ def undefined_fixture(): Fixtures can depend on other fixtures: ```python -@fixture() +@fixture def config(): return {"db_url": "postgres://localhost"} -@fixture() +@fixture async def database(cfg: Annotated[dict, Use(config)]): return await connect(cfg["db_url"]) @@ -109,11 +109,11 @@ If two tests both use `database`, and `database` is session-scoped, they share t Raised when a fixture depends on a narrower scope: ```python -@fixture() # Test scope (not bound) +@fixture # Test scope (not bound) def per_test(): return "fresh" -@fixture() +@fixture def shared(x: Annotated[str, Use(per_test)]): return x diff --git a/docs/core-concepts/factories.md b/docs/core-concepts/factories.md index 29309d00..b75ca553 100644 --- a/docs/core-concepts/factories.md +++ b/docs/core-concepts/factories.md @@ -129,7 +129,7 @@ If a test creates `alice` then `bob`, teardown runs `bob` first, then `alice`. Factories can depend on other fixtures: ```python -@fixture() +@fixture def database(): return Database() diff --git a/docs/core-concepts/fixtures.md b/docs/core-concepts/fixtures.md index e0383ecd..4a9c919e 100644 --- a/docs/core-concepts/fixtures.md +++ b/docs/core-concepts/fixtures.md @@ -4,12 +4,12 @@ Fixtures provide reusable setup and teardown logic for tests. ## What is a Fixture? -A fixture is a function decorated with `@fixture()` that provides a value to tests. +A fixture is a function decorated with `@fixture` that provides a value to tests. ```python from protest import fixture -@fixture() +@fixture def config(): return {"debug": True} ``` @@ -18,7 +18,7 @@ def config(): **Important:** Fixture scope is determined by WHERE you bind it, not by the decorator. -The `@fixture()` decorator only marks a function as a fixture. The scope is set when you bind the fixture to a session or suite. +The `@fixture` decorator only marks a function as a fixture. The scope is set when you bind the fixture to a session or suite. ### Session Scope @@ -27,7 +27,7 @@ Bind to session with `session.bind()`. Lives for the entire test session. ```python from protest import fixture, ProTestSession -@fixture() +@fixture async def database(): db = await connect() yield db @@ -46,7 +46,7 @@ Bind to suite with `suite.bind()`. Lives for the duration of the suite. ```python from protest import fixture, ProTestSuite -@fixture() +@fixture def api_client(db: Annotated[Database, Use(database)]): return Client(db) @@ -61,7 +61,7 @@ Use case: Resources shared within a group of related tests. Don't bind the fixture. Fresh instance for each test. ```python -@fixture() +@fixture def request_id(): return str(uuid.uuid4()) @@ -83,7 +83,7 @@ Use case: Isolated state per test, unique IDs, temporary files. Use `yield` to separate setup from teardown: ```python -@fixture() +@fixture async def database(): # Setup db = await connect() @@ -111,11 +111,11 @@ A fixture can only depend on fixtures with equal or wider scope: Violating this raises `ScopeMismatchError`: ```python -@fixture() +@fixture def per_test_data(): return "per-test" -@fixture() +@fixture def shared_resource(x: Annotated[str, Use(per_test_data)]): return x @@ -210,7 +210,7 @@ async def rate_limited_api(): """Only 2 concurrent requests allowed.""" yield ApiClient() -@fixture() +@fixture async def user_service(api: Annotated[ApiClient, Use(rate_limited_api)]): """Service that uses the rate-limited API.""" yield UserService(api) @@ -242,7 +242,7 @@ Autouse fixtures are automatically resolved at their scope start, without being ### Session Autouse ```python -@fixture() +@fixture def configure_logging(): logging.basicConfig(level=logging.DEBUG) yield @@ -256,7 +256,7 @@ Session autouse fixtures are resolved at `SESSION_SETUP_START`, before any test ### Suite Autouse ```python -@fixture() +@fixture def clear_environment(): old = os.environ.copy() os.environ.clear() @@ -280,10 +280,10 @@ Don't use autouse when tests need the fixture's value - use explicit `Use()` ins ## Plain Functions -All fixtures must be decorated with `@fixture()`. Plain functions raise `PlainFunctionError` when used with `Use()`: +All fixtures must be decorated with `@fixture`. Plain functions raise `PlainFunctionError` when used with `Use()`: ```python -def not_a_fixture(): # Missing @fixture()! +def not_a_fixture(): # Missing @fixture! return "value" @session.test() diff --git a/docs/core-concepts/sessions-and-suites.md b/docs/core-concepts/sessions-and-suites.md index 5daa7bd6..f4603624 100644 --- a/docs/core-concepts/sessions-and-suites.md +++ b/docs/core-concepts/sessions-and-suites.md @@ -39,7 +39,7 @@ Bind fixtures to the session with `bind()`: ```python from protest import fixture -@fixture() +@fixture def database(): db = connect() yield db @@ -79,7 +79,7 @@ api_suite = ProTestSuite( Bind fixtures to a suite with `bind()`: ```python -@fixture() +@fixture def api_client(): return Client() @@ -124,7 +124,7 @@ orders_suite.full_path # "API::Orders" Child suites can access fixtures from parent suites: ```python -@fixture() +@fixture def api_client(): return Client() diff --git a/docs/core-concepts/tags.md b/docs/core-concepts/tags.md index 27514096..e90952ea 100644 --- a/docs/core-concepts/tags.md +++ b/docs/core-concepts/tags.md @@ -46,7 +46,7 @@ session.bind(db) # Fixture that depends on db - inherits "database" tag -@fixture() +@fixture def user_repository(db: Annotated[Database, Use(db)]): return UserRepository(db) @@ -54,7 +54,7 @@ session.bind(user_repository) # Fixture that depends on user_repository - also inherits "database" -@fixture() +@fixture def user_service(repo: Annotated[UserRepository, Use(user_repository)]): return UserService(repo) diff --git a/docs/core-concepts/tests.md b/docs/core-concepts/tests.md index 09b173bf..7c5ce3e7 100644 --- a/docs/core-concepts/tests.md +++ b/docs/core-concepts/tests.md @@ -36,7 +36,7 @@ Tests declare their dependencies using type annotations: from typing import Annotated from protest import Use, fixture -@fixture() +@fixture def database(): return Database() @@ -175,7 +175,7 @@ ProTest evaluates skip conditions **after** fixture resolution, so your callable from typing import Annotated from protest import Use, fixture -@fixture() +@fixture def environment(): return {"is_ci": os.getenv("CI") == "true"} diff --git a/docs/faq.md b/docs/faq.md index f46c9cf2..6e7137b3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -29,7 +29,7 @@ from typing import Annotated user_suite = ProTestSuite("User") -@fixture() +@fixture def user(): return User("alice") @@ -95,12 +95,12 @@ If fixtures could use `From()`, you'd get hidden cartesian products: ```python # Hypothetical - NOT SUPPORTED (decorator syntax doesn't exist either) # If this were possible: -@fixture() +@fixture def db(engine: Annotated[str, From(ENGINES)]): # 3 engines ... session.bind(db) -@fixture() +@fixture def user(role: Annotated[str, From(ROLES)], db: ...): # 2 roles ... session.bind(user) @@ -164,5 +164,4 @@ async def test_pipeline() -> None: assert result.success ``` -See `examples/subprocess_capture/session.py` for complete examples and [Built-ins](core-concepts/builtins.md#shell) for full API. - +See `examples/subprocess_capture/session.py` for complete examples and [Built-ins](core-concepts/builtins.md#shell) for full API. \ No newline at end of file diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index c33ba08a..a01ca775 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -21,7 +21,7 @@ session.add_suite(api_suite) # Define a test-scoped fixture (fresh instance per test) # For session scope: session.bind(fixture_fn) # For suite scope: suite.bind(fixture_fn) -@fixture() +@fixture def config(): return {"api_url": "https://api.example.com"} diff --git a/docs/guides/pytest-to-protest.md b/docs/guides/pytest-to-protest.md index 5be1c0b9..53c9577c 100644 --- a/docs/guides/pytest-to-protest.md +++ b/docs/guides/pytest-to-protest.md @@ -58,11 +58,11 @@ def test_user_in_db(user, db): from protest import fixture, Use from typing import Annotated -@fixture() +@fixture def user(): return User(name="test") -@fixture() +@fixture def db(): conn = connect() yield conn # teardown after yield @@ -191,15 +191,15 @@ from protest import fixture, ProTestSession, ProTestSuite session = ProTestSession() suite = ProTestSuite("MyTests") -@fixture() +@fixture def expensive_resource(): return load_heavy_data() -@fixture() +@fixture def per_suite_db(): return create_db() -@fixture() # default: TEST scope (no binding needed) +@fixture # default: TEST scope (no binding needed) def per_test_data(): return fresh_data() @@ -278,7 +278,7 @@ def test_todo(): pass # Runtime skip condition with fixtures -@fixture() +@fixture def has_redis(): return shutil.which("redis-server") is not None @@ -364,7 +364,7 @@ async def test_async_operation(): assert result # Async fixtures work naturally -@fixture() +@fixture async def async_client(): async with aiohttp.ClientSession() as session: yield session @@ -475,10 +475,11 @@ protest run myapp.tests.session:session --lf ## Migration Checklist 1. **Create a session file** with `ProTestSession()` -2. **Convert fixtures to ProTest syntax** (`@fixture()`, `@factory()`) +2. **Convert fixtures to ProTest syntax** (`@fixture`, `@factory()`) 3. **Create suites** to group related tests 4. **Bind fixtures** to suites with appropriate scopes 5. **Convert test functions** with explicit `Annotated[T, Use(fixture)]` 6. **Replace `@pytest.mark.parametrize`** with `ForEach` + `From` 7. **Replace built-ins** (`tmp_path`, `caplog`, `mocker`) 8. **Update CI/CD** to use `protest run` command +9. \ No newline at end of file diff --git a/docs/guides/testing-fastapi.md b/docs/guides/testing-fastapi.md index fb96aae4..f9f9a9d2 100644 --- a/docs/guides/testing-fastapi.md +++ b/docs/guides/testing-fastapi.md @@ -69,7 +69,7 @@ from protest import fixture from myapp.app import app -@fixture() +@fixture async def client() -> AsyncIterator[httpx.AsyncClient]: async with LifespanManager(app) as manager: transport = httpx.ASGITransport(app=manager.app) @@ -144,7 +144,7 @@ from protest import fixture from myapp.app import app -@fixture() +@fixture async def client() -> AsyncIterator[httpx.AsyncClient]: async with LifespanManager(app) as manager: transport = httpx.ASGITransport(app=manager.app) @@ -239,7 +239,7 @@ class _LifespanWrapper: await self.app(scope, receive, send) -@fixture() +@fixture async def client() -> AsyncIterator[httpx.AsyncClient]: async with lifespan(app) as state: wrapped = _LifespanWrapper(app, state) diff --git a/docs/index.md b/docs/index.md index 98b68902..da540680 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,7 +37,7 @@ Tag a fixture once, and **every test using it inherits the tag automatically** def db(): ... session.bind(db) -@fixture() +@fixture def user_repo(db: Annotated[DB, Use(db)]): ... # Inherits "database" tag session.bind(user_repo) @@ -94,7 +94,7 @@ from protest import ProTestSession, Use, fixture session = ProTestSession() -@fixture() +@fixture def database(): return {"connected": True} diff --git a/docs/internals/dependency-injection.md b/docs/internals/dependency-injection.md index 4b5cb58d..20849814 100644 --- a/docs/internals/dependency-injection.md +++ b/docs/internals/dependency-injection.md @@ -62,12 +62,12 @@ A fixture can only depend on fixtures with **equal or wider** scope: Violating this raises `ScopeMismatchError`: ```python -@fixture() # Test scope (not bound) +@fixture # Test scope (not bound) def per_test(): return "fresh" -@fixture() +@fixture def shared(x: Annotated[str, Use(per_test)]): pass @@ -113,7 +113,7 @@ wait for the first resolution to complete, then share the cached value. Generator fixtures use `yield` to separate setup from teardown: ```python -@fixture() +@fixture async def database(): db = await connect() # Setup yield db # Value used by tests @@ -239,4 +239,4 @@ class FixtureDurationPlugin(PluginBase): - [Fixtures](../core-concepts/fixtures.md) - User guide for fixtures - [Factories](../core-concepts/factories.md) - Factory fixtures - [Events](events.md) - Complete event reference -- [Plugins](plugins.md) - Plugin development guide +- [Plugins](plugins.md) - Plugin development guide \ No newline at end of file diff --git a/protest/di/decorators.py b/protest/di/decorators.py index 18b07772..47d6ffb2 100644 --- a/protest/di/decorators.py +++ b/protest/di/decorators.py @@ -7,7 +7,7 @@ import functools from collections.abc import Callable -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, overload from protest.di.validation import validate_no_from_params from protest.entities import FixtureMarker, FixtureRegistration @@ -43,10 +43,25 @@ def unwrap_fixture(func: Callable[..., Any]) -> Callable[..., Any]: return func +@overload +def fixture(_func: FuncT, /) -> FixtureWrapper[FuncT]: ... + + +@overload def fixture( + *, tags: list[str] | None = None, max_concurrency: int | None = None, -) -> Callable[[FuncT], FixtureWrapper[FuncT]]: +) -> Callable[[FuncT], FixtureWrapper[FuncT]]: ... + + +def fixture( + _func: FuncT | None = None, + /, + *, + tags: list[str] | None = None, + max_concurrency: int | None = None, +) -> FixtureWrapper[FuncT] | Callable[[FuncT], FixtureWrapper[FuncT]]: """Decorator to mark a function as a fixture. Scope is determined at binding time: @@ -103,6 +118,9 @@ def decorator(func: FuncT) -> FixtureWrapper[FuncT]: ) return FixtureWrapper(func, registration) + if _func is not None: + return decorator(_func) + return decorator diff --git a/protest/fixtures/builtins.py b/protest/fixtures/builtins.py index f649c146..63c61da2 100644 --- a/protest/fixtures/builtins.py +++ b/protest/fixtures/builtins.py @@ -8,7 +8,7 @@ from protest.fixtures.mocker import Mocker -@fixture() +@fixture def tmp_path() -> Generator[Path, None, None]: """Provide a temporary directory that is cleaned up after the test. @@ -23,14 +23,14 @@ def test_file_ops(tmp: Annotated[Path, Use(tmp_path)]): yield Path(tmpdir) -@fixture() +@fixture def caplog() -> LogCapture: """Capture log records during a test.""" records = get_current_log_records() return LogCapture(records) -@fixture() +@fixture def mocker() -> Generator[Mocker, None, None]: """Provide a mocker for patching and mocking during tests.""" mock_manager = Mocker()