Skip to content

Testing Reference#

Jac has first-class support for testing built directly into the language. Instead of importing a test framework or following naming conventions, you write test "description" { ... } blocks right alongside your code. These blocks are ignored during normal execution (jac run) and only run when you invoke jac test.

Tests in Jac use the standard assert statement for all checks. Each test block runs in its own isolated context, so state created in one test (including graph nodes connected to root) doesn't leak into another. This isolation means you can test walkers, graph operations, and node behaviors without worrying about test ordering or cleanup -- each test gets a fresh graph.

Graph state and tests

If you encounter NodeAnchor errors when re-running tests, clear stale persisted state with jac clean --all before retrying. The local storage persists graph data between runs, but test isolation resets the in-memory graph for each test block.


Test Syntax#

Basic Test#

test "my feature" {
    # Test body
    assert condition;
}

Test with Setup#

obj MyObject {
    has data: str;

    def process() -> str {
        return self.data;
    }
}

test "object processing" {
    # Setup
    my_obj = MyObject(data="test");

    # Test
    result = my_obj.process();

    # Assert
    assert result == "test";
}

Assertions#

Basic Assert#

test "basic assert" {
    assert condition;
    assert condition, "Error message";
}

Equality#

test "equality checks" {
    assert a == b;           # Equal
    assert a != b;           # Not equal
    assert a is b;           # Same object
    assert a is not b;       # Different objects
}

Comparisons#

test "comparisons" {
    assert a > b;            # Greater than
    assert a >= b;           # Greater or equal
    assert a < b;            # Less than
    assert a <= b;           # Less or equal
}

Boolean#

test "boolean values" {
    assert True;
    assert not False;
    assert bool(value);
}

Membership#

test "membership" {
    assert item in collection;
    assert item not in collection;
    assert key in dictionary;
}

Type Checking#

test "type checking" {
    assert isinstance(obj, MyClass);
    assert type(obj) == MyClass;
}

None Checking#

test "none checking" {
    assert value is None;
    assert value is not None;
}

Float Comparison#

test "float comparison" {
    result = 0.1 + 0.2;
    assert almostEqual(result, 0.3, 10);
}

With Messages#

test "assertions with messages" {
    assert result > 0, f"Expected positive, got {result}";
    assert len(items) == 3, "Should have 3 items";
}

CLI Commands#

Running Tests#

# Run all tests in a file
jac test main.jac

# Run tests in a directory
jac test -d tests/

# Run specific test
jac test main.jac -t my_feature

CLI Options#

Option Short Description
--test_name -t Run specific test by name
--filter -f Filter tests by pattern
--xit -x Exit on first failure
--maxfail -m Stop after N failures
--directory -d Test directory
--verbose -v Verbose output

Examples#

# Verbose output
jac test main.jac -v

# Stop on first failure
jac test main.jac -x

# Filter by pattern
jac test main.jac -f "user_"

# Max failures
jac test -d tests/ -m 3

# Combined
jac test main.jac -t calculator_add -v

File naming

Avoid naming .jac files with a test_ prefix (e.g., test_utils.jac), as this can conflict with Python's module import system. Use descriptive names like utils_tests.jac or my_app.jac instead.


Test Output#

Success#

unittest.case.FunctionTestCase (test_add) ... ok
unittest.case.FunctionTestCase (test_subtract) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Failure#

unittest.case.FunctionTestCase (test_add) ... FAIL

======================================================================
FAIL: test_add
----------------------------------------------------------------------
AssertionError: Expected 5, got 4

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

Testing Patterns#

Testing Objects#

obj Calculator {
    has value: int = 0;

    def add(n: int) -> int {
        self.value += n;
        return self.value;
    }

    def reset() -> None {
        self.value = 0;
    }
}

test "calculator add" {
    calc = Calculator();
    assert calc.add(5) == 5;
    assert calc.add(3) == 8;
    assert calc.value == 8;
}

test "calculator reset" {
    calc = Calculator();
    calc.add(10);
    calc.reset();
    assert calc.value == 0;
}

Testing Nodes and Walkers#

node Counter {
    has count: int = 0;
}

