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:
- Compilation and Semantic Pipeline: Verifying that the compiler (
quorlin::compile) correctly tokenizes, parses, and type-checks source code without producing errors in theDiagnosticBag. - KVM Runtime and State Host: Executing the generated module bytecode against a simulated
StateHostto 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:
- Initialize a test
BlockContextand setcallerto0x1111111111111111111111111111111111111111. - Execute the constructor bytecode via the KVM interpreter.
- Query
getAdmin()(or inspect the underlying world state viaStateHost::get_storage). - 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 toMutability::View. Performs operations without modifying KVM storage trie state.writes: Maps toMutability::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
getBalanceon an uninitialized key returns0(the default value forType::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
- Set
callerto0x2222...2222. - Invoke
deposit(500). - Assert that the execution returns
yes(Type::Boolset to true / integer1). - Call
getBalance(0x2222...2222). Assert the returned value is500. - Call
getTotalBalance(). Assert the returned value is500.
Test Scenario: Sequential State Accumulation
- Call
deposit(300)from0x2222...2222. - Call
deposit(200)from0x2222...2222. - Call
getBalance(0x2222...2222). - 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
- Set
callerto0x3333...3333. - Execute
deposit(100). - Attempt execution of
withdraw(150). - Expected Result:
- The KVM halts with
Opcode::Revert. - State modifications within the failed transaction are rolled back.
- Calling
getBalance(0x3333...3333)still yields100(state remains unchanged).
- The KVM halts with
Test Case: Zero-Amount Guard
- Attempt execution of
deposit(0). - 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 Keyword | Internal ABI Type Name |
|---|---|---|
Type::U256 | number | uint256 |
Type::Bool | truth | bool |
Type::Address | address | address |
Type::Text | text | string |
Type::Void | nothing | void |
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:
- Invoke
setItem(42)withcallerset to0x4444...4444. - Inspect the KVM execution log output.
- Assert Topic 0: Computed Keccak-256 hash of signature
ItemSet(address,uint256). - Assert Topic 1 (Indexed parameter):
0x4444...4444padded to 32 bytes. - Assert Data Payload: Non-indexed parameter
42encoded as a 32-byte big-endianuint256.
27.7 Simulating Context and Environment Variables
Quorlin contracts interact with ambient context provided by the blockchain through built-in keywords.
| Quorlin Built-in | Description |
|---|---|
caller | Address of the account or contract calling the current function. |
block.number | Current block height. |
block.timestamp | Current 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:
- Register a mock contract address (e.g.,
0x5555...5555) inICallDispatcher. - Configure the mock dispatcher to intercept calls targeting selector
transfer(address,uint256)on0x5555...5555. - Set the mock response payload to encode
truth(yes/1). - Execute
executeSwap(0x5555...5555, recipient, 100). - Assert:
executeSwapcompletes successfully.ICallDispatcherrecorded a call request sent to0x5555...5555with 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::compileon source code. Assert thatresult.diagnostics.has_errors()istrue.
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
writesfunctions with valid inputs and verify corresponding storage updates. - View Integrity: Ensure
readsfunctions return correct values without mutating world state. - Invariant Protection: Test every
requirecondition 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
callerandblockcontext 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.