Skip to content

Tests & Groups

scaf provides a hierarchical structure for organizing tests: scopes group tests by function, groups organize related scenarios, and tests define individual cases.

Every test must be inside a scope that matches a defined function:

fn GetUser(userId) `
MATCH (u:User {id: $userId})
RETURN u.name, u.email
`
// Scope name must match function name
GetUser {
test "finds user" {
$userId: 1
u.name: "Alice"
}
}

You can have multiple scopes in one file:

fn GetUser(userId) `...`
fn CreateUser(name, email) `...`
fn DeleteUser(userId) `...`
GetUser {
test "finds user" { ... }
}
CreateUser {
test "creates user" { ... }
}
DeleteUser {
test "deletes user" { ... }
}

Groups organize related tests within a scope:

GetUser {
group "existing users" {
test "finds Alice" {
$userId: 1
u.name: "Alice"
}
test "finds Bob" {
$userId: 2
u.name: "Bob"
}
}
group "edge cases" {
test "missing user" {
$userId: 999
u.name: null
}
}
}

Groups can be nested for deeper organization:

GetUser {
group "by role" {
group "admin users" {
test "admin with full access" {
$userId: 1
u.role: "admin"
u.canDelete: true
}
}
group "regular users" {
test "user with limited access" {
$userId: 2
u.role: "user"
u.canDelete: false
}
}
}
}

Groups can have their own setup that applies to all contained tests:

GetUser {
group "with posts" {
setup `
CREATE (u:User {id: 1, name: "Alice"})
CREATE (p1:Post {id: 1, authorId: 1, title: "Post 1"})
CREATE (p2:Post {id: 2, authorId: 1, title: "Post 2"})
`
test "user has posts" {
$userId: 1
u.name: "Alice"
}
test "post count is correct" {
$userId: 1
// ...
}
}
}

Tests are the core unit. Each test specifies:

Prefixed with $:

test "finds user by id" {
$userId: 1
$includeDeleted: false
// ...
}

Field paths and their expected values:

test "returns all user fields" {
$userId: 1
u.name: "Alice"
u.email: "alice@example.com"
u.age: 30
u.verified: true
u.tags: ["admin", "beta-tester"]
u.metadata: {theme: "dark", notifications: true}
}

Tests can have their own setup:

test "with temp data" {
setup `CREATE (:TempNode {id: 999})`
$userId: 1
u.name: "Alice"
}

Expression-based or query-based validations:

test "user meets requirements" {
$userId: 1
u.name: "Alice"
assert {
(u.age >= 18)
(u.verified == true)
}
}

Tests inherit setup from all ancestors:

// 1. Suite setup runs first
setup `CREATE (:Config {key: "value"})`
GetUser {
// 2. Scope setup runs second
setup `CREATE (:User {id: 1, name: "Alice"})`
group "with posts" {
// 3. Group setup runs third
setup `CREATE (:Post {authorId: 1})`
test "user with post" {
// 4. Test setup runs last
setup `CREATE (:Comment {postId: 1})`
$userId: 1
u.name: "Alice"
}
}
}

The execution order is: Suite → Scope → Group → Test.

Each test runs in its own transaction that rolls back after completion:

CountUsers {
test "base count is 2" {
count: 2
}
test "temp user in transaction" {
setup `CREATE (:User {id: 999})`
count: 3 // Sees the temp user
}
test "still 2 after rollback" {
count: 2 // Previous test's data was rolled back
}
}

Teardown blocks run in reverse order after tests complete:

setup `CREATE (:A)`
teardown `MATCH (a:A) DELETE a`
GetUser {
setup `CREATE (:B)`
teardown `MATCH (b:B) DELETE b`
test "example" {
// After this test:
// 1. Scope teardown runs (delete B)
// 2. Suite teardown runs (delete A)
}
}

Test names should be descriptive:

// Good: Describes the scenario and expectation
test "finds existing user by id" { ... }
test "returns null for non-existent user" { ... }
test "validates email format on creation" { ... }
// Avoid: Vague or technical names
test "test1" { ... }
test "user query" { ... }
test "happy path" { ... }

Group names should describe the category:

// Good: Clear category names
group "existing users" { ... }
group "authentication" { ... }
group "error handling" { ... }
// Avoid: Overly generic
group "tests" { ... }
group "cases" { ... }

Each test has a unique path: FunctionScope/Group/Test:

GetUser {
group "existing users" {
test "finds Alice" { ... } // Path: GetUser/existing users/finds Alice
}
test "missing user" { ... } // Path: GetUser/missing user
}

This path is used for:

  • Test filtering with --run
  • Verbose output
  • Neotest integration

Run specific tests using the --run flag:

Terminal window
# Run all tests in GetUser scope
scaf test --run="GetUser"
# Run tests in a specific group
scaf test --run="GetUser/existing users"
# Run a specific test
scaf test --run="GetUser/existing users/finds Alice"
# Pattern matching
scaf test --run="*/edge cases/*"
  1. One assertion per concept — Keep tests focused

  2. Descriptive names — Names should explain the scenario

  3. Use groups for organization — Group related scenarios together

  4. Leverage setup inheritance — DRY up common setup

  5. Test edge cases — Include null, missing, and invalid inputs

GetUser {
group "happy path" {
test "finds user with all fields" {
$userId: 1
u.name: "Alice"
u.email: "alice@example.com"
}
}
group "edge cases" {
test "missing user returns null" {
$userId: 999
u.name: null
}
test "handles negative id" {
$userId: -1
u.name: null
}
}
group "validation" {
test "age must be positive" {
$userId: 1
assert { (u.age > 0) }
}
}
}