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.
Note
The code snippets showcasing the contract testing capabilities are using pytest 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.
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)
# Arrange
initial_topic = algopy.Bytes(b"initial_topic")
contract = SimpleVotingContract()
contract.voted[context.default_sender] = algopy.UInt64(0)
# Act - Create the contract
contract.create(initial_topic)
# Assert - Check initial state
assert contract.topic.value == initial_topic
assert 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 call
result = contract.vote()
# Assert - you can access the corresponding auto generated application call transaction via test context
assert len(context.txn.last_group.txns) == 1
# Assert - Note how local and global state are accessed via regular python instance attributes
assert result == algopy.UInt64(1)
assert contract.votes.value == algopy.UInt64(1)
assert contract.voted[context.default_sender] == algopy.UInt64(1)
# Act - Change topic
new_topic = algopy.Bytes(b"new_topic")
contract.change_topic(new_topic)
# Assert - Check topic changed and votes reset
assert contract.topic.value == new_topic
assert 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 votes
assert 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 algopy
import pytest
from 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.