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