Testing with Ethereum Tester

Ethereum Tester is a tool suite for testing Ethereum based applications.

This section provides a quick overview of testing with eth-tester. To learn more, you can view the documentation at the Github repo or join the Gitter channel.

Getting Started

Prior to testing, the Vyper specific contract conversion and the blockchain related fixtures need to be set up. These fixtures will be used in every test file and should therefore be defined in conftest.py.

Note

Since the testing is done in the pytest framework, you can make use of pytest.ini, tox.ini and setup.cfg and you can use most IDEs’ pytest plugins.

conftest.py
  1from contextlib import contextmanager
  2from random import Random
  3from typing import Generator
  4
  5import hypothesis
  6import pytest
  7from eth_keys.datatypes import PrivateKey
  8from hexbytes import HexBytes
  9
 10import vyper.evm.opcodes as evm_opcodes
 11from tests.evm_backends.base_env import BaseEnv, ExecutionReverted
 12from tests.evm_backends.pyevm_env import PyEvmEnv
 13from tests.evm_backends.revm_env import RevmEnv
 14from tests.utils import working_directory
 15from vyper import compiler
 16from vyper.codegen.ir_node import IRnode
 17from vyper.compiler.input_bundle import FilesystemInputBundle, InputBundle
 18from vyper.compiler.settings import OptimizationLevel, Settings, set_global_settings
 19from vyper.exceptions import EvmVersionException
 20from vyper.ir import compile_ir, optimizer
 21from vyper.utils import keccak256
 22
 23############
 24# PATCHING #
 25############
 26
 27
 28# disable hypothesis deadline globally
 29hypothesis.settings.register_profile("ci", deadline=None)
 30hypothesis.settings.load_profile("ci")
 31
 32
 33def pytest_addoption(parser):
 34    parser.addoption(
 35        "--optimize",
 36        choices=["codesize", "gas", "none"],
 37        default="gas",
 38        help="change optimization mode",
 39    )
 40    parser.addoption("--enable-compiler-debug-mode", action="store_true")
 41    parser.addoption("--experimental-codegen", action="store_true")
 42    parser.addoption("--tracing", action="store_true")
 43
 44    parser.addoption(
 45        "--evm-version",
 46        choices=list(evm_opcodes.EVM_VERSIONS.keys()),
 47        default="shanghai",
 48        help="set evm version",
 49    )
 50
 51    parser.addoption(
 52        "--evm-backend", choices=["py-evm", "revm"], default="revm", help="set evm backend"
 53    )
 54
 55
 56@pytest.fixture(scope="module")
 57def output_formats():
 58    output_formats = compiler.OUTPUT_FORMATS.copy()
 59
 60    to_drop = ("bb", "bb_runtime", "cfg", "cfg_runtime", "archive", "archive_b64", "solc_json")
 61    for s in to_drop:
 62        del output_formats[s]
 63
 64    return output_formats
 65
 66
 67@pytest.fixture(scope="session")
 68def optimize(pytestconfig):
 69    flag = pytestconfig.getoption("optimize")
 70    return OptimizationLevel.from_string(flag)
 71
 72
 73@pytest.fixture(scope="session")
 74def debug(pytestconfig):
 75    debug = pytestconfig.getoption("enable_compiler_debug_mode")
 76    assert isinstance(debug, bool)
 77    return debug
 78
 79
 80@pytest.fixture(scope="session")
 81def experimental_codegen(pytestconfig):
 82    ret = pytestconfig.getoption("experimental_codegen")
 83    assert isinstance(ret, bool)
 84    return ret
 85
 86
 87@pytest.fixture(autouse=True)
 88def check_venom_xfail(request, experimental_codegen):
 89    if not experimental_codegen:
 90        return
 91
 92    marker = request.node.get_closest_marker("venom_xfail")
 93    if marker is None:
 94        return
 95
 96    # https://github.com/okken/pytest-runtime-xfail?tab=readme-ov-file#alternatives
 97    request.node.add_marker(pytest.mark.xfail(strict=True, **marker.kwargs))
 98
 99
