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;
}
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});
}
}
Grouping Related Tests
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.