Skip to content

std.testing standard library guide

This page covers what std.testing provides and how to use it in your tests.

If you want CLI usage (incan test, discovery, flags), see Tooling: Testing. If you want the API reference only, see Language reference: Testing.

Assertion helpers

from std.testing import assert, assert_eq, assert_ne, assert_true, assert_false, fail
from std.testing import assert_is_some, assert_is_none, assert_is_ok, assert_is_err
Function Fails when Returns
assert(condition, msg?) condition is false
assert_true(condition, msg?) condition is false
assert_false(condition, msg?) condition is true
assert_eq(left, right, msg?) left != right
assert_ne(left, right, msg?) left == right
assert_is_some(option, msg?) option is None T
assert_is_none(option, msg?) option is Some(...)
assert_is_ok(result, msg?) result is Err(...) T
assert_is_err(result, msg?) result is Ok(...) E
fail(msg) Always (unconditional failure)

All msg parameters are optional. When omitted, a sensible default message is used.

Assert statement syntax (planned)

Incan will support assert as a statement keyword. When available, the mapping will be:

Statement Desugars to
assert cond std.testing.assert(cond)
assert a == b std.testing.assert_eq(a, b)
assert a != b std.testing.assert_ne(a, b)
assert opt is Some(v) v = std.testing.assert_is_some(opt)
assert opt is None std.testing.assert_is_none(opt)
assert res is Ok(v) v = std.testing.assert_is_ok(res)
assert res is Err(e) e = std.testing.assert_is_err(res)

Until then, use the function-call forms directly.

Markers and decorators

Test markers control how incan test discovers and runs tests:

Decorator Effect
@skip(reason?) Skips the test unconditionally.
@xfail(reason?) Marks the test as expected to fail (XPASS if it passes).
@slow Excludes the test by default; include with incan test --slow.
@fixture Declares a test fixture (see below).
@parametrize(argnames, argvalues) Runs the test once per parameter set.

Fixtures

The problem fixtures solve

Tests often need some shared setup — a database connection, a temporary file, a logged-in user. Without fixtures you end up repeating that setup in every test:

def test_query_users() -> None:
    db = Database.connect("test.db")    # repeated setup
    result = db.query("SELECT * FROM users")
    assert_eq(len(result), 3)
    db.close()                          # repeated teardown

def test_insert_user() -> None:
    db = Database.connect("test.db")    # same setup, again
    db.insert("users", {"name": "Alice"})
    assert_eq(db.count("users"), 4)
    db.close()                          # same teardown, again

This is tedious, error-prone (forget one db.close() and you leak a connection), and makes the actual test logic harder to spot.

Declaring a fixture

A fixture is a function decorated with @fixture that produces a value your tests can reuse.

Mark it with the @fixture decorator and return (or yield) the value:

from std.testing import fixture

@fixture
def database() -> Database:
    return Database.connect("test.db")

Using a fixture in a test

To use a fixture, add a parameter to your test function whose name matches the fixture function. The test runner sees the matching name, calls the fixture, and passes the result in automatically:

def test_query_users(database: Database) -> None:
    result = database.query("SELECT * FROM users")
    assert_eq(len(result), 3)

You don't call the fixture yourself — incan test handles that. The parameter name database is what connects the test to the database() fixture.

Teardown with yield

If your fixture needs cleanup after the test finishes, use yield instead of return. Everything before yield is setup; everything after is teardown:

@fixture
def database() -> Database:
    db = Database.connect("test.db")
    yield db          # <-- test receives `db` here
    db.close()        # <-- runs after the test finishes, even if it failed

This guarantees cleanup runs regardless of whether the test passes or fails — no more leaked connections or orphaned temp files.

Fixtures using other fixtures

Fixtures can depend on other fixtures, just like tests do. Use the same name-matching pattern:

@fixture
def database() -> Database:
    db = Database.connect("test.db")
    yield db
    db.close()

@fixture
def populated_db(database: Database) -> Database:
    database.insert("users", {"name": "Alice"})
    database.insert("users", {"name": "Bob"})
    return database

def test_user_count(populated_db: Database) -> None:
    assert_eq(populated_db.count("users"), 2)

The test runner resolves the dependency chain for you: populated_db needs database, so database() runs first, then its result is passed into populated_db().

Fixture scopes

By default, a fixture is created and torn down for each test that uses it. If the setup is expensive (e.g., a database connection or a network client), you can share it across a wider scope with the scope argument:

@fixture(scope="module")
def shared_client() -> Client:
    client = Client.connect("https://api.example.com")
    yield client
    client.disconnect()
Scope Lifetime
"function" (the default) Created and torn down for each test.
"module" Shared across all tests in one test file.
"session" Shared across the entire test session.

Choose the narrowest scope that makes sense — "function" keeps tests fully isolated, while wider scopes trade isolation for speed. You have to decide what is best for your test suite and your use case.

Parametrized tests

When you want to test the same logic with different inputs, you could write a separate test for each case:

def test_add_positive() -> None:
    assert_eq(add(1, 2), 3)

def test_add_zeros() -> None:
    assert_eq(add(0, 0), 0)

def test_add_negative() -> None:
    assert_eq(add(-1, 1), 0)

This works, but the test logic is identical every time — only the data changes. @parametrize lets you write the logic once and supply a table of inputs:

from std.testing import parametrize

@parametrize("x, y, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add(x: int, y: int, expected: int) -> None:
    assert_eq(add(x, y), expected)

The first argument is a comma-separated string of parameter names. The second is a list of tuples — one tuple per test case. Each tuple is unpacked into the named parameters.

The test runner generates a separate test case per tuple, with the values shown in the test ID:

test_add[1-2-3] ... PASSED
test_add[0-0-0] ... PASSED
test_add[-1-1-0] ... PASSED

Adding a new case is just one more tuple — no new function needed.

Full example

from std.testing import assert_eq, assert_true, assert_is_some, fixture, skip

@fixture
def database() -> Database:
    db = Database.connect("test.db")
    yield db
    db.close()

def add(a: int, b: int) -> int:
    return a + b

def find_user(name: str) -> Option[str]:
    if name == "alice":
        return Some("alice@example.com")
    return None

def test_add() -> None:
    assert_eq(add(2, 3), 5)
    assert_true(add(1, 1) == 2)

def test_find_user() -> None:
    email = assert_is_some(find_user("alice"))
    assert_eq(email, "alice@example.com")

@skip("not implemented yet")
def test_future_feature() -> None:
    pass