Testing TEAL
TEAL is a stack based language that executes inside Algorand transactions to program logic signatures and smart contracts. @algo-builder/runtime
provides a TypeScript and JavaScript, lightweight runtime and TEAL interpreter to test Algorand transactions, ASA and Smart Contracts.
You can test your smart contracts and flows by starting a local node (see our infrastructure guide) and creating deploy and run scripts to automate the process of creating dapps and running them against the official Algorand implementation.
However, this is not efficient for sustainable development. You need a proper automated test process, we strongly advice you to use our @algo-builder/runtime
package and the testing framework. It essentially brings the unit and integration testing experience form traditional software development to Algorand smart contracts development.
We strongly advice to always test your smart contract, and try to get 100% test coverage. The last thing you want is to get your contract hacked and your user assets stolen. Note: 100% test coverage is strongly recommended, but it doesn’t guarantee that your smart contracts are safe. For complex flow we still recommend you to audit your solution.
How it works
The @algo-builder/runtime
(TypeScript Algorand runtime) package has 4 major components:
- Runtime: process transaction or txn group, and manages state.
algob
user interacts directly withRuntime
to set up accounts and post transactions (create applications, upate application, opt-in to app, ASA …). - AccountStore:
AccountStore
object represents an Alogrand compatible account, which stores all account related information (apps
,assets
,localState
,globalState
etc..). - Parser: parses TEAL code and returns a list of opcodes which are executable by the
Interpreter
. If any opcode/data in teal code is invalid, parser will throw an error. - Interpreter: Executes the list of opcodes returned by the parser and updates
Runtime
current transaction context after each opcode execution. At the end of execution, if the execution stack contains a single non-zero uint64 element then the teal code is approved, and and current transaction context is committed. If transaction is executed in a group context, then the state commit only happens if all transactions in the group pass.
Block Rounds/Height
In Algorand blockchain, transaction processing is divided into rounds. At each round blockchain creates a block with transactions which update the state. All transactions in the same block have the same transaction time and block height.
All transactions are processed immediately. However, we keep the notion of rounds and timestamps because it is needed for transaction and smart contract processing.
Algo Builder Runtime
supports its own implementation of blocks which imitates the behavior of blockchain and allows users to create more sofisticated test scenarios. Every time a new Runtime
instance is created first 2000
blocks are created. The blocks will not be generated automatically. In order to move to the next block the method runtime.produceBlocks()
must be invoked. The method will create one new block and move to it. This method can be used to create more than one new block at the time, to do it invoke the mentionded method with the desired parameter. In order to get information about a specific block the method runtime.getBlock(blockNumber)
can be used. Each block contains information about its seed and timestamp. The seed of a first block and timestamp are randomly generated string of characters and the unix timestamp
of the creation of runtime respectively. The seed of the following blocks are a MD5
hash of the seed from the previous block and the timestamp is the timestamp of the previous block + BlockFinalisationTime
which in algobuilder equals 4
seconds.
The default Runtime block round is set to 2000
.
Example:
const runtime = new Runtime();
//your tests
runtime.produceBlocks(); //creates a new block
//your tests
runtime.getBlock(1900);
//your tests
runtime.produceBlocks(20); //creates 20 new blocks
//your tests
runtime.getBlock(2000);
Project Structure
algob project:
├── assets
│ ├── TEAL files
│ ├── PyTEAL files
├── scripts
│ ├── deploy.ts
│ ├── run.ts
├── test
│ ├── JS test files
│ ├── .mocharc.json (optional)
├── package.json
All our test files should be stored in test
directory. In the test files, you can import algob
as a library as well as functions from scripts
.
Tests are typically done using Mocha framework, while assertions using Chai a BDD / TDD assertion library. Your test file is usually organized as follows:
describe("use-case", function() {
let variable1;
// ...
this.beforeAll(function() { ... });
this.afterAll(function() { ... });
it("test case 1", function() {
// preparation
// execution
// checks
});
it("test case 2", function() { ... });
});
Please read more about Mocha and Chai if you are not familiar with them.
Test structure
In this section we will describe the flow of testing smart contracts in runtime:
-
Prepare Accounts. First of all we need to create accounts which we will use in transactions:
const john = new AccountStore(initialMicroAlgo); const bob = new AccountStore(initialMicroAlgo);
initialMicroAlgo
is the amount of ALGO set for the created account. It’s recommended to have at least 1 ALGO (1000000 micro ALGO) to cover transaction fees and to maintain minimum account balance. Note: User can initialize & use accounts by name in runtime, similar to algob. Ex:const john = new AccountStore(initialMicroAlgo, "john");
-
Prepare Runtime. Next we create a runtime with those accounts.
const runtime = new Runtime([john, bob]);
-
Set block round and timestamp.
runtime.setRoundAndTimestamp(20, 100);
- Create Apps/Assets. At this point our runtime is ready. Now we can create apps and assets, and begin testing our smart contracts (present in your current directory’s
asset
folder). To create a stateful application (smart contract), useruntime.deployApp()
funtcion. Similarly to create a new asset useruntime.deployAsset()
function. - Create and Execute Transactions. We can create transactions to test our smart contracts. You create a transaction (Payment Transaction, Atomic Transfers, Asset Transfer etc…) as you would do it in algob: either using the JS SDK, or one of the high level algob functions. To execute tranasction use
runtime.executeTx()
funtion. - Update/Refresh State. After a transaction is executed the state of an account will be updated. In order to inspect a new state of accounts we need to re-query them from the runtime. In algob examples we use
syncAccounts()
closure (see example) closure which will reassign accounts to their latest state. - Verify State: Now, we can verify if the
global state
andlocal state
as well as accounts are correctly updated. We useruntime.getGlobalState()
andruntime.getLocalState()
to check the state and directly inspect account objects (after thesyncAccounts
is made).
Default Accounts
This section briefly explains how to use Default Accounts provided by the Runtime. The Default Accounts are predifined accounts that are recommented to be used in tests. Instead of long setup copy-paste code like this:
const initialMicroAlgo = 1e8;
let john = new AccountStore(initialMicroAlgo);
let bob = new AccountStore(initialMicroAlgo);
let runtime = Runtime([john, bob]);
syncAccounts(){
john = runtime.getAccount(john.address);
bob = runtime.getAccount(bob.address);
};
With the utilization of the Default Accounts the setup might look like this:
let john;
let bob;
let runtime = Runtime([]);
[john, bob] = runtime.defaultAccounts();
syncAccounts(){
[john, bob] = runtime.defaultAccounts();
};
There is no need to pass the Default Accounts to the Runtime constructor, since these are created inside of it.
To sync the accounts the method runtime.defaultAccounts()
must be invoked. No additional code is necessary.
Methods:
runtime.defaultAccounts()
returns a list of 16 pre-generated accounts with predefined addresses and keys, each with 1e8 microAlgos (100 Algos).runtime.resetDefaultAccounts()
- will reset the state of all the Default Accounts.
The Default Accounts also require syncing, however its done in the same manner as the first creation of them (see the example above).
For a better understanding see the following examples: bond token test or unique asa test.
Run tests
TL;DR: Write tests in /test
directory and then call mocha
:
mocha <test_name or path>
or you can also run tests using algob
algob test
See one of our examples for more details (eg: examples/crowdfunding/test
).
NOTE: Please note that few opcodes (eg. arg
, arg_0
, app_global_get
..etc) can only be used in a single run mode (signature/application). Runtime
will throw an error if an opcode is not being used in it’s preferred “execution mode”.
Stateless TEAL
Escrow Account
Let’s try to execute a transaction where a user (say john
) can withdraw funds from an escrow
account based on a smart signature logic. In the example below, we will use a TEAL code from our escrow account test.
The logic signature accepts only ALGO payment transaction where amount is <= 100 AND receiver is john
AND fee <= 10000.
-
First let’s prepare the runtime and state: initialize accounts, get a logic signature for escrow and set up runtime:
const minBalance = BigInt(ALGORAND_ACCOUNT_MIN_BALANCE + 1000); // 1000 to cover fee const initialEscrowHolding = minBalance + BigInt(1000e6); const initialJohnHolding = minBalance + 500n; const fee = 1000; // admin is an account used to fund escrow let admin = new AccountStore(1e12); let john = new AccountStore(initialJohnHolding); const escrow = runtime.loadLogic("escrow.teal", []); const runtime = new Runtime([john]); // setup runtime
-
We create a helper function to update local accounts based on the runtime state
function syncAccounts() { john = runtime.getAccount(john.address); escrow = runtime.getAccount(escrow.address); }
-
Execute transaction (using
runtime.executeTx()
) with valid txnParams.// set up transaction paramenters let paymentTxParams: AlgoTransferParam = { type: TransactionType.TransferAlgo, sign: SignType.LogicSignature, lsig: lsig, fromAccountAddr: escrow.address, toAccountAddr: john.address, amountMicroAlgos: 100n, payFlags: { totalFee: fee }, }; it("should fund escrow account", function () { runtime.executeTx({ type: TransactionType.TransferAlgo, // payment sign: SignType.SecretKey, fromAccount: admin.account, toAccountAddr: escrow.address, amountMicroAlgos: initialEscrowHolding, payFlags: { totalFee: fee }, }); // check initial balance syncAccounts(); assert.equal(escrow.balance(), initialEscrowHolding); assert.equal(john.balance(), initialJohnHolding); }); it("should withdraw funds from escrow if txn params are correct", function () { runtime.executeTx(paymentTxParams); // check final state (updated accounts) syncAccounts(); assert.equal(escrow.balance(), initialEscrowHolding - 100n - BigInt(fee)); assert.equal(john.balance(), initialJohnHolding + 100n); });
In the first test above, we fund the escrow using the admin account. John already has an initial balance set - we initialized runtime with John’s account. In the second test we execute payment transaction from escrow to john and validate that the balances are correct.
-
Executing transaction with invalid transaction.
it("should reject transaction if amount > 100", function () { expectRuntimeError( () => runtime.executeTx({ ...paymentTxParams, amountMicroAlgos: 500n }), RUNTIME_ERRORS.TEAL.REJECTED_BY_LOGIC ); });
Full example with above tests is available in our escrow-account.ts integration test suite.
Delegated Signature Account
Let’s try to execute a transaction where a user (john
) will use delegated signature based on a smart signatures logic. We will use a TEAL code from our asset test suite.
- As before we start with preparing the runtime. We use
runtime.loadLogic('escrow.teal', [])
to create and load logic signature.
let john = new AccountStore(initialJohnHolding);
let bob = new AccountStore(initialBobHolding);
let runtime = new Runtime([john, bob]);
- We will create a test with valid delegated signature check and try to use it to send ALGO from the delegator account.
const txnParams: ExecParams = {
type: TransactionType.TransferAlgo, // payment
sign: SignType.LogicSignature,
fromAccountAddr: john.account.addr,
toAccountAddr: bob.address,
amountMicroAlgos: 100n,
lsig: {} as LogicSig, // will be set below
payFlags: { totalFee: fee },
};
it("should send algo's from john to bob if delegated logic check passes", function () {
// check initial balance
assert.equal(john.balance(), initialJohnHolding);
assert.equal(bob.balance(), initialBobHolding);
// get delegated logic signature
const lsig = runtime.loadLogic("basic.teal", []);
lsig.sign(john.account.sk);
txnParams.lsig = lsig;
runtime.executeTx(txnParams);
syncAccounts();
assert.equal(john.balance(), initialJohnHolding - 100n - BigInt(fee));
assert.equal(bob.balance(), initialBobHolding + 100n);
});
- In the next test, create a delegated signature whose verification will fail. We check that the transfer was not done and the balances didn’t change.
it("should fail if delegated logic check doesn't pass", function () {
const johnBal = john.balance();
const bobBal = bob.balance();
const lsig = runtime.loadLogic("incorrect-logic.teal", []);
lsig.sign(john.account.sk);
txnParams.lsig = lsig;
// should fail because logic check fails
expectRuntimeError(
() => runtime.executeTx({ ...txnParams, amountMicroAlgos: 50n }),
RUNTIME_ERRORS.TEAL.REJECTED_BY_LOGIC
);
// accounts balance shouldn't be changed
syncAccounts();
assert.equal(john.balance(), johnBal);
assert.equal(bob.balance(), bobBal);
});
Full example with the above tests is available in our basic-teal integration test suite.
Stateful TEAL
Now, we will execute a transaction with an app call (stateful TEAL). The app is a simple smart contract which increments a global and local “counter” during each application call. Teal code can be found here
-
Similar to the previous test, we need to setup accounts and initialize runtime. Now, for stateful smart contract, we also need to create a new application in user account and opt-in (to call the stateful smart contract later). User can use
runtime.deployApp()
andruntime.optInToApp()
for app setup.const john = new AccountStore(1000); let runtime: Runtime; let program: string; const txnParams: ExecParams = { type: TransactionType.CallApp, sign: SignType.SecretKey, fromAccount: john.account, appId: 0, payFlags: { totalFee: fee }, }; this.beforeAll(async function () { runtime = new Runtime([john]); // setup test // create new app txnParams.appId = await runtime.deployApp( john.account, // creator { appName: "CounterApp", metaType: MetaType.FILE, approvalProgramFilename: "counter-approval.teal", clearProgramFilename: "clear-program", globalBytes: 32, globalInts: 32, localBytes: 8, localInts: 8, }, {} ).appID; // opt-in to the app await runtime.optInToApp(txnParams.appId, john.address, {}, {}); });
-
After set up, let’s call the stateful smart contract and check the updated global state
const key = "counter"; it("should set global and local counter to 1 on first call", function () { runtime.executeTx(txnParams); const globalCounter = runtime.getGlobalState(txnParams.appID, key); assert.equal(globalCounter, 1n); const localCounter = runtime.getAccount(john.address).getLocalState(txnParams.appID, key); // get local value from john account assert.equal(localCounter, 1n); });
In this test, after executing a transaction with stateful smart contract call, we are verifying if the
global state
andlocal state
are updated correctly. User can useruntime.getGlobalState()
andruntime.getLocalState()
to check state.
Please look at stateful-counter.ts to see the complete integration test suite.
Debugging
runtime.executeTx()
function has an optional debugStack: number
parameter. Setting up teal debugger (start tealdbg instance, creating a dry-run dump.json) could be a lengthy process, this param is helpful to quickly inspect the state of stack while executing TEAL code (for each opcode in smart contract in test).
When the debugStack: number
parameter is set, a TEAL stack is printed to a console (up to max_depth = debugStack
) after each opcode execution in the TEAL file.
// prints top 2 elements of stack (with opcode, line no.) after each opcode execution
runtime.executeTx(txParams, 2);
Best Practices
- Follow the Test Structure section to setup your tests.
- Structure tests using AAA pattern: Arrange, Act & Assert (AAA). The first part includes the test setup, then the execution of the unit under test, and finally the assertion phase. Following this structure guarantees that the reader will quickly understand the test plan.
- To prevent test coupling and easily reason about the test flow, each test should add and act on its own set of states.
- Use
beforeEach
,afterEach
,beforeAll
,afterAll
functions to setup and clean shared resources in your tests. - Sync your accounts’ before checking their state.
What we support
Currently, runtime
supports:
- Prepare account state for teal execution.
- Stateless TEAL - Approve/Reject logic.
- Stateful TEAL - Update and verify global/local states if teal logic is correct.
-
Transactions to
- Full transaction processing for type
payment
,application call
- Asset related transactions:
Examples
TEAL files used for the below tests can be found in /test/fixtures
in runtime package.
In our templates you will find dapps with test suites, which serves as a good examples and place to learn. Notable mentions:
- DAO Tests
- Complex TEAL test suite (Stateless + Stateful + Atomic transactions) - Crowdfunding application