walker Incrementer {
    has amount: int = 1;

    can start with Root entry {
        visit [-->];
    }

    can increment with Counter entry {
        here.count += self.amount;
    }
}

test "walker increments" {
    counter = root ++> Counter();
    root spawn Incrementer();
    assert counter[0].count == 1;
}

test "walker custom amount" {
    counter = root ++> Counter();
    root spawn Incrementer(amount=5);
    assert counter[0].count == 5;
}

Testing Walker Reports#

node Person {
    has name: str;
    has age: int;
}

walker FindAdults {
    can check with Root entry {
        for person in [-->](?:Person) {
            if person.age >= 18 {
                report person;
            }
        }
    }
}

test "find adults" {
    root ++> Person(name="Alice", age=30);
    root ++> Person(name="Bob", age=15);
    root ++> Person(name="Carol", age=25);

    result = root spawn FindAdults();

    assert len(result.reports) == 2;
    names = [p.name for p in result.reports];
    assert "Alice" in names;
    assert "Carol" in names;
    assert "Bob" not in names;
}

Testing Graph Structure#

node Room {
    has name: str;
}

edge Door {}

test "graph connections" {
    kitchen = Room(name="Kitchen");
    living = Room(name="Living Room");
    bedroom = Room(name="Bedroom");

    root ++> kitchen;
    kitchen +>: Door() :+> living;
    living +>: Door() :+> bedroom;

    # Test connections
    assert len([root -->]) == 1;
    assert len([kitchen -->]) == 1;
    assert len([living -->]) == 1;
    assert len([bedroom -->]) == 0;

    # Test connectivity
    assert living in [kitchen ->:Door:->];
    assert bedroom in [living ->:Door:->];
}

Testing Exceptions#

def divide(a: int, b: int) -> float {
    if b == 0 {
        raise ZeroDivisionError("Cannot divide by zero");
    }
    return a / b;
}

test "divide normal" {
    assert divide(10, 2) == 5;
}

test "divide by zero" {
    try {
        divide(10, 0);
        assert False, "Should have raised error";
    } except ZeroDivisionError {
        assert True;  # Expected
    }
}

test "divide negative" {
    assert divide(-10, 2) == -5;
}

Project Organization#

Separate Test Files#

myproject/
├── jac.toml
├── src/
│   ├── models.jac
│   └── walkers.jac
└── tests/
    ├── models_test.jac
    └── walkers_test.jac
# Run all tests
jac test -d tests/

# Run specific file
jac test tests/models_test.jac

Tests in Same File#

# models.jac

obj User {
    has name: str;
    has email: str;

    def is_valid() -> bool {
        return len(self.name) > 0 and "@" in self.email;
    }
}

# Tests at bottom
test "user valid" {
    user = User(name="Alice", email="alice@example.com");
    assert user.is_valid();
}

test "user invalid email" {
    user = User(name="Alice", email="invalid");
    assert not user.is_valid();
}

test "user empty name" {
    user = User(name="", email="alice@example.com");
    assert not user.is_valid();
}

Configuration#

jac.toml#

[test]
directory = "tests"
verbose = true
fail_fast = false
max_failures = 10

JacTestClient#

JacTestClient provides an in-process HTTP client for testing Jac API endpoints without starting a real server or opening network ports.

Import#

from jaclang.runtimelib.testing import JacTestClient

Creating a Client#

# Create from a .jac file
client = JacTestClient.from_file("app.jac")

# With a custom base path (useful for temp directories in tests)
client = JacTestClient.from_file("app.jac", base_path="/tmp/test")

Authentication#

# Register a test user
response = client.register_user("testuser", "password123")

# Login
response = client.login("testuser", "password123")

# Manually set auth token
client.set_auth_token("eyJ...")

# Clear auth
client.clear_auth()

Making Requests#

# GET request
response = client.get("/walker/get_users")

# POST request with JSON body
response = client.post("/walker/create_user", json={"name": "Alice"})

# PUT request
response = client.put("/walker/update_user", json={"name": "Bob"})

# Generic request
response = client.request("DELETE", "/walker/delete_user", json={"id": "123"})

# With custom headers
response = client.get("/walker/data", headers={"X-Custom": "value"})

TestResponse#

