Testing

Kit has built-in support for testing through test blocks and assertion functions. Tests run in the interpreter with kit run.

Overview

Kit's testing approach is simple and integrated directly into the language. You write tests alongside your code using test blocks, and assertions verify expected behavior.

  • Test blocks - Define named test cases with the test keyword
  • Assertions - Verify conditions using assert-* functions
  • Interpreter execution - Tests run with kit run, not kit build
Note: Assertions only work in test blocks and when running with the interpreter (kit run). The compiler (kit build) does not compile tests.

Test Blocks

Use the test keyword to define a named test case:

# Basic test block
test "addition works" =>
  assert-eq! (1 + 1) 2

# Test with multiple assertions
test "math operations" =>
  assert-eq! (2 + 2) 4
  assert-eq! (10 - 3) 7
  assert-eq! (4 * 5) 20
  assert-eq! (15 / 3) 5

# Test with setup
test "list operations" =>
  items = [1, 2, 3, 4, 5]
  assert-eq! (length items) 5
  assert-eq! (head items) 1
  assert-eq! (last items) 5

Test Names

Test names should be descriptive and explain what behavior is being tested:

# Good: descriptive names
test "empty list returns None for head" =>
  assert-eq! (head []) None

test "filter removes non-matching elements" =>
  evens = filter (fn(x) => x % 2 == 0) [1, 2, 3, 4]
  assert-eq! evens [2, 4]

test "map transforms each element" =>
  doubled = map (fn(x) => x * 2) [1, 2, 3]
  assert-eq! doubled [2, 4, 6]

Assertions

Kit provides several assertion functions for different testing needs:

assert-eq!

Assert that two values are equal:

# Basic equality
assert-eq! 42 42
assert-eq! "hello" "hello"
assert-eq! [1, 2, 3] [1, 2, 3]

# Record equality
assert-eq! {x: 1, y: 2} {x: 1, y: 2}

# Result and Option types
assert-eq! (Some 42) (Some 42)
assert-eq! (Ok "success") (Ok "success")

assert-neq!

Assert that two values are not equal:

assert-neq! 1 2
assert-neq! "hello" "world"
assert-neq! [1, 2] [1, 2, 3]

assert-true!

Assert that a condition is true:

assert-true! (5 > 3)
assert-true! (String.contains? "hello" "ell")
assert-true! (List.empty? [])

assert-false!

Assert that a condition is false:

assert-false! (3 > 5)
assert-false! (String.contains? "hello" "xyz")
assert-false! (List.empty? [1, 2, 3])

assert-some!

Assert that an Option contains a value:

assert-some! (Some 42)
assert-some! (List.head [1, 2, 3])
assert-some! (Map.get "key" {key: "value"})

assert-none!

Assert that an Option is None:

assert-none! None
assert-none! (List.head [])
assert-none! (Map.get "missing" {})

assert-ok!

Assert that a Result is Ok:

assert-ok! (Ok 42)
assert-ok! (parse-int "123")

assert-err!

Assert that a Result is Err:

assert-err! (Err "failed")
assert-err! (parse-int "not a number")

Running Tests

Run tests using the interpreter:

# Run a single file with tests
kit run tests/math.test.kit

# Run your main file (if it contains tests)
kit run src/main.kit

Test Output

When tests run, you'll see output indicating success or failure:

# Successful tests
Running tests...
  ✓ addition works
  ✓ math operations
  ✓ list operations

3 tests passed

# Failed test
Running tests...
  ✓ addition works
  ✗ math operations
    Assertion failed: expected 5, got 4
    at line 8 in math.test.kit

1 of 2 tests failed

Organizing Tests

Inline Tests

For simple modules, you can include tests in the same file:

# math.kit

square = fn(x) => x * x

cube = fn(x) => x * x * x

factorial = fn(n) =>
  if n <= 1 then 1
  else n * factorial (n - 1)

# Tests
test "square" =>
  assert-eq! (square 0) 0
  assert-eq! (square 2) 4
  assert-eq! (square 5) 25

test "cube" =>
  assert-eq! (cube 2) 8
  assert-eq! (cube 3) 27

test "factorial" =>
  assert-eq! (factorial 0) 1
  assert-eq! (factorial 1) 1
  assert-eq! (factorial 5) 120

Separate Test Files

For larger projects, keep tests in separate files:

# tests/user.test.kit
import "../src/user.kit"

test "create user" =>
  u = user.create 1 "Alice" "alice@example.com"
  assert-eq! u.id 1
  assert-eq! u.name "Alice"
  assert-eq! u.email "alice@example.com"

test "validate user with valid email" =>
  u = user.create 1 "Alice" "alice@example.com"
  assert-ok! (user.validate u)

test "validate user with invalid email" =>
  u = user.create 1 "Alice" "not-an-email"
  assert-err! (user.validate u)

Project Structure

my-project/
  kit.toml
  src/
    main.kit
    user.kit
    order.kit
  tests/
    user.test.kit
    order.test.kit
    integration.test.kit

Testing Patterns

Testing Pure Functions

test "map transforms list" =>
  input = [1, 2, 3]
  expected = [2, 4, 6]
  result = map (fn(x) => x * 2) input
  assert-eq! result expected

