Documentation Index
9 min readChapter 27

27. Writing Unit Tests for Contracts

Testing smart contracts is a foundational practice in Kortana development. Because deployed contracts are immutable and execute financial or state-critical logic on the Kortana Virtual Machine (KVM), ensuring that every execution path behaves deterministically is essential.

In the Quorlin ecosystem, unit testing encompasses both compile-time analysis checks and runtime state verification. This chapter provides a comprehensive guide to designing, structuring, and executing unit tests for Quorlin smart contracts.


27.1 Structure of Testable Quorlin Contracts

Quorlin contracts are designed with explicit structural boundaries. Types are human-readable (number, truth, address, text), function mutability is clearly distinguished using reads and writes modifiers, and contract state is stored in standard variables and map data structures.

To write contracts that are easy to test, keep logic modular and state transitions guarded by explicit require statements. Below is an example contract—TokenVault.ql—that demonstrates standard language constructs used throughout this chapter's testing scenarios.

contract TokenVault { address owner; number totalVaultBalance; map<address, number> balances; event Deposit(address indexed user, number amount); event Withdraw(address indexed user, number amount); constructor { owner = caller; totalVaultBalance = 0; } reads number getBalance(address account) { return balances[account]; } reads number getTotalBalance() { return totalVaultBalance; } reads address getOwner() { return owner; } writes truth deposit(number amount) { require amount > 0, "deposit amount must be positive"; balances[caller] = balances[caller] + amount; totalVaultBalance = totalVaultBalance + amount; emit Deposit(caller, amount); return yes; } writes truth withdraw(number amount) { number userBalance = balances[caller]; require userBalance >= amount, "insufficient balance"; balances[caller] = userBalance - amount; totalVaultBalance = totalVaultBalance - amount; emit Withdraw(caller, amount); return yes; } }

27.2 The Quorlin Test Execution Model

When testing Quorlin smart contracts, tests evaluate two primary layers:

  1. Compilation and Semantic Pipeline: Verifying that the compiler (quorlin::compile) correctly tokenizes, parses, and type-checks source code without producing errors in the DiagnosticBag.
  2. KVM Runtime and State Host: Executing the generated module bytecode against a simulated StateHost to verify storage updates, event emissions, and gas consumption.
+-----------------------------------------------------------------------+
|                           Quorlin Compiler                            |
|  Source (.ql) ---> Lexer ---> Parser ---> Analyzer ---> CodeGen       |
+-----------------------------------------------------------------------+
                                                                |
                                                      CompilationResult
                                                                |
                                                                v
+-----------------------------------------------------------------------+
|                              KVM Engine                               |
|   StateHost (WorldState, BlockContext) <---> Interpreter Execution    |
+-----------------------------------------------------------------------+

Unit testing harnesses invoke the compiler pipeline to produce a CompilationResult. If diagnostics.has_errors() returns false, the bytecode module is loaded into the KVM interpreter for execution against an isolated StateHost.


27.3 Testing Deployment and Constructor Initialization

The constructor of a Quorlin contract runs exactly once during contract instantiation. Testing the constructor ensures that initial state variables—such as initial balances, contract ownership, or global settings—are correctly set in storage slots.

Testing Initial Storage State

In Quorlin, standard state variables are assigned during constructor execution. In tests, you deploy the contract with a mock transaction where caller is set to a known test address (e.g., 0x1111...1111).

contract OwnerTest { address admin; constructor { admin = caller; } reads address getAdmin() { return admin; } }

Verification Steps:

  1. Initialize a test BlockContext and set caller to 0x1111111111111111111111111111111111111111.
  2. Execute the constructor bytecode via the KVM interpreter.
  3. Query getAdmin() (or inspect the underlying world state via StateHost::get_storage).
  4. Assert: The returned address matches 0x1111111111111111111111111111111111111111.

27.4 Validating State Transitions and Mutability

