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
  1import json
  2import logging
  3from contextlib import contextmanager
  4from functools import wraps
  5
  6import hypothesis
  7import pytest
  8import web3.exceptions
  9from eth_tester import EthereumTester, PyEVMBackend
 10from eth_tester.exceptions import TransactionFailed
 11from eth_utils import setup_DEBUG2_logging
 12from eth_utils.toolz import compose
 13from hexbytes import HexBytes
 14from web3 import Web3
 15from web3.contract import Contract
 16from web3.providers.eth_tester import EthereumTesterProvider
 17
 18from vyper import compiler
 19from vyper.ast.grammar import parse_vyper_source
 20from vyper.codegen.ir_node import IRnode
 21from vyper.compiler.input_bundle import FilesystemInputBundle, InputBundle
 22from vyper.compiler.settings import OptimizationLevel, Settings, _set_debug_mode
 23from vyper.ir import compile_ir, optimizer
 24
 25# Import the base fixtures
 26pytest_plugins = ["tests.fixtures.memorymock"]
 27
 28############
 29# PATCHING #
 30############
 31
 32
 33# disable hypothesis deadline globally
 34hypothesis.settings.register_profile("ci", deadline=None)
 35hypothesis.settings.load_profile("ci")
 36
 37
 38def set_evm_verbose_logging():
 39    logger = logging.getLogger("eth.vm.computation.Computation")
 40    setup_DEBUG2_logging()
 41    logger.setLevel("DEBUG2")
 42
 43
 44# Useful options to comment out whilst working:
 45# set_evm_verbose_logging()
 46#
 47# from vdb import vdb
 48# vdb.set_evm_opcode_debugger()
 49
 50
 51def pytest_addoption(parser):
 52    parser.addoption(
 53        "--optimize",
 54        choices=["codesize", "gas", "none"],
 55        default="gas",
 56        help="change optimization mode",
 57    )
 58    parser.addoption("--enable-compiler-debug-mode", action="store_true")
 59
 60
 61@pytest.fixture(scope="module")
 62def output_formats():
 63    output_formats = compiler.OUTPUT_FORMATS.copy()
 64    del output_formats["bb"]
 65    del output_formats["bb_runtime"]
 66    return output_formats
 67
 68
 69@pytest.fixture(scope="module")
 70def optimize(pytestconfig):
 71    flag = pytestconfig.getoption("optimize")
 72    return OptimizationLevel.from_string(flag)
 73
 74
 75@pytest.fixture(scope="session", autouse=True)
 76def debug(pytestconfig):
 77    debug = pytestconfig.getoption("enable_compiler_debug_mode")
 78    assert isinstance(debug, bool)
 79    _set_debug_mode(debug)
 80
 81
 82@pytest.fixture
 83def keccak():
 84    return Web3.keccak
 85
 86
 87@pytest.fixture
 88def make_file(tmp_path):
 89    # writes file_contents to file_name, creating it in the
 90    # tmp_path directory. returns final path.
 91    def fn(file_name, file_contents):
 92        path = tmp_path / file_name
 93        path.parent.mkdir(parents=True, exist_ok=True)
 94        with path.open("w") as f:
 95            f.write(file_contents)
 96
 97        return path
 98
 99    return fn
