Smart Contract Testing
This guide provides an overview of how to test smart contracts using the Algorand Python SDK (algopy). We will cover the basics of testing ARC4Contract and Contract classes, focusing on abimethod and baremethod decorators.
The code snippets showcasing the contract testing capabilities are using [pytest](https://docs.pytest.org/en/latest/) as the test framework. However, note that the `algorand-python-testing` package can be used with any other test framework that supports Python. `pytest` is used for demonstration purposes in this documentation.import algopyimport algopy_testingfrom algopy_testing import algopy_testing_context
# Create the context manager for snippets belowctx_manager = algopy_testing_context()
# Enter the contextcontext = ctx_manager.__enter__()algopy.ARC4Contract
Section titled “algopy.ARC4Contract”Subclasses of algopy.ARC4Contract are required to be instantiated with an active test context. As part of instantiation, the test context will automatically create a matching algopy.Application object instance.
Within the class implementation, methods decorated with algopy.arc4.abimethod and algopy.arc4.baremethod will automatically assemble an algopy.gtxn.ApplicationCallTransaction transaction to emulate the AVM application call. This behavior can be overriden by setting the transaction group manually as part of test setup, this is done via implicit invocation of algopy_testing.context.any_application() value generator (refer to APIs for more details).
class SimpleVotingContract(algopy.ARC4Contract): def __init__(self) -> None: self.topic = algopy.GlobalState(algopy.Bytes(b"default_topic"), key="topic", description="Voting topic") self.votes = algopy.GlobalState( algopy.UInt64(0), key="votes", description="Votes for the option", ) self.voted = algopy.LocalState(algopy.UInt64, key="voted", description="Tracks if an account has voted")
@algopy.arc4.abimethod(create="require") def create(self, initial_topic: algopy.Bytes) -> None: self.topic.value = initial_topic self.votes.value = algopy.UInt64(0)
@algopy.arc4.abimethod def vote(self) -> algopy.UInt64: assert self.voted[algopy.Txn.sender] == algopy.UInt64(0), "Account has already voted" self.votes.value += algopy.UInt64(1) self.voted[algopy.Txn.sender] = algopy.UInt64(1) return self.votes.value
@algopy.arc4.abimethod(readonly=True) def get_votes(self) -> algopy.UInt64: return self.votes.value
@algopy.arc4.abimethod def change_topic(self, new_topic: algopy.Bytes) -> None: assert algopy.Txn.sender == algopy.Txn.application_id.creator, "Only creator can change topic" self.topic.value = new_topic self.votes.value = algopy.UInt64(0) # Reset user's vote (this is simplified per single user for the sake of example) self.voted[algopy.Txn.sender] = algopy.UInt64(0)
# Arrangeinitial_topic = algopy.Bytes(b"initial_topic")contract = SimpleVotingContract()contract.voted[context.default_sender] = algopy.UInt64(0)
# Act - Create the contractcontract.create(initial_topic)
# Assert - Check initial stateassert contract.topic.value == initial_topicassert contract.votes.value == algopy.UInt64(0)
# Act - Vote# The method `.vote()` is decorated with `algopy.arc4.abimethod`, which means it will assemble a transaction to emulate the AVM application callresult = contract.vote()
# Assert - you can access the corresponding auto generated application call transaction via test contextassert len(context.txn.last_group.txns) == 1
# Assert - Note how local and global state are accessed via regular python instance attributesassert result == algopy.UInt64(1)assert contract.votes.value == algopy.UInt64(1)assert contract.voted[context.default_sender] == algopy.UInt64(1)
# Act - Change topicnew_topic = algopy.Bytes(b"new_topic")contract.change_topic(new_topic)
# Assert - Check topic changed and votes resetassert contract.topic.value == new_topicassert contract.votes.value == algopy.UInt64(0)assert contract.voted[context.default_sender] == algopy.UInt64(0)
# Act - Get votes (should be 0 after reset)votes = contract.get_votes()
# Assert - Check votesassert votes == algopy.UInt64(0)For more examples of tests using algopy.ARC4Contract, see the examples section.
`algopy.Contract“
Section titled “`algopy.Contract“”Subclasses of algopy.Contract are required to be instantiated with an active test context. As part of instantiation, the test context will automatically create a matching algopy.Application object instance. This behavior is identical to algopy.ARC4Contract class instances.
Unlike algopy.ARC4Contract, algopy.Contract requires manual setup of the transaction context and explicit method calls. Alternatively, you can use active_txn_overrides to specify application arguments and foreign arrays without needing to create a full transaction group if your aim is to patch a specific active transaction related metadata.
Here’s an updated example demonstrating how to test a Contract class:
import algopyimport pytestfrom algopy_testing import AlgopyTestContext, algopy_testing_context
class CounterContract(algopy.Contract): def __init__(self): self.counter = algopy.UInt64(0)
@algopy.subroutine def increment(self): self.counter += algopy.UInt64(1) return algopy.UInt64(1)
@algopy.arc4.baremethod def approval_program(self): return self.increment()
@algopy.arc4.baremethod def clear_state_program(self): return algopy.UInt64(1)
@pytest.fixture()def context(): with algopy_testing_context() as ctx: yield ctx
def test_counter_contract(context: AlgopyTestContext): # Instantiate contract contract = CounterContract()
# Set up the transaction context using active_txn_overrides with context.txn.create_group( active_txn_overrides={ "sender": context.default_sender, "app_args": [algopy.Bytes(b"increment")], } ): # Invoke approval program result = contract.approval_program()
# Assert approval program result assert result == algopy.UInt64(1)
# Assert counter value assert contract.counter == algopy.UInt64(1)
# Test clear state program assert contract.clear_state_program() == algopy.UInt64(1)
def test_counter_contract_multiple_txns(context: AlgopyTestContext): contract = CounterContract()
# For scenarios with multiple transactions, you can still use gtxns extra_payment = context.any.txn.payment()
with context.txn.create_group( gtxns=[ extra_payment, context.any.txn.application_call( sender=context.default_sender, app_id=contract.app_id, app_args=[algopy.Bytes(b"increment")], ), ], active_txn_index=1 # Set the application call as the active transaction ): result = contract.approval_program()
assert result == algopy.UInt64(1) assert contract.counter == algopy.UInt64(1)
assert len(context.txn.last_group.txns) == 2In this updated example:
-
We use
context.txn.create_group()withactive_txn_overridesto set up the transaction context for a single application call. This simplifies the process when you don’t need to specify a full transaction group. -
The
active_txn_overridesparameter allows you to specifyapp_argsand other transaction fields directly, without creating a fullApplicationCallTransactionobject. -
For scenarios involving multiple transactions, you can still use the
gtxnsparameter to create a transaction group, as shown in thetest_counter_contract_multiple_txnsfunction. -
The
app_idis automatically set to the contract’s application ID, so you don’t need to specify it explicitly when usingactive_txn_overrides.
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:
def test_deferred_call(context): contract = MyARC4Contract()
extra_payment = context.any.txn.payment() extra_asset_transfer = context.any.txn.asset_transfer() implicit_payment = context.any.txn.payment() deferred_call = context.txn.defer_app_call(contract.some_method, implicit_payment)
with context.txn.create_group([extra_payment, deferred_call, extra_asset_transfer]): result = deferred_call.submit()
print(context.txn.last_group) # [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.
ctx_manager.__exit__(None, None, None)