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
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“
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) == 2
In this updated example:
-
We use
context.txn.create_group()
withactive_txn_overrides
to 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_overrides
parameter allows you to specifyapp_args
and other transaction fields directly, without creating a fullApplicationCallTransaction
object. -
For scenarios involving multiple transactions, you can still use the
gtxns
parameter to create a transaction group, as shown in thetest_counter_contract_multiple_txns
function. -
The
app_id
is 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
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)