puya:python:testing:docs:testing-guide:contract-testing.md•10.1 kB
# 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](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.
```
```{testsetup}
import algopy
import algopy_testing
from algopy_testing import algopy_testing_context
# Create the context manager for snippets below
ctx_manager = algopy_testing_context()
# Enter the context
context = 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](../apis.md) for more details).
```{testcode}
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](../examples.md) 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:
```{testcode}
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:
1. We use `context.txn.create_group()` with `active_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.
2. The `active_txn_overrides` parameter allows you to specify `app_args` and other transaction fields directly, without creating a full `ApplicationCallTransaction` object.
3. For scenarios involving multiple transactions, you can still use the `gtxns` parameter to create a transaction group, as shown in the `test_counter_contract_multiple_txns` function.
4. The `app_id` is automatically set to the contract's application ID, so you don't need to specify it explicitly when using `active_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:
```python
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.
```{testcleanup}
ctx_manager.__exit__(None, None, None)
```