Skip to content

Writing tests

Karva collects module-level Python functions whose names start with test by default. Test files do not need a test_ prefix: Karva searches every .py file under the selected paths and ignores files that contain no tests.

tests/check_calculator.py
1
2
def test_addition():
    assert 1 + 2 == 3

Run the whole project, a directory, a file, or one function:

Bash
1
2
3
4
uv run karva test
uv run karva test tests/
uv run karva test tests/check_calculator.py
uv run karva test tests/check_calculator.py::test_addition

Use --test-prefix or test-function-prefix in configuration when your suite uses another naming convention. Karva does not collect class methods; see Project Non-Goals.

Project and path discovery

With no path arguments, Karva searches upward from the current directory for karva.toml or a [tool.karva] table in pyproject.toml, then uses that directory as the project root. If no Karva configuration exists, it uses the nearest plain pyproject.toml, or the current directory when neither exists. Discovery does not cross a .git boundary.

Karva respects .gitignore files by default. Pass --no-ignore to include Git-ignored Python files, or set respect-ignore-files = false under the profile's src configuration.

Test results

A passing test returns None, either explicitly or by reaching the end of the function. Returning any other value fails the test with a diagnostic; use an assertion instead.

Generator test functions are rejected before their bodies run. Use @karva.tags.parametrize to create multiple cases. Generator fixtures remain supported for setup and teardown.

Async tests and fixtures

Async tests run without a plugin or marker:

tests/test_service.py
1
2
3
4
5
6
import asyncio


async def test_service():
    result = await asyncio.sleep(0, result=42)
    assert result == 42

Fixtures may also be async functions or async generators. Sync and async tests can consume either sync or async fixtures:

tests/test_service.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import karva


@karva.fixture
async def service():
    client = await create_client()
    yield client
    await client.close()


def test_sync_code_can_use_async_fixture(service):
    assert service.is_ready()


async def test_async_code_can_use_async_fixture(service):
    assert await service.healthcheck()

Async generator fixture teardown is awaited after its consumer finishes, just like teardown after yield in a sync fixture.

Background tasks

Work started with asyncio.create_task runs alongside the test. If it raises and nothing awaits it, the exception never reaches the test coroutine, so Python reports it to the event loop instead of propagating it. Karva watches for those reports and fails the test:

tests/test_service.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import asyncio


async def refresh_cache():
    raise RuntimeError("refresh failed")


async def test_background_refresh():
    asyncio.create_task(refresh_cache())
    await asyncio.sleep(0)
Text Only
1
2
error[test-failure]: Test `test_background_refresh` failed
info: Unhandled exception in background task: Task-2: RuntimeError: refresh failed

Every unretrieved failure is reported, not only the first. The same watch covers async fixture setup and teardown, so a task started by a fixture is attributed to that fixture.

A failure the test deals with itself is not reported. Awaiting the task, reading task.exception(), or collecting it through asyncio.gather(..., return_exceptions=True) all count as handling it. Tasks still pending when the test returns are cancelled cleanly during shutdown and do not fail the test:

Python
1
2
3
4
async def test_handled_background_failure():
    task = asyncio.create_task(refresh_cache())
    with karva.raises(RuntimeError):
        await task