Quorlin strictly separates state inspection from state modification using two keywords:

  • reads: Maps to Mutability::View. Performs operations without modifying KVM storage trie state.
  • writes: Maps to Mutability::Mut. Can execute state mutations using underlying storage instructions (set_storage / SStore).

Testing Read Functions (reads)

Functions marked as reads must not modify storage. When unit testing a reads function, verify that calling the method multiple times returns consistent results and leaves the StateHost storage unchanged.

reads number getBalance(address account) { return balances[account]; }
  • Test Assertion: Calling getBalance on an uninitialized key returns 0 (the default value for Type::U256 / number).

Testing Write Functions (writes)

Functions marked as writes must mutate state correctly upon valid calls.

writes truth deposit(number amount) { require amount > 0, "deposit amount must be positive"; balances[caller] = balances[caller] + amount; totalVaultBalance = totalVaultBalance + amount; emit Deposit(caller, amount); return yes; }

Test Scenario: Single Deposit State Transition

  1. Set caller to 0x2222...2222.
  2. Invoke deposit(500).
  3. Assert that the execution returns yes (Type::Bool set to true / integer 1).
  4. Call getBalance(0x2222...2222). Assert the returned value is 500.
  5. Call getTotalBalance(). Assert the returned value is 500.

Test Scenario: Sequential State Accumulation

  1. Call deposit(300) from 0x2222...2222.
  2. Call deposit(200) from 0x2222...2222.
  3. Call getBalance(0x2222...2222).
  4. Assert: The accumulated balance equals 800.

27.5 Asserting Invariants with require and Revert Behavior

Quorlin uses the require statement to enforce condition invariants. If a condition evaluates to no (false), execution aborts immediately, emitting an Opcode::Revert instruction on the KVM.

writes truth withdraw(number amount) { number userBalance = balances[caller]; require userBalance >= amount, "insufficient balance"; balances[caller] = userBalance - amount; return yes; }

Testing Revert Conditions

To test that an invariant is properly enforced, construct tests that deliberately violate the precondition and verify that execution reverts without mutating state.

Test Case: Withdrawing More Than Deposited

  1. Set caller to 0x3333...3333.
  2. Execute deposit(100).
  3. Attempt execution of withdraw(150).
  4. Expected Result:
    • The KVM halts with Opcode::Revert.
    • State modifications within the failed transaction are rolled back.
    • Calling getBalance(0x3333...3333) still yields 100 (state remains unchanged).

Test Case: Zero-Amount Guard

  1. Attempt execution of deposit(0).
  2. Expected Result: Revert triggered with the diagnostic message "deposit amount must be positive".

27.6 Testing Event Logs and ABI Representation

Events enable off-chain indexing and tracking of contract activity. In Quorlin, events are declared using event and triggered using emit.

event Transfer(address indexed from, address indexed to, number amount);

When compiled, the Quorlin ABI emitter (quorlin/abi.cpp) maps Quorlin native types to standard Ethereum-compatible ABI type names for interoperability:

Quorlin Type (Type)Language KeywordInternal ABI Type Name
Type::U256numberuint256
Type::Booltruthbool
Type::Addressaddressaddress
Type::Texttextstring
Type::Voidnothingvoid

Validating Event Emissions in Unit Tests

Unit tests should inspect log emissions produced during a writes function call:

contract EventTester { event ItemSet(address indexed setter, number value); writes truth setItem(number val) { emit ItemSet(caller, val); return yes; } }

Test Steps:

  1. Invoke setItem(42) with caller set to 0x4444...4444.
  2. Inspect the KVM execution log output.
  3. Assert Topic 0: Computed Keccak-256 hash of signature ItemSet(address,uint256).
  4. Assert Topic 1 (Indexed parameter): 0x4444...4444 padded to 32 bytes.
  5. Assert Data Payload: Non-indexed parameter 42 encoded as a 32-byte big-endian uint256.

27.7 Simulating Context and Environment Variables

Quorlin contracts interact with ambient context provided by the blockchain through built-in keywords.