test "filter keeps matching elements" =>
  input = [1, 2, 3, 4, 5, 6]
  expected = [2, 4, 6]
  result = filter (fn(x) => x % 2 == 0) input
  assert-eq! result expected

Testing Error Cases

safe-divide = fn(a, b) =>
  if b == 0 then
    Err "Division by zero"
  else
    Ok (a / b)

test "safe-divide with valid inputs" =>
  assert-eq! (safe-divide 10 2) (Ok 5)
  assert-eq! (safe-divide 15 3) (Ok 5)

test "safe-divide with zero" =>
  result = safe-divide 10 0
  assert-err! result
  match result
  | Err msg -> assert-eq! msg "Division by zero"
  | Ok _ -> assert-false! true  # Should not reach here

Testing Option Returns

test "find returns Some when found" =>
  items = [1, 2, 3, 4, 5]
  result = List.find (fn(x) => x > 3) items
  assert-some! result
  assert-eq! result (Some 4)

test "find returns None when not found" =>
  items = [1, 2, 3]
  result = List.find (fn(x) => x > 10) items
  assert-none! result

Testing Records

test "record creation and access" =>
  point = {x: 10, y: 20}
  assert-eq! point.x 10
  assert-eq! point.y 20

test "record update" =>
  point = {x: 10, y: 20}
  moved = {x: point.x + 5, y: point.y + 5}
  assert-eq! moved {x: 15, y: 25}

Testing Pattern Matching

type Shape =
  | Circle Float
  | Rectangle Float Float
  | Square Float

area = fn(shape) =>
  match shape
  | Circle r -> 3.14159 * r * r
  | Rectangle w h -> w * h
  | Square s -> s * s

test "area of circle" =>
  a = area (Circle 2.0)
  assert-true! (a > 12.56 && a < 12.57)

test "area of rectangle" =>
  assert-eq! (area (Rectangle 4.0 5.0)) 20.0

test "area of square" =>
  assert-eq! (area (Square 3.0)) 9.0

Testing with Setup

# Helper function for test setup
make-test-users = fn() =>
  [
    {id: 1, name: "Alice", active?: true},
    {id: 2, name: "Bob", active?: false},
    {id: 3, name: "Carol", active?: true}
  ]

test "filter active users" =>
  users = make-test-users
  active = filter (fn(u) => u.active?) users
  assert-eq! (length active) 2

test "find user by id" =>
  users = make-test-users
  result = List.find (fn(u) => u.id == 2) users
  assert-some! result
  match result
  | Some u -> assert-eq! u.name "Bob"
  | None -> assert-false! true

Best Practices

Write Focused Tests

# Good: One concept per test
test "map preserves list length" =>
  input = [1, 2, 3, 4, 5]
  result = map (fn(x) => x * 2) input
  assert-eq! (length result) (length input)

test "map applies function to each element" =>
  result = map (fn(x) => x * 2) [1, 2, 3]
  assert-eq! result [2, 4, 6]

# Bad: Too many concepts in one test
test "map does everything" =>
  input = [1, 2, 3]
  result = map (fn(x) => x * 2) input
  assert-eq! (length result) 3
  assert-eq! result [2, 4, 6]
  assert-eq! (head result) 2
  assert-eq! (last result) 6

Test Edge Cases

test "map on empty list" =>
  assert-eq! (map (fn(x) => x * 2) []) []

test "map on single element" =>
  assert-eq! (map (fn(x) => x * 2) [5]) [10]

test "factorial of zero" =>
  assert-eq! (factorial 0) 1

test "factorial of one" =>
  assert-eq! (factorial 1) 1

Use Descriptive Names

# Good: Describes the scenario and expected behavior
test "parse-int returns Err for non-numeric string" =>
  assert-err! (parse-int "hello")

test "parse-int returns Ok for valid integer" =>
  assert-eq! (parse-int "42") (Ok 42)

# Bad: Vague names
test "test 1" =>
  assert-err! (parse-int "hello")

test "parse-int works" =>
  assert-eq! (parse-int "42") (Ok 42)

Keep Tests Independent

# Good: Each test creates its own data
test "filter removes items" =>
  items = [1, 2, 3, 4, 5]
  result = filter (fn(x) => x > 3) items
  assert-eq! result [4, 5]

test "filter on empty list" =>
  items = []
  result = filter (fn(x) => x > 3) items
  assert-eq! result []

Property-Based Testing

For more advanced testing, Kit provides the kit-quickcheck package for property-based testing. Instead of writing individual test cases, you describe properties that should hold for all inputs, and QuickCheck generates random test cases automatically.

import "kit-quickcheck"

# Property: reversing a list twice gives the original
test "reverse is self-inverse" =>
  QuickCheck.for-all gen-list (fn(xs) =>
    reverse (reverse xs) == xs
  )

# Property: length is preserved by map
test "map preserves length" =>
  QuickCheck.for-all gen-list (fn(xs) =>
    length (map (fn(x) => x * 2) xs) == length xs
  )

# Property: sorting is idempotent
test "sort is idempotent" =>
  QuickCheck.for-all gen-list (fn(xs) =>
    sort xs == sort (sort xs)
  )

Property-based testing is particularly effective for finding edge cases and unexpected bugs that might not be caught by hand-written tests. See the kit-quickcheck documentation for more details.

Next Steps

Now that you understand testing, explore: