Testing

I built a testing framework directly into the language because I got tired of the alternative — external test frameworks, separate test files, complex setup to simulate your runtime environment. Tests in Adama live next to the code they test, run during compilation, and have full access to document internals.

If a test fails, the build fails. No separate CI step needed (though you can still have one).

Test Blocks

Define tests using the test keyword followed by a name and a code block:

test MyTestName {
  // Test code here
  assert true;
}

Each test starts with a fresh document instance (as if just constructed), runs in isolation, and can modify state without affecting other tests.

Multiple Tests

int counter = 0;

@construct {
  counter = 10;
}

test InitialValue {
  assert counter == 10;
}

test Modification {
  counter = 20;
  assert counter == 20;
}

test StillInitial {
  // Each test starts fresh
  assert counter == 10;
}

Each test gets its own document. Modifications in one test don't bleed into others. This was a deliberate decision — I wanted tests to be order-independent.

Assertions

The assert statement verifies that a condition is true. If it's false, the test fails:

test BasicAssertions {
  assert true;           // Passes
  assert 1 + 1 == 2;     // Passes
  assert "hello".length() == 5;  // Passes
}

Assertion Expressions

Any boolean expression works with assert:

test ComparisonAssertions {
  int x = 5;
  int y = 10;

  assert x < y;
  assert x != y;
  assert x + y == 15;
  assert x * 2 == y;
}

test StringAssertions {
  string s = "hello";

  assert s == "hello";
  assert s != "world";
  assert s.length() == 5;
  assert "xy" == "x" + "y";
}

test LogicAssertions {
  bool a = true;
  bool b = false;

  assert a;
  assert !b;
  assert a || b;
  assert !(a && b);
}

Negation

Use ! to assert that something is false:

test NegationAssertions {
  assert !false;
  assert !(1 == 2);
  assert !("a" == "b");
}

Maybe Type Assertions

Test maybe values using .has():

maybe<int> value;

test MaybeAssertions {
  assert !value.has();  // Initially empty

  value = 42;
  assert value.has();   // Now has a value

  if (value as v) {
    assert v == 42;
  }

  value.delete();
  assert !value.has();  // Empty again
}

The @step Directive

The @step directive advances document execution by one step. You need this for testing state machine transitions and deferred execution — without it, transitions are scheduled but not yet executed:

int x = 1;

@construct {
  transition #bump;
}

#bump {
  x++;
  transition #done;
}

#done {
  // Final state
}

test SteppingExecution {
  // After construction, transition is scheduled but not yet executed
  assert x == 1;

  @step;  // Execute the transition to #bump

  assert x == 2;  // x was incremented in #bump
}

Multiple Steps

Chain multiple @step calls to advance through workflows:

string status = "init";

@construct {
  transition #step1;
}

#step1 {
  status = "step1";
  transition #step2;
}

#step2 {
  status = "step2";
  transition #step3;
}

#step3 {
  status = "complete";
}

test MultipleSteps {
  assert status == "init";

  @step;
  assert status == "step1";

  @step;
  assert status == "step2";

  @step;
  assert status == "complete";
}

The @pump Directive

The @pump directive sends a message to a channel, simulating client input. This is how you test channel handlers and async workflows:

@pump {field: value, ...} into channel_name;

Testing Complete Channels

For channels with handlers, pump a message and step to execute:

message Input {
  int x;
  int y;
}

int result;

channel process(Input msg) {
  result = msg.x + msg.y;
}

test ChannelHandler {
  assert result == 0;

  @pump {x: 10, y: 20} into process;
  @step;

  assert result == 30;
}

Testing Incomplete Channels (Async)

For async workflows using incomplete channels and fetch, pump provides the response:

message Response {
  int value;
}

channel<Response> respond;

int captured;

@construct {
  transition #waiting;
}

#waiting {
  future<Response> f = respond.fetch(@no_one);
  Response r = f.await();
  captured = r.value;
}

test AsyncChannel {
  @step;  // Start the state machine, now blocked waiting for response

  @pump {value: 42} into respond;
  @step;  // Process the pumped message

  assert captured == 42;
}

Message Syntax

The pump message uses anonymous object syntax matching the channel's message type:

message Complex {
  string name;
  int count;
  bool active;
}

channel handle(Complex msg) {
  // ...
}

test ComplexMessage {
  @pump {name: "test", count: 5, active: true} into handle;
  @step;
}

The @blocked Directive

The @blocked expression returns true if the document is blocked waiting for external input (typically an async channel fetch). This is how you verify your state machine is in the state you expect:

message X {
  int x;
  int y;
}

channel<X> chan;
string status;

@construct {
  transition #setup;
}

#setup {
  status = "Blocked";
  future<X> fut = chan.fetch(@no_one);
  X val = fut.await();
  status = "Value:" + val.x + "/" + val.y;
}

test BlockedState {
  assert !(@blocked);      // Not blocked initially
  assert status == "";

  @step;                   // Execute transition to #setup

  assert @blocked;         // Now blocked waiting for input
  assert status == "";     // Status not yet updated (blocked at await)

  @pump {x: 4, y: 8} into chan;

  assert @blocked;         // Still blocked (message queued, not processed)

  @step;                   // Process the message

  assert !(@blocked);      // No longer blocked
  assert status == "Value:4/8";
}

Verifying Unblocked State

Use !(@blocked) to verify the document isn't waiting:

test NotBlocked {
  assert !(@blocked);  // Document is not waiting for input
}

The @forward Directive

The @forward directive advances simulated time — essential for testing timeouts and delayed transitions:

message X {
  int x;
  int y;
}

channel<X> chan;
principal person;
public int z;

@construct {
  person = @who;
  transition #ask;
}

#ask {
  let r = chan.fetchTimed(person, 0.25).await();
  if (r as v) {
    z = v.x + v.y;
  } else {
    z = -1;  // Timeout occurred
  }
}

test TimeoutBehavior {
  @step;           // Start waiting

  assert @blocked;

  @forward 0.150;  // Advance time by 150ms
  @step;

  assert @blocked; // Still waiting (timeout is 250ms)

  @forward 0.150;  // Advance another 150ms (total 300ms > 250ms timeout)
  @step;

  assert !(@blocked);
  assert z == -1;  // Timeout result
}

The @aborts Block

Test code that calls abort using the @aborts block. The block expects the code inside to abort — if it doesn't, the test fails:

procedure thing_that_aborts() aborts {
  abort;
}

test DirectAbort {
  @aborts {
    abort;
  }
}

test ProcedureAbort {
  @aborts {
    thing_that_aborts();
  }
}

The @send Directive

The @send directive invokes a channel handler directly with a principal and message. Unlike @pump which queues messages, @send executes the handler immediately:

message X {
  int x;
  int y;
}

int z;
bool ran = false;

channel foo(X x) {
  z = x.x + x.y;
  ran = true;
}

test DirectSend {
  X v;
  v.x = 10;
  v.y = 100;

  assert !ran;

  @send foo(@no_one, v);

  assert z == 110;
  assert ran;
}

The log Statement

Use log to output debug information during test execution:

test WithLogging {
  log "Starting test";
  int x = 42;
  log "x = " + x;
  assert x == 42;
  log "Test complete";
}

Log output shows up in the test results. When a test fails and you can't figure out why, log is your friend.

Testing State Machines

State machines need careful testing of transitions and blocked states. Here's the pattern I use:

enum GameState { Waiting, Playing, GameOver }

public GameState state = GameState::Waiting;
public int round = 0;

@construct {
  transition #waiting;
}

#waiting {
  state = GameState::Waiting;
  // Wait for start signal
}

#playing {
  state = GameState::Playing;
  round++;
  if (round >= 3) {
    transition #gameOver;
  } else {
    transition #playing in 1;  // Next round in 1 second
  }
}

#gameOver {
  state = GameState::GameOver;
}

message Start {}
channel start(Start s) {
  if (state == GameState::Waiting) {
    transition #playing;
  }
}

test GameFlow {
  @step;
  assert state == GameState::Waiting;
  assert round == 0;

  @pump {} into start;
  @step;

  assert state == GameState::Playing;
  assert round == 1;

  @forward 1.5;  // Advance past the 1-second delay
  @step;

  assert round == 2;

  @forward 1.5;
  @step;

  assert round == 3;
  assert state == GameState::GameOver;
}

Testing Privacy

Tests run in a privileged context with full state access, but you can still verify privacy logic:

record Card {
  private principal owner;
  private int value;
}

table<Card> _cards;

bubble my_cards = iterate _cards where owner == @who;

procedure dealCard(principal player, int cardValue) {
  _cards <- {owner: player, value: cardValue};
}

test PrivacyLogic {
  principal alice;
  principal bob;

  // Deal cards
  dealCard(alice, 10);
  dealCard(alice, 20);
  dealCard(bob, 15);

  // Verify card counts
  assert (iterate _cards).size() == 3;
  assert (iterate _cards where owner == alice).size() == 2;
  assert (iterate _cards where owner == bob).size() == 1;
}
Note

Tests have full access to private fields — that's intentional. The privacy system is enforced at runtime for actual client connections, not during testing. You need to see everything to test properly.

Testing with Tables

Test table operations including insertion, queries, and deletion:

record Player {
  public int id;
  public string name;
  public int score;
}

table<Player> _players;

test TableOperations {
  // Insert records
  _players <- {name: "Alice", score: 100};
  _players <- {name: "Bob", score: 150};
  _players <- {name: "Charlie", score: 100};

  // Query assertions
  assert (iterate _players).size() == 3;
  assert (iterate _players where score == 100).size() == 2;
  assert (iterate _players where score > 100).size() == 1;

  // Update
  (iterate _players where name == "Alice").score = 200;
  assert (iterate _players where score == 200).size() == 1;

  // Delete
  (iterate _players where name == "Bob").delete();
  assert (iterate _players).size() == 2;
}

Test Organization

Naming Conventions

Use descriptive names that say what's being tested:

test UserCanJoinGame { assert true; }
test OwnerCanKickPlayer { assert true; }
test ScoreUpdatesOnCorrectAnswer { assert true; }
test GameEndsWhenAllRoundsComplete { assert true; }

Testing Edge Cases

Don't forget the boundaries:

record Item {
  public int id;
}
table<Item> _items;
int counter;
string name;

test EmptyTableQueries {
  assert (iterate _items).size() == 0;
  assert (iterate _items where id == 1).size() == 0;
}

test MaximumValues {
  counter = 2147483647;  // Max int
  counter++;
  // Verify overflow behavior
}

test EmptyStringHandling {
  name = "";
  assert name.length() == 0;
  assert name == "";
}

Testing Abort Behavior

Test that invalid operations properly abort:

message Bet {
  int amount;
}

channel place_bet(Bet bet) {
  if (bet.amount <= 0) {
    abort;  // Invalid bet
  }
  // Process valid bet
}

test InvalidBetAborts {
  @aborts {
    @send place_bet(@no_one, {amount: -10});
  }
}

Organize tests by feature using naming prefixes:

test Auth_ValidLoginSucceeds { assert true; }
test Auth_InvalidPasswordFails { assert true; }
test Auth_LockedAccountDenied { assert true; }

test Game_StartInitializesBoard { assert true; }
test Game_MoveUpdatesPosition { assert true; }
test Game_WinConditionDetected { assert true; }

test Chat_MessageDelivered { assert true; }
test Chat_EmptyMessageRejected { assert true; }

Complete Testing Example

Here's a tic-tac-toe game with tests. Not the most exciting game, but it exercises a lot of the testing machinery:

message Move {
  int position;
}

channel<Move> make_move;

public principal player1;
public principal player2;
public principal current_player;
// Board cells represented as individual fields (b0-b8)
public int b0; public int b1; public int b2;
public int b3; public int b4; public int b5;
public int b6; public int b7; public int b8;
public bool game_over = false;
public principal winner;

@construct {
  player1 = @no_one;
  player2 = @no_one;
  current_player = @no_one;
}

message Join {}

channel join(Join j) {
  if (player1 == @no_one) {
    player1 = @who;
  } else if (player2 == @no_one && @who != player1) {
    player2 = @who;
    current_player = player1;
    transition #play_turn;
  }
}

procedure getCell(int pos) -> int {
  if (pos == 0) { return b0; }
  if (pos == 1) { return b1; }
  if (pos == 2) { return b2; }
  if (pos == 3) { return b3; }
  if (pos == 4) { return b4; }
  if (pos == 5) { return b5; }
  if (pos == 6) { return b6; }
  if (pos == 7) { return b7; }
  return b8;
}

procedure setCell(int pos, int val) {
  if (pos == 0) { b0 = val; }
  if (pos == 1) { b1 = val; }
  if (pos == 2) { b2 = val; }
  if (pos == 3) { b3 = val; }
  if (pos == 4) { b4 = val; }
  if (pos == 5) { b5 = val; }
  if (pos == 6) { b6 = val; }
  if (pos == 7) { b7 = val; }
  if (pos == 8) { b8 = val; }
}

procedure checkWinner() -> bool {
  // Simplified - just check first row
  return b0 != 0 && b0 == b1 && b1 == b2;
}

#play_turn {
  if (game_over) {
    return;
  }

  future<Move> f = make_move.fetch(current_player);
  Move m = f.await();

  // Validate and apply move
  if (m.position >= 0 && m.position < 9 && getCell(m.position) == 0) {
    setCell(m.position, (current_player == player1) ? 1 : 2);

    if (checkWinner()) {
      winner = current_player;
      game_over = true;
      return;
    }

    // Switch players
    current_player = (current_player == player1) ? player2 : player1;
  }

  transition #play_turn;
}

test InitialState {
  assert player1 == @no_one;
  assert player2 == @no_one;
  assert !game_over;
  assert b0 == 0;
}

test JoinFlow {
  // First player joins
  Join j;
  @send join(@no_one, j);
  // Note: @who would be the actual principal in real scenario
}

test BoardUpdate {
  // Setup game state manually for testing
  player1 = @no_one;
  player2 = @no_one;
  current_player = player1;

  // Simulate a move
  b0 = 1;

  assert b0 == 1;
  assert b1 == 0;
}

test WinnerDetection {
  // Set up winning board
  b0 = 1; b1 = 1; b2 = 1;

  assert checkWinner();
}

test NoWinnerYet {
  b0 = 1; b1 = 0; b2 = 1;

  assert !checkWinner();
}

The whole point of building testing into the language is that there's no excuse not to test. Tests are right there, next to the code, running every time you compile. That's the kind of friction reduction that actually changes behavior.

Previous Cron
Next Agents