Responses from JacTestClient are TestResponse objects:

Property/Method Type Description
status_code int HTTP status code
headers dict Response headers
text str Raw response body
json() dict Parse body as JSON
ok bool True if status is 2xx
data dict \| None Unwrapped data from TransportResponse envelope

Full Example#

import pytest
from jaclang.runtimelib.testing import JacTestClient

def test_task_crud(tmp_path):
    client = JacTestClient.from_file("app.jac", base_path=str(tmp_path))

    # Register and authenticate
    client.register_user("testuser", "password123")

    # Create
    resp = client.post("/walker/CreateTask", json={"title": "My Task"})
    assert resp.status_code == 200
    assert resp.ok

    # Read
    resp = client.post("/walker/GetTasks")
    data = resp.json()
    assert len(data["reports"]) == 1

    # Cleanup
    client.close()

HMR Testing#

Test hot module replacement behavior:

def test_hmr(tmp_path):
    client = JacTestClient.from_file("app.jac", base_path=str(tmp_path))
    client.register_user("user", "pass")

    # Initial state
    resp = client.post("/walker/get_data")
    assert resp.ok

    # Simulate file change and reload
    client.reload()

    # Verify after reload
    resp = client.post("/walker/get_data")
    assert resp.ok

    client.close()

Parameterized Tests#

The parametrize() helper registers one test per parameter, similar to pytest.mark.parametrize. It creates individual test cases from a list of inputs, so each case runs and reports independently.

Import#

import from jaclang.runtimelib.test { parametrize }

Signature#

parametrize(base_name: str, params: Iterable, test_func: Callable, id_fn: Callable | None = None)
Parameter Type Description
base_name str Base name for the generated tests
params Iterable List of parameter values, each passed to the test function
test_func Callable Test function to invoke with each parameter
id_fn Callable \| None Optional function to generate test IDs from each parameter

Usage#

Define a test function that takes a single parameter, then call parametrize() in a with entry block:

import from jaclang.runtimelib.test { parametrize }

def _test_square(pair: tuple) {
    input_val = pair[0];
    expected = pair[1];
    result = input_val ** 2;
    assert result == expected, f"Expected {expected}, got {result}";
}

with entry {
    parametrize(
        "square",
        [(2, 4), (3, 9), (0, 0), (-1, 1)],
        _test_square
    );
}

This registers four tests: square_0, square_1, square_2, square_3.

Custom Test IDs#

Use id_fn to generate descriptive test names:

import from jaclang.runtimelib.test { parametrize }

def _test_parse(raw: str) {
    # test logic
}

with entry {
    parametrize(
        "parse values",
        ["500m", "2", "250"],
        _test_parse,
        id_fn=lambda p: str -> str { return f"input_{p}"; }
    );
}

Best Practices#

1. Descriptive Names#

# Good - use readable descriptions
test "user creation with valid email" { }
test "walker visits all connected nodes" { }

# Avoid - vague or cryptic names
test "t1" { }
test "thing" { }

2. One Focus Per Test#

# Good - focused tests
test "add positive numbers" {
    assert add(2, 3) == 5;
}

test "add negative numbers" {
    assert add(-2, -3) == -5;
}

# Avoid - too broad
test "all math operations" {
    assert add(2, 3) == 5;
    assert subtract(5, 3) == 2;
    assert multiply(2, 3) == 6;
}

3. Isolate Tests#

# Good - creates fresh state
test "counter increment" {
    counter = root ++> Counter();
    root spawn Incrementer();
    assert counter[0].count == 1;
}

# Each test should be independent
test "counter starts at zero" {
    counter = Counter();
    assert counter.count == 0;
}

4. Test Edge Cases#

test "empty list" {
    result = process([]);
    assert result == [];
}

test "single item" {
    result = process([1]);
    assert len(result) == 1;
}

test "large list" {
    result = process(list(range(1000)));
    assert len(result) == 1000;
}

5. Clear Assertions#

# Good - clear what failed
test "calculation with message" {
    result = calculate(input);
    assert result == expected, f"Expected {expected}, got {result}";
}

# Avoid - unclear failures
test "calculation no message" {
    assert calculate(input) == expected;
}