Quorlin Built-inDescription
callerAddress of the account or contract calling the current function.
block.numberCurrent block height.
block.timestampCurrent block timestamp.

Unit tests must simulate diverse context values using the KVM BlockContext struct to verify time-locked or caller-restricted logic.

contract Timelock { number unlockTime; address owner; constructor { owner = caller; unlockTime = 1000; } writes truth claim() { require caller == owner, "not owner"; require block.timestamp >= unlockTime, "locked"; return yes; } }

Unit Testing Environment Contexts

// Test Case 1: Unauthorized Caller // Set caller = 0x9999...9999, block.timestamp = 2000 // Expected Result: Revert ("not owner") // Test Case 2: Premature Claim // Set caller = owner, block.timestamp = 500 // Expected Result: Revert ("locked") // Test Case 3: Successful Claim // Set caller = owner, block.timestamp = 1000 // Expected Result: Success (returns yes)

27.8 Testing Inter-Contract Communication and Interfaces

Contracts often interact with other contracts through interface definitions. In the KVM runtime, cross-contract calls are handled by the ICallDispatcher interface via StateHost::call.

interface IERC20 { reads number balanceOf(address account); writes truth transfer(address recipient, number amount); } contract SwapRouter { writes truth executeSwap(address tokenAddress, address recipient, number amount) { IERC20 token = IERC20(tokenAddress); truth success = token.transfer(recipient, amount); require success, "transfer failed"; return yes; } }

Mocking External Calls in Unit Tests

To test SwapRouter without deploying a real ERC20 token contract:

  1. Register a mock contract address (e.g., 0x5555...5555) in ICallDispatcher.
  2. Configure the mock dispatcher to intercept calls targeting selector transfer(address,uint256) on 0x5555...5555.
  3. Set the mock response payload to encode truth (yes / 1).
  4. Execute executeSwap(0x5555...5555, recipient, 100).
  5. Assert:
    • executeSwap completes successfully.
    • ICallDispatcher recorded a call request sent to 0x5555...5555 with the expected calldata payload.

27.9 Compiler Diagnostic Unit Testing

In addition to runtime tests, unit tests should validate that illegal Quorlin syntax or type errors are caught by the compiler during static analysis (Analyzer).

Common Compiler Error Scenarios to Test

1. Type Mismatch

Assigning a number to a variable declared as truth:

contract TypeMismatchTest { writes nothing invalidAssignment() { truth flag = 123; // Error: cannot assign 'number' to 'truth' } }
  • Test Verification: Run quorlin::compile on source code. Assert that result.diagnostics.has_errors() is true.

2. Mutability Violation

Attempting to write to storage inside a reads function:

contract MutabilityTest { number stateVar; reads number illegalWrite() { stateVar = 10; // Error: storage write in 'reads' function return stateVar; } }
  • Test Verification: Assert compilation fails with a mutability violation diagnostic.

3. Undeclared Identifier

Referencing an undefined variable:

contract UndeclaredTest { reads number unknownRef() { return nonExistentVar; // Error: unknown identifier } }
  • Test Verification: Assert compilation fails during semantic analysis.

27.10 Summary Checklist for Testing Quorlin Contracts

When writing unit test suites for Quorlin smart contracts, ensure coverage across these key dimensions:

  • Constructor Verification: Confirm initial values of state variables and ownership settings after deployment.
  • State Transitions: Test all writes functions with valid inputs and verify corresponding storage updates.
  • View Integrity: Ensure reads functions return correct values without mutating world state.
  • Invariant Protection: Test every require condition with invalid inputs to ensure proper revert execution.
  • Event Logging: Verify topic hash generation, indexed parameter positions, and non-indexed payload data.
  • Environment Boundary: Test logic dependent on caller and block context across distinct scenarios.
  • Inter-Contract Interactions: Mock external interface dependencies to verify payload encoding and call dispatching.
  • Compiler Diagnostics: Validate that invalid code patterns trigger precise static analyzer errors.