100
101
102# this can either be used for its side effects (to prepare a call
103# to get_contract), or the result can be provided directly to
104# compile_code / CompilerData.
105@pytest.fixture
106def make_input_bundle(tmp_path, make_file):
107    def fn(sources_dict):
108        for file_name, file_contents in sources_dict.items():
109            make_file(file_name, file_contents)
110        return FilesystemInputBundle([tmp_path])
111
112    return fn
113
114
115# for tests which just need an input bundle, doesn't matter what it is
116@pytest.fixture
117def dummy_input_bundle():
118    return InputBundle([])
119
120
121# TODO: remove me, this is just string.encode("utf-8").ljust()
122# only used in test_logging.py.
123@pytest.fixture
124def bytes_helper():
125    def bytes_helper(str, length):
126        return bytes(str, "utf-8") + bytearray(length - len(str))
127
128    return bytes_helper
129
130
131def _none_addr(datatype, data):
132    if datatype == "address" and int(data, base=16) == 0:
133        return (datatype, None)
134    else:
135        return (datatype, data)
136
137
138CONCISE_NORMALIZERS = (_none_addr,)
139
140
141@pytest.fixture(scope="module")
142def tester():
143    # set absurdly high gas limit so that london basefee never adjusts
144    # (note: 2**63 - 1 is max that evm allows)
145    custom_genesis = PyEVMBackend._generate_genesis_params(overrides={"gas_limit": 10**10})
146    custom_genesis["base_fee_per_gas"] = 0
147    backend = PyEVMBackend(genesis_parameters=custom_genesis)
148    return EthereumTester(backend=backend)
149
150
151def zero_gas_price_strategy(web3, transaction_params=None):
152    return 0  # zero gas price makes testing simpler.
153
154
155@pytest.fixture(scope="module")
156def w3(tester):
157    w3 = Web3(EthereumTesterProvider(tester))
158    w3.eth.set_gas_price_strategy(zero_gas_price_strategy)
159    return w3
160
161
162def get_compiler_gas_estimate(code, func):
163    sigs = compiler.phases.CompilerData(code).function_signatures
164    if func:
165        return compiler.utils.build_gas_estimates(sigs)[func] + 22000
166    else:
167        return sum(compiler.utils.build_gas_estimates(sigs).values()) + 22000
168
169
170def check_gas_on_chain(w3, tester, code, func=None, res=None):
171    gas_estimate = get_compiler_gas_estimate(code, func)
172    gas_actual = tester.get_block_by_number("latest")["gas_used"]
173    # Computed upper bound on the gas consumption should
174    # be greater than or equal to the amount of gas used
175    if gas_estimate < gas_actual:
176        raise Exception(f"Gas upper bound fail: bound {gas_estimate} actual {gas_actual}")
177
178    print(f"Function name: {func} - Gas estimate {gas_estimate}, Actual: {gas_actual}")
179
180
181def gas_estimation_decorator(w3, tester, fn, source_code, func):
182    def decorator(*args, **kwargs):
183        @wraps(fn)
184        def decorated_function(*args, **kwargs):
185            result = fn(*args, **kwargs)
186            if "transact" in kwargs:
187                check_gas_on_chain(w3, tester, source_code, func, res=result)
188            return result
189
190        return decorated_function(*args, **kwargs)
191
192    return decorator
193
194
195def set_decorator_to_contract_function(w3, tester, contract, source_code, func):
196    func_definition = getattr(contract, func)
197    func_with_decorator = gas_estimation_decorator(w3, tester, func_definition, source_code, func)
198    setattr(contract, func, func_with_decorator)
199
200
201class VyperMethod:
202    ALLOWED_MODIFIERS = {"call", "estimateGas", "transact", "buildTransaction"}
203
204    def __init__(self, function, normalizers=None):
205        self._function = function
206        self._function._return_data_normalizers = normalizers
207
208    def __call__(self, *args, **kwargs):
209        return self.__prepared_function(*args, **kwargs)
210
211    def __prepared_function(self, *args, **kwargs):
212        if not kwargs:
213            modifier, modifier_dict = "call", {}
214            fn_abi = [
215                x
216                for x in self._function.contract_abi
217                if x.get("name") == self._function.function_identifier
218            ].pop()
219            # To make tests faster just supply some high gas value.
220            modifier_dict.update({"gas": fn_abi.get("gas", 0) + 500000})
221        elif len(kwargs) == 1:
222            modifier, modifier_dict = kwargs.popitem()
223            if modifier not in self.ALLOWED_MODIFIERS:
224                raise TypeError(f"The only allowed keyword arguments are: {self.ALLOWED_MODIFIERS}")
225        else:
226            raise TypeError(f"Use up to one keyword argument, one of: {self.ALLOWED_MODIFIERS}")
227        return getattr(self._function(*args), modifier)(modifier_dict)
228
229
230class VyperContract:
231    """
232    An alternative Contract Factory which invokes all methods as `call()`,
233    unless you add a keyword argument. The keyword argument assigns the prep method.
234    This call
235    > contract.withdraw(amount, transact={'from': eth.accounts[1], 'gas': 100000, ...})
236    is equivalent to this call in the classic contract:
237    > contract.functions.withdraw(amount).transact({'from': eth.accounts[1], 'gas': 100000, ...})
238    """
239
240    def __init__(self, classic_contract, method_class=VyperMethod):
241        classic_contract._return_data_normalizers += CONCISE_NORMALIZERS
242        self._classic_contract = classic_contract
243        self.address = self._classic_contract.address
244        protected_fn_names = [fn for fn in dir(self) if not fn.endswith("__")]
245
246        try:
247            fn_names = [fn["name"] for fn in self._classic_contract.functions._functions]
248        except web3.exceptions.NoABIFunctionsFound:
249            fn_names = []
250
251        for fn_name in fn_names:
252            # Override namespace collisions
253            if fn_name in protected_fn_names:
254                raise AttributeError(f"{fn_name} is protected!")
255            else:
256                _classic_method = getattr(self._classic_contract.functions, fn_name)
257                _concise_method = method_class(
258                    _classic_method, self._classic_contract._return_data_normalizers
259                )
260            setattr(self, fn_name, _concise_method)
261
262    @classmethod
263    def factory(cls, *args, **kwargs):
264        return compose(cls, Contract.factory(*args, **kwargs))
265
266
267@pytest.fixture
268def get_contract_from_ir(w3, optimize):
269    def ir_compiler(ir, *args, **kwargs):
270        ir = IRnode.from_list(ir)
271        if optimize != OptimizationLevel.NONE:
272            ir = optimizer.optimize(ir)
273
274        bytecode, _ = compile_ir.assembly_to_evm(
275            compile_ir.compile_to_assembly(ir, optimize=optimize)
276        )
277
278        abi = kwargs.get("abi") or []
279        c = w3.eth.contract(abi=abi, bytecode=bytecode)
280        deploy_transaction = c.constructor()
281        tx_hash = deploy_transaction.transact()
282        address = w3.eth.get_transaction_receipt(tx_hash)["contractAddress"]
283        contract = w3.eth.contract(
284            address, abi=abi, bytecode=bytecode, ContractFactoryClass=VyperContract
285        )
286        return contract
287
288    return ir_compiler
289
290
291def _get_contract(
292    w3,
293    source_code,
294    optimize,
295    output_formats,
296    *args,
297    override_opt_level=None,
298    input_bundle=None,
299    **kwargs,
300):
301    settings = Settings()
302    settings.evm_version = kwargs.pop("evm_version", None)
303    settings.optimize = override_opt_level or optimize
304    out = compiler.compile_code(
305        source_code,
306        # test that all output formats can get generated
307        output_formats=output_formats,
308        settings=settings,
309        input_bundle=input_bundle,
310        show_gas_estimates=True,  # Enable gas estimates for testing
311    )
312    parse_vyper_source(source_code)  # Test grammar.
313    json.dumps(out["metadata"])  # test metadata is json serializable
314    abi = out["abi"]
315    bytecode = out["bytecode"]
316    value = kwargs.pop("value_in_eth", 0) * 10**18  # Handle deploying with an eth value.
317    c = w3.eth.contract(abi=abi, bytecode=bytecode)
318    deploy_transaction = c.constructor(*args)
319    tx_info = {"from": w3.eth.accounts[0], "value": value, "gasPrice": 0}
320    tx_info.update(kwargs)
321    tx_hash = deploy_transaction.transact(tx_info)
322    address = w3.eth.get_transaction_receipt(tx_hash)["contractAddress"]
323    return w3.eth.contract(address, abi=abi, bytecode=bytecode, ContractFactoryClass=VyperContract)
324
325
326@pytest.fixture(scope="module")
327def get_contract(w3, optimize, output_formats):
328    def fn(source_code, *args, **kwargs):
329        return _get_contract(w3, source_code, optimize, output_formats, *args, **kwargs)
330
331    return fn
332
333
334@pytest.fixture
335def get_contract_with_gas_estimation(tester, w3, optimize, output_formats):
336    def get_contract_with_gas_estimation(source_code, *args, **kwargs):
337        contract = _get_contract(w3, source_code, optimize, output_formats, *args, **kwargs)
338        for abi_ in contract._classic_contract.functions.abi:
339            if abi_["type"] == "function":
340                set_decorator_to_contract_function(w3, tester, contract, source_code, abi_["name"])
341        return contract
342
343    return get_contract_with_gas_estimation
344
345
346@pytest.fixture
347def get_contract_with_gas_estimation_for_constants(w3, optimize, output_formats):
348    def get_contract_with_gas_estimation_for_constants(source_code, *args, **kwargs):
349        return _get_contract(w3, source_code, optimize, output_formats, *args, **kwargs)
350
351    return get_contract_with_gas_estimation_for_constants
352
353
354@pytest.fixture(scope="module")
355def get_contract_module(optimize, output_formats):
356    """
357    This fixture is used for Hypothesis tests to ensure that
358    the same contract is called over multiple runs of the test.
359    """
360    custom_genesis = PyEVMBackend._generate_genesis_params(overrides={"gas_limit": 4500000})
361    custom_genesis["base_fee_per_gas"] = 0
362    backend = PyEVMBackend(genesis_parameters=custom_genesis)
363    tester = EthereumTester(backend=backend)
364    w3 = Web3(EthereumTesterProvider(tester))
365    w3.eth.set_gas_price_strategy(zero_gas_price_strategy)
366
367    def get_contract_module(source_code, *args, **kwargs):
368        return _get_contract(w3, source_code, optimize, output_formats, *args, **kwargs)
369
370    return get_contract_module
371
372
373def _deploy_blueprint_for(w3, source_code, optimize, output_formats, initcode_prefix=b"", **kwargs):
374    settings = Settings()
375    settings.evm_version = kwargs.pop("evm_version", None)
376    settings.optimize = optimize
377    out = compiler.compile_code(
378        source_code,
379        output_formats=output_formats,
380        settings=settings,
381        show_gas_estimates=True,  # Enable gas estimates for testing
382    )
383    parse_vyper_source(source_code)  # Test grammar.
384    abi = out["abi"]
385    bytecode = HexBytes(initcode_prefix) + HexBytes(out["bytecode"])
386    bytecode_len = len(bytecode)
387    bytecode_len_hex = hex(bytecode_len)[2:].rjust(4, "0")
388    # prepend a quick deploy preamble
389    deploy_preamble = HexBytes("61" + bytecode_len_hex + "3d81600a3d39f3")
390    deploy_bytecode = HexBytes(deploy_preamble) + bytecode
391
392    deployer_abi = []  # just a constructor
393    c = w3.eth.contract(abi=deployer_abi, bytecode=deploy_bytecode)
394    deploy_transaction = c.constructor()
395    tx_info = {"from": w3.eth.accounts[0], "value": 0, "gasPrice": 0}
396
397    tx_hash = deploy_transaction.transact(tx_info)
398    address = w3.eth.get_transaction_receipt(tx_hash)["contractAddress"]
399
400    # sanity check
401    assert w3.eth.get_code(address) == bytecode, (w3.eth.get_code(address), bytecode)
402
403    def factory(address):
404        return w3.eth.contract(
405            address, abi=abi, bytecode=bytecode, ContractFactoryClass=VyperContract
406        )
407
408    return w3.eth.contract(address, bytecode=deploy_bytecode), factory
409
410
411@pytest.fixture(scope="module")
412def deploy_blueprint_for(w3, optimize, output_formats):
413    def deploy_blueprint_for(source_code, *args, **kwargs):
414        return _deploy_blueprint_for(w3, source_code, optimize, output_formats, *args, **kwargs)
415
416    return deploy_blueprint_for
417
418
419# TODO: this should not be a fixture.
420# remove me and replace all uses with `with pytest.raises`.
421@pytest.fixture
422def assert_compile_failed():
423    def assert_compile_failed(function_to_test, exception=Exception):
424        with pytest.raises(exception):
425            function_to_test()
426
427    return assert_compile_failed
428
429
430@pytest.fixture
431def create2_address_of(keccak):
432    def _f(_addr, _salt, _initcode):
433        prefix = HexBytes("0xff")
434        addr = HexBytes(_addr)
435        salt = HexBytes(_salt)
436        initcode = HexBytes(_initcode)
437        return keccak(prefix + addr + salt + keccak(initcode))[12:]
438
439    return _f
440
441
442@pytest.fixture
443def side_effects_contract(get_contract):
444    def generate(ret_type):
445        """
446        Generates a Vyper contract with an external `foo()` function, which
447        returns the specified return value of the specified return type, for
448        testing side effects using the `assert_side_effects_invoked` fixture.
449        """
450        code = f"""
451counter: public(uint256)
452
453@external
454def foo(s: {ret_type}) -> {ret_type}:
455    self.counter += 1
456    return s
457    """
458        contract = get_contract(code)
459        return contract
460
461    return generate
462
463
464@pytest.fixture
465def assert_side_effects_invoked():
466    def assert_side_effects_invoked(side_effects_contract, side_effects_trigger, n=1):
467        start_value = side_effects_contract.counter()
468
469        side_effects_trigger()
470
471        end_value = side_effects_contract.counter()
472        assert end_value == start_value + n
473
474    return assert_side_effects_invoked
475
476
477@pytest.fixture
478def get_logs(w3):
479    def get_logs(tx_hash, c, event_name):
480        tx_receipt = w3.eth.get_transaction_receipt(tx_hash)
481        return c._classic_contract.events[event_name]().process_receipt(tx_receipt)
482
483    return get_logs
484
485
486@pytest.fixture(scope="module")
487def tx_failed(tester):
488    @contextmanager
489    def fn(exception=TransactionFailed, exc_text=None):
490        snapshot_id = tester.take_snapshot()
491        with pytest.raises(exception) as excinfo:
492            yield excinfo
493        tester.revert_to_snapshot(snapshot_id)
494        if exc_text:
495            # TODO test equality
496            assert exc_text in str(excinfo.value), (exc_text, excinfo.value)
497
498    return fn

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
1storedData: public(int128)
2
3@external
4def __init__(_x: int128):
5  self.storedData = _x
6
7@external
8def set(_x: int128):
9  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
 7def storage_contract(w3, 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(w3, storage_contract):
21    k0 = w3.eth.accounts[0]
22
23    # Let k0 try to set the value to 10
24    storage_contract.set(10, transact={"from": k0})
25    assert storage_contract.storedData() == 10  # Directly access storedData
26
27    # Let k0 try to set the value to -5
28    storage_contract.set(-5, transact={"from": k0})
29    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
 1event DataChange:
 2    setter: indexed(address)
 3    value: int128
 4
 5storedData: public(int128)
 6
 7@external
 8def __init__(_x: int128):
 9  self.storedData = _x
10
11@external
12def set(_x: int128):
13  assert _x >= 0, "No negative values"
14  assert self.storedData < 100, "Storage is locked when 100 or more is stored"
15  self.storedData = _x
16  log DataChange(msg.sender, _x)
17
18@external
19def reset():
20  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(tester):
    @contextmanager
    def fn(exception=TransactionFailed, exc_text=None):
        snapshot_id = tester.take_snapshot()
        with pytest.raises(exception) as excinfo:
            yield excinfo
        tester.revert_to_snapshot(snapshot_id)
        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
def get_logs(w3):
    def get_logs(tx_hash, c, event_name):
        tx_receipt = w3.eth.get_transaction_receipt(tx_hash)
        return c._classic_contract.events[event_name]().process_receipt(tx_receipt)

    return 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 web3.exceptions import ValidationError
 3
 4INITIAL_VALUE = 4
 5
 6
 7@pytest.fixture
 8def adv_storage_contract(w3, 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(w3, adv_storage_contract, tx_failed):
22    k1 = w3.eth.accounts[1]
23
24    # Try to set the storage to a negative amount
25    with tx_failed():
26        adv_storage_contract.set(-10, transact={"from": k1})
27
28    # Lock the contract by storing more than 100. Then try to change the value
29    adv_storage_contract.set(150, transact={"from": k1})
30    with tx_failed():
31        adv_storage_contract.set(10, transact={"from": k1})
32
33    # Reset the contract and try to change the value
34    adv_storage_contract.reset(transact={"from": k1})
35    adv_storage_contract.set(10, transact={"from": k1})
36    assert adv_storage_contract.storedData() == 10
37
38    # Assert a different exception (ValidationError for non-matching argument type)
39    with tx_failed(ValidationError):
40        adv_storage_contract.set("foo", transact={"from": k1})
41
42    # Assert a different exception that contains specific text
43    with tx_failed(ValidationError, "invocation failed due to improper number of arguments"):
44        adv_storage_contract.set(1, 2, transact={"from": k1})
45
46
47def test_events(w3, adv_storage_contract, get_logs):
48    k1, k2 = w3.eth.accounts[:2]
49
50    tx1 = adv_storage_contract.set(10, transact={"from": k1})
51    tx2 = adv_storage_contract.set(20, transact={"from": k2})
52    tx3 = adv_storage_contract.reset(transact={"from": k1})
53
54    # Save DataChange logs from all three transactions
55    logs1 = get_logs(tx1, adv_storage_contract, "DataChange")
56    logs2 = get_logs(tx2, adv_storage_contract, "DataChange")
57    logs3 = get_logs(tx3, adv_storage_contract, "DataChange")
58
59    # Check log contents
60    assert len(logs1) == 1
61    assert logs1[0].args.value == 10
62
63    assert len(logs2) == 1
64    assert logs2[0].args.setter == k2
65
66    assert not logs3  # tx3 does not generate a log