Do testing and stuff
Project description
snektest
A type-safe, async-native Python testing framework.
Installation
uv add snektest
Type checking is part of the contract
snektest expects your test code to pass a strict static type checker (such as
pyright) before tests run — typically continuously, via your editor's language
server. The API is designed around this: signatures are exact, and snektest
does not re-validate at runtime what a type checker already rejects. If you
skip type checking, misuse that a checker would flag — such as applying
@test without parentheses — can fail silently at runtime.
Runtime validation is reserved for what static checkers cannot see: CLI input, file paths, and fixture protocol rules (for example, session fixtures must not accept parameters).
Quick Start
Create a test_*.py file. The recommended style is to mark every test with the resources it may use:
from collections.abc import AsyncGenerator
from snektest import assert_eq, fixture, load_fixture, test
@fixture
async def provide_number() -> AsyncGenerator[int]:
yield 2
@test(mark="fast")
async def test_basic_math() -> None:
given_number = await load_fixture(provide_number())
result = given_number * 2
assert_eq(result, 4)
@test(mark="fast")
def test_strings() -> None:
assert_eq("hello".upper(), "HELLO")
Run your tests:
snektest
Run one marker group when you want focused feedback:
snektest --mark fast
Features
Fixtures
Define fixtures as generator functions decorated with @fixture, annotated
Generator[T] or AsyncGenerator[T]. @fixture (the default) is
function-scoped: set up and torn down for each test. @fixture(scope="session")
is set up once and reused across the run. Calling a decorated fixture returns a
handle; pass it to load_fixture(). Fixtures may take arguments, passed at the
call site (e.g. load_fixture(make_user("Ada"))), and calling one twice yields
two independent instances. Session fixtures must not accept parameters because
they are cached once per fixture function. Use a function fixture for
parameter-dependent setup, or have a zero-argument session fixture return a
factory/cache.
Set up and tear down test dependencies with session-scoped fixtures:
from collections.abc import AsyncGenerator
from snektest import assert_eq, fixture, load_fixture, test
@fixture(scope="session")
async def connection_pool() -> AsyncGenerator[dict[str, str]]:
# Setup: runs once for all tests
pool = {"host": "localhost", "status": "connected"}
yield pool
# Teardown: runs after all tests
pool["status"] = "disconnected"
@test(mark="fast")
async def test_connection() -> None:
pool = await load_fixture(connection_pool())
assert_eq(pool["status"], "connected")
A fixture handle is also a context manager, so fixtures double as setup helpers
in standalone scripts (no runner needed): with user_fixture() as user: ... or
async with connection_pool() as pool: .... In standalone use there is no
runner, so scope is ignored and each block does its own setup and teardown.
Load fixtures at the beginning of each test, before actions or assertions. This keeps fixture setup unconditional, makes teardown ownership obvious, and avoids hiding fixture setup behind an earlier assertion failure or branch. Only load a fixture later in a test when delayed fixture loading is the behavior being tested.
Rich Assertions
Get helpful error messages with custom assertions:
from snektest import assert_eq, test
@test(mark="fast")
def test_show_dict_diff() -> None:
assert_eq({"name": "alice", "age": 30}, {"name": "bob", "age": 30})
E {'name': 'alice', 'age': 30} != {'name': 'bob', 'age': 30}
E - {'age': 30, 'name': 'bob'}
E ? ^^^
E + {'age': 30, 'name': 'alice'}
E ? ^^^^^
from snektest import assert_in, test
@test(mark="fast")
def test_show_in_assertion() -> None:
assert_in("qux", ["foo", "bar", "baz"])
E 'qux' not found in ['foo', 'bar', 'baz']
Async Support
Write async tests as naturally as sync ones:
import asyncio
import time
from snektest import assert_eq, test
@test(mark="fast")
def test_sync_operation() -> None:
time.sleep(0.1)
result = "completed"
assert_eq(result, "completed")
@test(mark="fast")
async def test_async_operation() -> None:
await asyncio.sleep(0.1)
result = "completed"
assert_eq(result, "completed")
Parameterized Tests
Run the same test with different inputs:
from snektest import Param, assert_eq, test
@test(
[
Param(value="hello", name="lowercase"),
Param(value="WORLD", name="uppercase"),
Param(value="MiXeD", name="mixed"),
],
mark="fast",
)
def test_string_length(value: str) -> None:
assert_eq(len(value), 5)
# Test with multiple parameter combinations (cartesian product)
@test(
[Param(value="hello", name="hello"), Param(value="hi", name="hi")],
[Param(value=" world", name="world"), Param(value=" there", name="there")],
mark="fast",
)
def test_concatenation(greeting: str, target: str) -> None:
result = greeting + target
assert_eq(result[0], greeting[0])
Static Type Checking
Snektest's public decorators and helpers are typed so test parameters, fixtures, and Hypothesis strategies can be checked by tools such as pyright.
Running Tests
# Run all tests
snektest
# Run specific file
snektest tests/test_myfeature.py
# Run specific test
snektest tests/test_myfeature.py::test_something
# If an explicit test name or parameter case is not found, snektest exits with an error.
# Run tests with a marker
snektest --mark fast
# Disable stdout/stderr capture
snektest -s
# Print machine-readable JSON summary
snektest --json-output
# Print AI-agent usage guide
snektest --agent-docs
python -m snektest --agent-docs
# List or print bundled examples
snektest --examples
snektest --example async
# Drop into post-mortem debugging on first failure
snektest --pdb
# Run with coverage.py
coverage run -m snektest
Human-readable summary lines are compact: exception details keep only the first
line and long lines may be truncated with an ellipsis. Full failure details and
tracebacks are printed earlier in the output. Use --json-output for a pure
machine-readable summary with per-test exception messages.
When --pdb is set, snektest enters a post-mortem debugger on the first test
failure or fixture error (setup/teardown), and stops executing further tests.
Execution Model
Tests run sequentially — one at a time, in collection order — on a single asyncio event loop shared by the entire run. An async test is awaited to completion before the next test starts, so tests never interleave with each other; a background task a test starts but does not await can, however, keep running on the shared loop while later tests execute.
Test collection runs in a background thread while tests execute, so a test module may be imported while tests from earlier modules are already running. Avoid import-time side effects in test modules.
Teardown is last-in-first-out: function fixtures are torn down after each test in reverse loading order, and session fixtures are torn down after all tests finish in reverse registration order.
Marking Tests
Use the mark argument on @test() to attach built-in marker metadata for filtering. Marking tests is the recommended way to use snektest: every test should declare whether it is "fast", "medium", or "slow".
Marker is a type alias for those three literal strings. Markers must be passed as a single marker literal.
Markers describe the resources a test may use, not how long it is expected to take:
fast means the test runs entirely in memory, without IO, threads, or
subprocesses.
medium means the test may use local IO or threads, but not network IO or
subprocesses.
slow means the test may use network IO, subprocesses, or other expensive
external resources.
from snektest import test
@test(mark="slow")
def test_integration() -> None:
pass
@test(mark="fast")
def test_unit() -> None:
pass
Use --mark fast, --mark medium, or --mark slow to run one marker group.
Property-Based Testing with Hypothesis
Snektest provides first-class integration with Hypothesis for property-based testing. Property-based tests automatically generate test cases to explore edge cases and verify properties that should hold for all inputs.
Basic Usage
Use the @test_hypothesis(..., mark=...) decorator with Hypothesis strategies to automatically generate marked test inputs:
from hypothesis import strategies as st
from snektest import assert_ge, test_hypothesis
@test_hypothesis(st.integers(), mark="fast")
async def test_absolute_value_is_non_negative(x: int) -> None:
result = abs(x)
assert_ge(result, 0)
Multiple Strategies
Pass multiple strategies for functions with multiple parameters:
from hypothesis import strategies as st
from snektest import assert_eq, test_hypothesis
@test_hypothesis(st.text(), st.text(), mark="fast")
async def test_string_concatenation_length(s1: str, s2: str) -> None:
result = s1 + s2
assert_eq(len(result), len(s1) + len(s2))
Async Function Support
Property-based tests work seamlessly with async functions. Snektest automatically handles the complexity of running Hypothesis by executing the Hypothesis engine in a worker thread and scheduling each generated test case back onto the main event loop:
import asyncio
from hypothesis import strategies as st
from snektest import assert_eq, assert_true, test_hypothesis
@test_hypothesis(st.integers(min_value=0, max_value=100), mark="fast")
async def test_async_computation(n: int) -> None:
# Simulate async operation
await asyncio.sleep(0.001)
result = n * 2
assert_true(result >= 0)
assert_eq(result % 2, 0)
Configuring Hypothesis
Use Hypothesis's @settings() decorator to configure test behavior. Apply it above or below @test_hypothesis():
from hypothesis import settings, strategies as st
from snektest import assert_isinstance, test_hypothesis
@settings(max_examples=500, deadline=None)
@test_hypothesis(st.lists(st.integers()), mark="fast")
async def test_list_operations(numbers: list[int]) -> None:
reversed_twice = list(reversed(list(reversed(numbers))))
assert_isinstance(reversed_twice, list)
Type Safety
The decorator provides full type safety - strategy types are checked against function parameters:
from hypothesis import strategies as st
from snektest import test_hypothesis
# ✓ This type-checks correctly
@test_hypothesis(st.integers(), st.text(), mark="fast")
async def test_correct_types(x: int, s: str) -> None:
pass
# ✗ This will fail type checking - int strategy doesn't match str parameter
@test_hypothesis(st.integers(), mark="fast")
async def test_wrong_type(x: str) -> None: # Type error!
pass
Combining with Traditional Tests
You can mix property-based tests with traditional example-based tests in the same file:
from hypothesis import strategies as st
from snektest import Param, assert_eq, test, test_hypothesis
# Property-based test
@test_hypothesis(st.integers(), st.integers(), mark="fast")
async def test_addition_commutative(a: int, b: int) -> None:
assert_eq(a + b, b + a)
# Traditional parameterized test
@test(
[
Param(value=(2, 3, 5), name="small"),
Param(value=(100, 200, 300), name="large"),
],
mark="fast",
)
async def test_addition_specific_cases(values: tuple[int, int, int]) -> None:
a, b, expected = values
assert_eq(a + b, expected)
Assertions Reference
All assertion functions are importable from snektest and accept an optional
msg keyword argument for custom error messages.
Assertion argument order is intentional. Pass the observed/computed value first
and the expected/reference value second, following the parameter names in each
signature: assert_eq(actual, expected), assert_in(member, container),
assert_isinstance(obj, classinfo), and assert_len(obj, expected_length).
Value and Comparison Assertions
assert_eq(actual, expected)— assert thatactual == expectedassert_ne(actual, expected)— assert thatactual != expectedassert_true(value)— assert thatvalue is Trueassert_false(value)— assert thatvalue is Falseassert_is_none(value)— assert thatvalue is Noneassert_is_not_none(value)— assert thatvalue is not Noneassert_is(actual, expected)— assert thatactual is expectedassert_is_not(actual, expected)— assert thatactual is not expectedassert_lt(actual, expected)— assert thatactual < expectedassert_gt(actual, expected)— assert thatactual > expectedassert_le(actual, expected)— assert thatactual <= expectedassert_ge(actual, expected)— assert thatactual >= expectedassert_in(member, container)— assert thatmember in containerassert_not_in(member, container)— assert thatmember not in containerassert_isinstance(obj, classinfo)— assert thatisinstance(obj, classinfo)is true;classinfomay be a tuple of typesassert_not_isinstance(obj, classinfo)— assert thatisinstance(obj, classinfo)is falseassert_len(obj, expected_length)— assert thatlen(obj) == expected_length
Exception Assertions
assert_raises(*expected_exceptions, msg=None) - Assert that code raises an expected exception
Use as a context manager to verify that a specific exception is raised:
from snektest import assert_eq, assert_raises, test
@test(mark="fast")
def test_division_by_zero() -> None:
with assert_raises(ZeroDivisionError):
_ = 1 / 0
@test(mark="fast")
def test_multiple_exception_types() -> None:
# Can accept multiple exception types
with assert_raises(ValueError, TypeError):
_ = int("not a number")
@test(mark="fast")
def test_access_exception() -> None:
# Access the caught exception via the exception property
with assert_raises(ValueError) as exc_info:
raise ValueError("custom message")
assert_eq(exc_info.exception.args[0], "custom message")
Unconditional Failure
fail(msg=None) - Raise an AssertionFailure unconditionally
from snektest import fail, test
@test(mark="fast")
def test_unreachable() -> None:
if False:
pass
else:
fail("This code path should never execute")
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file snektest-0.16.0.tar.gz.
File metadata
- Download URL: snektest-0.16.0.tar.gz
- Upload date:
- Size: 69.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b3723cd42b6bb7fded4fb7be3cd7aeb23b68788e1ee69460ba01a75074c23a51
|
|
| MD5 |
b737c3c1a044afcc1aeafdd7107c3c7f
|
|
| BLAKE2b-256 |
41c1da3edd9ab955d19821486a35d168e177c31fc71079d3225bb5b8ca768e3b
|
File details
Details for the file snektest-0.16.0-py3-none-any.whl.
File metadata
- Download URL: snektest-0.16.0-py3-none-any.whl
- Upload date:
- Size: 41.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ad9469d28dc7589746dedf302e35e6aa3bf37437ec83d5048457877722888ab1
|
|
| MD5 |
0ca34344ad0c0387c8d3e1028c0003e7
|
|
| BLAKE2b-256 |
115344d102d3c9abeb06bf1e0041d7f02b089ee2834dd048f17cbc158e1943a8
|