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 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#
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#
Membership#
test "membership" {
assert item in collection;
assert item not in collection;
assert key in dictionary;
}
Type Checking#
None Checking#
Float Comparison#
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
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#
JacTestClient#
JacTestClient provides an in-process HTTP client for testing Jac API endpoints without starting a real server or opening network ports.
Import#
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#
Signature#
| 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;
}