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.
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.
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.
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
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.
@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.
@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.
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