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 with Runtime 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), use runtime.deployApp() funtcion. Similarly to create a new asset use runtime.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 and local state as well as accounts are correctly updated. We use runtime.getGlobalState() and runtime.getLocalState() to check the state and directly inspect account objects (after the syncAccounts 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() and runtime.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 and local state are updated correctly. User can use runtime.getGlobalState() and runtime.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:

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: