Smart Contract Testing
This guide provides an overview of how to test smart contracts using the Algorand TypeScript Testing package. We cover the basics of testing arc4.Contract and BaseContract classes, with a focus on the abimethod and baremethod decorators.
The code snippets demonstrating contract testing use [vitest](https://vitest.dev/) as the test framework. Note that `algorand-typescript-testing` works with any test framework that supports TypeScript; `vitest` is used here for demonstration.
import { arc4 } from '@algorandfoundation/algorand-typescript'
import { TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing'
// Create the context manager for snippets below
const ctx = new TestExecutionContext()
arc4.Contract
Section titled “arc4.Contract”Subclasses of arc4.Contract must be instantiated with an active test context. As part of instantiation, the test context automatically creates a matching Application object instance.
Within the class implementation, methods decorated with arc4.abimethod and arc4.baremethod automatically assemble a gtxn.ApplicationCallTxn to emulate the AVM application call. This behaviour can be overridden by setting the transaction group manually as part of test setup; this is done via implicit invocation of the ctx.any.txn.applicationCall value generator (see APIs for details).
class SimpleVotingContract extends arc4.Contract {
topic = GlobalState({ initialValue: Bytes('default_topic'), key: 'topic' })
votes = GlobalState({
initialValue: Uint64(0),
key: 'votes',
})
voted = LocalState<uint64>({ key: 'voted' })
@arc4.abimethod({ onCreate: 'require' })
create(initialTopic: bytes) {
this.topic.value = initialTopic
this.votes.value = Uint64(0)
}
@arc4.abimethod()
vote(): uint64 {
assert(this.voted(Txn.sender).value === 0, 'Account has already voted')
this.votes.value = this.votes.value + 1
this.voted(Txn.sender).value = Uint64(1)
return this.votes.value
}
@readonly
getVotes(): uint64 {
return this.votes.value
}
@arc4.abimethod()
changeTopic(newTopic: bytes) {
assert(Txn.sender === Txn.applicationId.creator, 'Only creator can change topic')
this.topic.value = newTopic
this.votes.value = Uint64(0)
// Reset user's vote (this is simplified per single user for the sake of example)
this.voted(Txn.sender).value = Uint64(0)
}
}
// Arrange
const initialTopic = Bytes('initial_topic')
const contract = ctx.contract.create(SimpleVotingContract)
contract.voted(ctx.defaultSender).value = Uint64(0)
// Act - Create the topic
contract.create(initialTopic)
// Assert - Check initial state
expect(contract.topic.value).toEqual(initialTopic)
expect(contract.votes.value).toEqual(Uint64(0))
// Act - Vote
// The method `.vote()` is decorated with `arc4.abimethod`, which means it will assemble a transaction to emulate the AVM application call
const result = contract.vote()
// Assert - you can access the corresponding auto generated application call transaction via test context
expect(ctx.txn.lastGroup.transactions.length).toEqual(1)
// Assert - Note how local and global state are accessed via regular TypeScript instance attributes
expect(result).toEqual(1)
expect(contract.votes.value).toEqual(1)
expect(contract.voted(ctx.defaultSender).value).toEqual(1)
// Act - Change topic
const newTopic = Bytes('new_topic')
contract.changeTopic(newTopic)
// Assert - Check topic changed and votes reset
expect(contract.topic.value).toEqual(newTopic)
expect(contract.votes.value).toEqual(0)
expect(contract.voted(ctx.defaultSender).value).toEqual(0)
// Act - Get votes (should be 0 after reset)
const votes = contract.getVotes()
// Assert - Check votes
expect(votes).toEqual(0)
For more examples of tests using arc4.Contract, see the examples section.
`BaseContract“
Section titled “`BaseContract“”Subclasses of BaseContract are required to be instantiated with an active test context. As part of instantiation, the test context will automatically create a matching Application object instance. This behaviour is identical to arc4.Contract class instances.
Unlike arc4.Contract, BaseContract requires manual setup of the transaction context and explicit method calls.
Here’s an updated example demonstrating how to test a BaseContract class:
import { BaseContract, Bytes, GlobalState, Uint64 } from '@algorandfoundation/algorand-typescript'
import { TestExecutionContext } from '@algorandfoundation/algorand-typescript-testing'
import { afterEach, expect, test } from 'vitest'
class CounterContract extends BaseContract {
counter = GlobalState({ initialValue: Uint64(0) })
increment() {
this.counter.value = this.counter.value + 1
return Uint64(1)
}
approvalProgram() {
return this.increment()
}
clearStateProgram() {
return Uint64(1)
}
}
const ctx = new TestExecutionContext()
afterEach(() => {
ctx.reset()
})
test('increment', () => {
// Instantiate contract
const contract = ctx.contract.create(CounterContract)
// Set up the transaction context using active_txn_overrides
ctx.txn
.createScope([ctx.any.txn.applicationCall({ appId: contract, sender: ctx.defaultSender, appArgs: [Bytes('increment')] })])
.execute(() => {
// Invoke approval program
const result = contract.approvalProgram()
// Assert approval program result
expect(result).toEqual(1)
// Assert counter value
expect(contract.counter.value).toEqual(1)
})
// Test clear state program
expect(contract.clearStateProgram()).toEqual(1)
})
test('increment with multiple txns', () => {
const contract = ctx.contract.create(CounterContract)
// For scenarios with multiple transactions, you can still use gtxns
const extraPayment = ctx.any.txn.payment()
ctx.txn
.createScope(
[
extraPayment,
ctx.any.txn.applicationCall({
sender: ctx.defaultSender,
appId: contract,
appArgs: [Bytes('increment')],
}),
],
1, // Set the application call as the active transaction
)
.execute(() => {
const result = contract.approvalProgram()
expect(result).toEqual(1)
expect(contract.counter.value).toEqual(1)
})
expect(ctx.txn.lastGroup.transactions.length).toEqual(2)
})
In this updated example:
-
We use
ctx.txn.createScope()withctx.any.txn.applicationCallto set up the transaction context for a single application call. -
For scenarios involving multiple transactions, you can still use the
groupparameter to create a transaction group, as shown in thetest('increment with multiple txns', () => {})function.
This approach provides more flexibility in setting up the transaction context for testing Contract classes, allowing for both simple single-transaction scenarios and more complex multi-transaction tests.
Defer contract method invocation
Section titled “Defer contract method invocation”You can create deferred application calls for more complex testing scenarios where order of transactions needs to be controlled:
class MyARC4Contract extends arc4.Contract {
someMethod(payment: gtxn.PaymentTxn) {
return Uint64(1)
}
}
const ctx = new TestExecutionContext()
test('deferred call', () => {
const contract = ctx.contract.create(MyARC4Contract)
const extraPayment = ctx.any.txn.payment()
const extraAssetTransfer = ctx.any.txn.assetTransfer()
const implicitPayment = ctx.any.txn.payment()
const deferredCall = ctx.txn.deferAppCall(contract, contract.someMethod, 'someMethod', implicitPayment)
ctx.txn.createScope([extraPayment, deferredCall, extraAssetTransfer]).execute(() => {
const result = deferredCall.submit()
})
console.log(ctx.txn.lastGroup) // [extra_payment, implicit_payment, app call, extra_asset_transfer]
})
A deferred application call prepares the application call transaction without immediately executing it. The call can be executed later by invoking the .submit() method on the deferred application call instance. As demonstrated in the example, you can also include the deferred call in a transaction group creation context manager to execute it as part of a larger transaction group. When .submit() is called, only the specific method passed to defer_app_call() will be executed.
// test cleanup
ctx.reset()