Testing with Titanoboa
Titanoboa integrates natively with pytest and hypothesis. Nothing special is needed to enable these, as the plugins for these packages will be loaded automatically. By default, isolation is enabled for tests - that is, any changes to the EVM state inside the test case will automatically be rolled back after the test case completes.
Since titanoboa
is framework-agnostic any other testing framework should work as well.
Gas Profiling
Titanoboa has native gas profiling tools that store and generate statistics upon calling a contract. When enabled, gas costs are stored per call in global boa.env._cached_call_profiles
and boa.env._cached_line_profiles
dictionaries.
To enable gas profiling,
decorate tests with
@pytest.mark.gas_profile
, orrun pytest with
--gas-profile
, e.g.pytest tests/unitary --gas-profile
If --gas-profile
is selected, to ignore gas profiling for specific tests, decorate the test with @pytest.mark.ignore_gas_profiling
.
@pytest.mark.profile
def test_profile():
source_code = """
@external
@view
def foo(a: uint256 = 0):
x: uint256 = a
"""
contract = boa.loads(source_code, name="FooContract")
contract.foo()
┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━┓
┃ Contract ┃ Address ┃ Computation ┃ Count ┃ Mean ┃ Median ┃ Stdev ┃ Min ┃ Max ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━╇━━━━━┩
│ FooContract │ 0x0000000000000000000000000000000000000066 │ foo │ 1 │ 88 │ 88 │ 0 │ 88 │ 88 │
└─────────────┴────────────────────────────────────────────┴─────────────┴───────┴──────┴────────┴───────┴─────┴─────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┓
┃ Contract ┃ Computation ┃ Count ┃ Mean ┃ Median ┃ Stdev ┃ Min ┃ Max ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━┩
│ Path: │ │ │ │ │ │ │ │
│ Name: FooContract │ │ │ │ │ │ │ │
│ Address: 0x0000000000000000000000000000000000000066 │ │ Count │ Mean │ Median │ Stdev │ Min │ Max │
│ ---------------------------------------------------- │ -------------------------------------------------------------------------- │ ----- │ ----- │ ----- │ ----- │ ----- │ ----- │
│ Function: foo │ 4: def foo(a: uint256 = 0): │ 1 │ 73 │ 73 │ 0 │ 73 │ 73 │
│ │ 5: x: uint256 = a │ 1 │ 15 │ 15 │ 0 │ 15 │ 15 │
└──────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────┴───────┴───────┴────────┴───────┴───────┴───────┘
Note
Note that if a specific fixture is called in two separate tests, pytest will re-instantiate it. Meaning, if a Contract is deployed in a fixture, calling the fixture on tests in two separate files can lead to two deployments of that Contract, and hence two separate addresses in the profile table.
Warning
Profiling does not work with pytest-xdist plugin at the moment.
Coverage
Warning
Coverage is not yet supported when using fast mode.
Titanoboa offers coverage through the coverage.py package.
To use, add the following to .coveragerc
:
[run]
plugins = boa.coverage
(for more information see https://coverage.readthedocs.io/en/latest/config.html)
Then, run with coverage run ...
To run with pytest, it can be invoked in either of two ways,
coverage run -m pytest ...
or,
pytest --cov= ...
pytest-cov is a wrapper around coverage.py
for using with pytest; using it is recommended because it smooths out some quirks of using coverage.py
with pytest.
Finally, coverage.py
saves coverage data to a file named .coverage
in the directory it is run in. To view the formatted coverage data, you typically want to use coverage report
or coverage html
. See more options at https://coverage.readthedocs.io/en/latest/cmd.html.
Coverage is experimental and there may be odd corner cases! If so, please report them on github or in the #titanoboa-interpreter
channel of the Vyper discord.
Fuzzing Strategies
Titanoboa offers custom hypothesis strategies for testing. These can be used to generate EVM-compliant random inputs for tests.
Native Import Syntax
Titanoboa supports the native Python import syntax for Vyper contracts. This means that you can import Vyper contracts in any Python script as if you were importing a Python module.
For example, if you have a contract contracts/Foo.vy
:
x: public(uint256)
def __init__(x_initial: uint256):
self.x = x_initial
You can import it in a Python script tests/bar.py
like this
from contracts import Foo
my_contract = Foo(42) # This will create a new instance of the contract
my_contract.x() # Do anything with the contract as you normally would
Internally this will use the importlib
module to load the file and create a ContractFactory
.
Note
For this to work boa
must be imported first.
Due to limitations in the Python import system, only imports of the form import Foo
or from <folder> import Foo
will work and it is not possible to use import <folder>
.
Fast Mode
Titanoboa has a fast mode that can be enabled by using boa.env.enable_fast_mode()
.
This mode performs a number of optimizations by patching some py-evm objects to speed up the execution of unit tests.
Warning
Fast mode is experimental and may break other features of boa (like coverage).
ipython Vyper Cells
Titanoboa supports ipython Vyper cells. This means that you can write Vyper code in a ipython/Jupyter Notebook environment and execute it as if it was a Python cell (the contract will be compiled instead, and a ContractFactory
will be returned).
You can use Jupyter to execute titanoboa code in network mode from your browser using any wallet, using your wallet to sign transactions and call the RPC. For a full example, please see this example Jupyter notebook.
In [1]: import boa; boa.env.fork(url="<rpc server address>")
In [2]: %load_ext boa.ipython
In [3]: %%vyper Test
...: interface HasName:
...: def name() -> String[32]: view
...:
...: @external
...: def get_name_of(addr: HasName) -> String[32]:
...: return addr.name()
Out[3]: <boa.vyper.contract.VyperDeployer at 0x7f3496187190>
In [4]: c = Test.deploy()
In [5]: c.get_name_of("0xD533a949740bb3306d119CC777fa900bA034cd52")
Out[5]: 'Curve DAO Token'