100@pytest.fixture
101def venom_xfail(request, experimental_codegen):
102    def _xfail(*args, **kwargs):
103        if not experimental_codegen:
104            return
105        request.node.add_marker(pytest.mark.xfail(*args, strict=True, **kwargs))
106
107    return _xfail
108
109
110@pytest.fixture(scope="session")
111def evm_version(pytestconfig):
112    # note: configure the evm version that we emit code for.
113    # The env will read this fixture and apply the evm version there.
114    return pytestconfig.getoption("evm_version")
115
116
117@pytest.fixture(scope="session")
118def evm_backend(pytestconfig):
119    backend_str = pytestconfig.getoption("evm_backend")
120    return {"py-evm": PyEvmEnv, "revm": RevmEnv}[backend_str]
121
122
123@pytest.fixture(scope="session")
124def tracing(pytestconfig):
125    return pytestconfig.getoption("tracing")
126
127
128@pytest.fixture
129def chdir_tmp_path(tmp_path):
130    # this is useful for when you want imports to have relpaths
131    with working_directory(tmp_path):
132        yield
133
134
135# CMC 2024-03-01 this doesn't need to be a fixture
136@pytest.fixture
137def keccak():
138    return keccak256
139
140
141@pytest.fixture
142def make_file(tmp_path):
143    # writes file_contents to file_name, creating it in the
144    # tmp_path directory. returns final path.
145    def fn(file_name, file_contents):
146        path = tmp_path / file_name
147        path.parent.mkdir(parents=True, exist_ok=True)
148        with path.open("w") as f:
149            f.write(file_contents)
150
151        return path
152
153    return fn
154
155
156# this can either be used for its side effects (to prepare a call
157# to get_contract), or the result can be provided directly to
158# compile_code / CompilerData.
159@pytest.fixture
160def make_input_bundle(tmp_path, make_file):
161    def fn(sources_dict):
162        for file_name, file_contents in sources_dict.items():
163            make_file(file_name, file_contents)
164        return FilesystemInputBundle([tmp_path])
165
166    return fn
167
168
169# for tests which just need an input bundle, doesn't matter what it is
170@pytest.fixture
171def dummy_input_bundle():
172    return InputBundle([])
173
174
175@pytest.fixture(scope="module")
176def gas_limit():
177    # set absurdly high gas limit so that london basefee never adjusts
178    # (note: 2**63 - 1 is max that py-evm allows)
179    return 10**10
180
181
182@pytest.fixture(scope="module")
183def account_keys():
184    random = Random(b"vyper")
185    return [PrivateKey(random.randbytes(32)) for _ in range(10)]
186
187
188@pytest.fixture(scope="module")
189def env(gas_limit, evm_version, evm_backend, tracing, account_keys) -> BaseEnv:
190    return evm_backend(
191        gas_limit=gas_limit,
192        tracing=tracing,
193        block_number=1,
194        evm_version=evm_version,
195        account_keys=account_keys,
196    )
197
198
199@pytest.fixture
200def get_contract_from_ir(env, optimize):
201    def ir_compiler(ir, *args, **kwargs):
202        ir = IRnode.from_list(ir)
203        if kwargs.pop("optimize", optimize) != OptimizationLevel.NONE:
204            ir = optimizer.optimize(ir)
205
206        assembly = compile_ir.compile_to_assembly(ir, optimize=optimize)
207        bytecode, _ = compile_ir.assembly_to_evm(assembly)
208
209        abi = kwargs.pop("abi", [])
210        return env.deploy(abi, bytecode, *args, **kwargs)
211
212    return ir_compiler
213
214
215@pytest.fixture(scope="module", autouse=True)
216def compiler_settings(optimize, experimental_codegen, evm_version, debug):
217    compiler.settings.DEFAULT_ENABLE_DECIMALS = True
218    settings = Settings(
219        optimize=optimize,
220        evm_version=evm_version,
221        experimental_codegen=experimental_codegen,
222        debug=debug,
223    )
224    set_global_settings(settings)
225    return settings
226
227
228@pytest.fixture(scope="module")
229def get_contract(env, optimize, output_formats, compiler_settings):
230    def fn(source_code, *args, **kwargs):
231        if "override_opt_level" in kwargs:
232            kwargs["compiler_settings"] = Settings(
233                **dict(compiler_settings.__dict__, optimize=kwargs.pop("override_opt_level"))
234            )
235        return env.deploy_source(source_code, output_formats, *args, **kwargs)
236
237    return fn
238
239
240@pytest.fixture(scope="module")
241def deploy_blueprint_for(env, output_formats):
242    def fn(source_code, *args, **kwargs):
243        # we don't pass any settings, but it will pick up the global settings
244        return env.deploy_blueprint(source_code, output_formats, *args, **kwargs)
245
246    return fn
247
248
249@pytest.fixture(scope="module")
250def get_logs(env):
251    return env.get_logs
252
253
254# TODO: this should not be a fixture.
255# remove me and replace all uses with `with pytest.raises`.
256@pytest.fixture
257def assert_compile_failed(tx_failed):
258    def assert_compile_failed(function_to_test, exception=Exception):
259        with tx_failed(exception):
260            function_to_test()
261
262    return assert_compile_failed
263
264
265@pytest.fixture
266def create2_address_of(keccak):
267    def _f(_addr, _salt, _initcode):
268        prefix = HexBytes("0xff")
269        addr = HexBytes(_addr)
270        salt = HexBytes(_salt)
271        initcode = HexBytes(_initcode)
272        return keccak(prefix + addr + salt + keccak(initcode))[12:]
273
274    return _f
275
276
277@pytest.fixture
278def side_effects_contract(get_contract):
279    def generate(ret_type):
280        """
281        Generates a Vyper contract with an external `foo()` function, which
282        returns the specified return value of the specified return type, for
283        testing side effects using the `assert_side_effects_invoked` fixture.
284        """
285        code = f"""
286counter: public(uint256)
287
288@external
289def foo(s: {ret_type}) -> {ret_type}:
290    self.counter += 1
291    return s
292    """
293        return get_contract(code)
294
295    return generate
296
297
298@pytest.fixture
299def assert_side_effects_invoked():
300    def assert_side_effects_invoked(side_effects_contract, side_effects_trigger, n=1):
301        start_value = side_effects_contract.counter()
302
303        side_effects_trigger()
304
305        end_value = side_effects_contract.counter()
306        assert end_value == start_value + n
307
308    return assert_side_effects_invoked
309
310
311# should probably be renamed since there is no longer a transaction object
312@pytest.fixture(scope="module")
313def tx_failed(env):
314    @contextmanager
315    def fn(exception=ExecutionReverted, exc_text=None):
316        with pytest.raises(exception) as excinfo:
317            yield
318
319        if exc_text:
320            # TODO test equality
321            assert exc_text in str(excinfo.value), (exc_text, excinfo.value)
322
323    return fn
324
325
326@pytest.hookimpl(hookwrapper=True)
327def pytest_runtest_call(item) -> Generator:
328    marker = item.get_closest_marker("requires_evm_version")
329    if marker:
330        assert len(marker.args) == 1
331        version = marker.args[0]
332        if not evm_opcodes.version_check(begin=version):
333            item.add_marker(
334                pytest.mark.xfail(reason="Wrong EVM version", raises=EvmVersionException)
335            )
336
337    # Isolate tests by reverting the state of the environment after each test
338    env = item.funcargs.get("env")
339    if env:
340        with env.anchor():
341            yield
342    else:
343        yield

The final two fixtures are optional and will be discussed later. The rest of this chapter assumes that you have this code set up in your conftest.py file.

Alternatively, you can import the fixtures to conftest.py or use pytest plugins.

Writing a Basic Test

Assume the following simple contract storage.vy. It has a single integer variable and a function to set that value.

storage.vy
 1#pragma version >0.3.10
 2
 3storedData: public(int128)
 4
 5@deploy
 6def __init__(_x: int128):
 7  self.storedData = _x
 8
 9@external
10def set(_x: int128):
11  self.storedData = _x

We create a test file test_storage.py where we write our tests in pytest style.

test_storage.py
 1import pytest
 2
 3INITIAL_VALUE = 4
 4
 5
 6@pytest.fixture(scope="module")
 7def storage_contract(get_contract):
 8    with open("examples/storage/storage.vy") as f:
 9        contract_code = f.read()
10        # Pass constructor variables directly to the contract
11        contract = get_contract(contract_code, INITIAL_VALUE)
12    return contract
13
14
15def test_initial_state(storage_contract):
16    # Check if the constructor of the contract is set up properly
17    assert storage_contract.storedData() == INITIAL_VALUE
18
19
20def test_set(storage_contract):
21    storage_contract.set(10)
22    assert storage_contract.storedData() == 10  # Directly access storedData
23
24    storage_contract.set(-5)
25    assert storage_contract.storedData() == -5

