Skip to content

Commit

Permalink
Add pytest-xdist support (#159)
Browse files Browse the repository at this point in the history
- `launch_anvil()` will use a random port by default
- Run tests parallel
  • Loading branch information
miohtama authored Oct 2, 2023
1 parent 0567d5f commit 3108097
Show file tree
Hide file tree
Showing 17 changed files with 1,440 additions and 1,187 deletions.
16 changes: 10 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,24 @@ jobs:
- name: Install dependencies
run: |
poetry env use '3.10'
poetry install -E data
poetry install --all-extras
- name: Install Ganache
run: yarn global add ganache
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: 'nightly-e15e33a07c0920189fc336391f538c3dad53da73'
# - name: Workaround aave-v3-deploy npm ci failing randomly on Github
# run: |
# cd contracts/aave-v3-deploy
# npm ci

# We also work around race condition for setting up Aave NPM packages.
- name: Setup aave for tests
run: |
poetry run install-aave-for-testing
# Run tests parallel.
# By default Github gives us only 2 CPUs, but we want to parallerise a bit more.
- name: Run test scripts
run: |
poetry run pytest --tb=native
poetry run pytest --tb=native -n 6 --dist loadscope
env:
BNB_CHAIN_JSON_RPC: ${{ secrets.BNB_CHAIN_JSON_RPC }}
JSON_RPC_POLYGON_ARCHIVE: ${{ secrets.JSON_RPC_POLYGON_ARCHIVE }}
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# 0.22.17

- Make testing and `launch_anvil` distrubuted safe by randomising Anvil localhost port it binds.
Test now run in few minutes instead of tens of minutes. Tests must be still run with
`pytest --dist loadscope` as individual test modules are not parallel safe.
- Add ``eth_defi.broken_provider.set_block_tip_latency()`` to control the default delays
for which we expect the chain tip to stabilise.

Expand Down
6 changes: 6 additions & 0 deletions docs/source/development.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ Check that the tests of unmodified master branch pass:
pytest
For fast parallel test execution run with ``pytest-xdist`` across all of your CPUs:

.. code-block::
pytest -n auto --dist loadscope
You should get all green.

Some tests will be skipped, because they require full EVM nodes. JSON-RPC needs to be configured through environment variables.
Expand Down
14 changes: 14 additions & 0 deletions eth_defi/aave_v3/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,17 @@ def get_aave_hardhard_export() -> dict:
"""
hardhat_export_path = Path(__file__).resolve().parent / "aave-hardhat-localhost-export.json"
return json.loads(hardhat_export_path.read_bytes())


def install_aave_for_testing():
"""Entry-point to ensure Aave dev env is installedon Github Actions.
Because pytest-xdist does not have very good support for preventing
race conditions with fixtures, we run this problematic test
before test suite.
It will do npm install for Aave deployer.
"""
deployer = AaveDeployer()
deployer.install(echo=True)

28 changes: 21 additions & 7 deletions eth_defi/provider/anvil.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"""

import logging
import random
import sys
import time
import warnings
Expand All @@ -46,7 +47,7 @@
from requests.exceptions import ConnectionError as RequestsConnectionError
from web3 import HTTPProvider, Web3

from eth_defi.utils import is_localhost_port_listening, shutdown_hard
from eth_defi.utils import is_localhost_port_listening, shutdown_hard, find_free_port

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -192,7 +193,7 @@ def launch_anvil(
fork_url: Optional[str] = None,
unlocked_addresses: list[Union[HexAddress, str]] = None,
cmd="anvil",
port: int = 19999,
port: int | tuple = (19999, 29999, 25),
block_time=0,
launch_wait_seconds=20.0,
attempts=3,
Expand Down Expand Up @@ -313,7 +314,15 @@ def test_anvil_fork_transfer_busd(web3: Web3, large_busd_holder: HexAddress, use
List of addresses of which ownership we take to allow test code to transact as them
:param port:
Localhost port we bind for Anvil JSON-RPC
Localhost port we bind for Anvil JSON-RPC.
The tuple format is (min port, max port, opening attempts).
By default, takes a tuple range and tries to open a a random port in the range,
until empty found. This allows to run multiple parallel Anvil's during unit testing
with ``pytest -n auto``.
You can also specify an individual port.
:param launch_wait_seconds:
How long we wait anvil to start until giving up
Expand Down Expand Up @@ -354,10 +363,6 @@ def test_anvil_fork_transfer_busd(web3: Web3, large_busd_holder: HexAddress, use
"""

assert not is_localhost_port_listening(port), f"localhost port {port} occupied.\n" f"You might have a zombie Anvil process around.\nRun to kill: kill -SIGKILL $(lsof -ti:{port})"

url = f"http://localhost:{port}"

attempts_left = attempts
process = None
final_cmd = None
Expand All @@ -367,6 +372,15 @@ def test_anvil_fork_transfer_busd(web3: Web3, large_busd_holder: HexAddress, use
if unlocked_addresses is None:
unlocked_addresses = []

# Find a free port
if type(port) == tuple:
port = find_free_port(*port)
else:
warnings.warn(f"launch_anvil(port={port}) called - we recommend using the default random port range instead", DeprecationWarning, stacklevel=2)
assert not is_localhost_port_listening(port), f"localhost port {port} occupied.\n" f"You might have a zombie Anvil process around.\nRun to kill: kill -SIGKILL $(lsof -ti:{port})"

url = f"http://localhost:{port}"

# https://book.getfoundry.sh/reference/anvil/
args = dict(
port=port,
Expand Down
36 changes: 36 additions & 0 deletions eth_defi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import calendar
import datetime
import logging
import random
import socket
import time
from typing import Optional, Tuple
Expand Down Expand Up @@ -37,6 +38,41 @@ def is_localhost_port_listening(port: int, host="localhost") -> bool:
return result_of_check == 0


def find_free_port(min_port: int, max_port: int, max_attempt: int) -> int:
"""Find a free localhost port to bind.
Does by random.
.. note ::
Subject to race condition, but should be rareish.
:param min_port:
Minimum port range
:param max_port:
Maximum port range
:param max_attempt:
Give up and die with an exception if no port found after this many attempts.
:return:
Free port number
"""

assert type(min_port) == int
assert type(max_port) == int
assert type(max_attempt) == int

for attempt in range(0, max_attempt):
random_port = random.randrange(start=min_port, stop=max_port)
logger.info("Attempting to allocate port %d to Anvil", random_port)
if not is_localhost_port_listening(random_port, "127.0.0.1"):
return random_port

raise RuntimeError(f"Could not open a port with a spec: {min_port} - {max_port}, {max_attempt} attemps")


def shutdown_hard(
process: psutil.Popen,
log_level: Optional[int] = None,
Expand Down
Loading

0 comments on commit 3108097

Please sign in to comment.