First we create a fixture for the contract which will compile our contract and set up a Web3 contract object. We then use this fixture for our test functions to interact with the contract.

Note

To run the tests, call pytest or python -m pytest from your project directory.

Events and Failed Transactions

To test events and failed transactions we expand our simple storage contract to include an event and two conditions for a failed transaction: advanced_storage.vy

advanced_storage.vy
 1#pragma version >0.3.10
 2
 3event DataChange:
 4    setter: indexed(address)
 5    value: int128
 6
 7storedData: public(int128)
 8
 9@deploy
10def __init__(_x: int128):
11  self.storedData = _x
12
13@external
14def set(_x: int128):
15  assert _x >= 0, "No negative values"
16  assert self.storedData < 100, "Storage is locked when 100 or more is stored"
17  self.storedData = _x
18  log DataChange(msg.sender, _x)
19
20@external
21def reset():
22  self.storedData = 0

Next, we take a look at the two fixtures that will allow us to read the event logs and to check for failed transactions.

conftest.py
@pytest.fixture(scope="module")
def tx_failed(env):
    @contextmanager
    def fn(exception=ExecutionReverted, exc_text=None):
        with pytest.raises(exception) as excinfo:
            yield

        if exc_text:
            # TODO test equality
            assert exc_text in str(excinfo.value), (exc_text, excinfo.value)

    return fn

The fixture to assert failed transactions defaults to check for a TransactionFailed exception, but can be used to check for different exceptions too, as shown below. Also note that the chain gets reverted to the state before the failed transaction.

conftest.py
@pytest.fixture(scope="module")
def get_logs(env):
    return env.get_logs

This fixture will return a tuple with all the logs for a certain event and transaction. The length of the tuple equals the number of events (of the specified type) logged and should be checked first.

Finally, we create a new file test_advanced_storage.py where we use the new fixtures to test failed transactions and events.

test_advanced_storage.py
 1import pytest
 2from eth.codecs.abi.exceptions import EncodeError
 3
 4INITIAL_VALUE = 4
 5
 6
 7@pytest.fixture(scope="module")
 8def adv_storage_contract(get_contract):
 9    with open("examples/storage/advanced_storage.vy") as f:
10        contract_code = f.read()
11        # Pass constructor variables directly to the contract
12        contract = get_contract(contract_code, INITIAL_VALUE)
13    return contract
14
15
16def test_initial_state(adv_storage_contract):
17    # Check if the constructor of the contract is set up properly
18    assert adv_storage_contract.storedData() == INITIAL_VALUE
19
20
21def test_failed_transactions(env, adv_storage_contract, tx_failed):
22    k1 = env.accounts[1]
23    env.set_balance(k1, 10**18)
24
25    # Try to set the storage to a negative amount
26    with tx_failed():
27        adv_storage_contract.set(-10, sender=k1)
28
29    # Lock the contract by storing more than 100. Then try to change the value
30    adv_storage_contract.set(150, sender=k1)
31    with tx_failed():
32        adv_storage_contract.set(10, sender=k1)
33
34    # Reset the contract and try to change the value
35    adv_storage_contract.reset(sender=k1)
36    adv_storage_contract.set(10, sender=k1)
37    assert adv_storage_contract.storedData() == 10
38
39    # Assert a different exception (ValidationError for non-matching argument type)
40    with tx_failed(EncodeError):
41        adv_storage_contract.set("foo", sender=k1)
42
43    # Assert a different exception that contains specific text
44    with tx_failed(TypeError, "invocation failed due to improper number of arguments"):
45        adv_storage_contract.set(1, 2, sender=k1)
46
47
48def test_events(env, adv_storage_contract, get_logs):
49    k1, k2 = env.accounts[:2]
50
51    adv_storage_contract.set(10, sender=k1)
52    (log1,) = get_logs(adv_storage_contract, "DataChange")
53    adv_storage_contract.set(20, sender=k2)
54    (log2,) = get_logs(adv_storage_contract, "DataChange")
55    adv_storage_contract.reset(sender=k1)
56    logs3 = get_logs(adv_storage_contract, "DataChange")
57
58    # Check log contents
59    assert log1.args.value == 10
60    assert log2.args.setter == k2
61    assert logs3 == []  # tx3 does not generate a log