Skip to content

Commit

Permalink
feat: test command (#225)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey authored Nov 9, 2021
1 parent d9eae4f commit ac2e358
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 2 deletions.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ target-version = ['py37', 'py38', 'py39']
include = '\.pyi?$'

[tool.pytest.ini_options]
norecursedirs = "data"
addopts = "-p no:ape_test" # NOTE: Prevents the ape plugin from activating on our tests
python_files = "test_*.py"
testpaths = "tests"
markers = "fuzzing: Run Hypothesis fuzz test suite"
Expand Down
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

extras_require = {
"test": [ # `test` GitHub Action jobs uses this
"pytest>=6.0,<7.0", # Core testing package
"pytest-xdist", # multi-process runner
"pytest-cov", # Coverage analyzer plugin
"pytest-mock", # For creating mocks
Expand Down Expand Up @@ -77,17 +76,20 @@
"importlib-metadata",
"singledispatchmethod ; python_version<'3.8'",
"IPython>=7.25",
"pytest>=6.0,<7.0",
"web3[tester]>=5.18.0,<6.0.0",
],
entry_points={
"console_scripts": ["ape=ape._cli:cli"],
"pytest11": ["ape_test=ape_test.plugin"],
"ape_cli_subcommands": [
"ape_accounts=ape_accounts._cli:cli",
"ape_compile=ape_compile._cli:cli",
"ape_console=ape_console._cli:cli",
"ape_plugins=ape_plugins._cli:cli",
"ape_run=ape_run._cli:cli",
"ape_networks=ape_networks._cli:cli",
"ape_test=ape_test._cli:cli",
],
},
python_requires=">=3.7,<3.10",
Expand Down
4 changes: 3 additions & 1 deletion src/ape/managers/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def get_provider_from_choice(
provider_settings: Optional[Dict] = None,
) -> ProviderAPI:
if network_choice is None:
return self.default["development"].get_provider(provider_settings=provider_settings)
return self.default_ecosystem["development"].get_provider(
provider_settings=provider_settings
)

selections = network_choice.split(":")

Expand Down
17 changes: 17 additions & 0 deletions src/ape_test/_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import sys

import click
import pytest


@click.command(
add_help_option=False, # NOTE: This allows pass-through to pytest's help
short_help="Launches pytest and runs the tests for a project",
context_settings=dict(ignore_unknown_options=True),
)
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED)
def cli(pytest_args):
return_code = pytest.main([*pytest_args], ["ape_test"])
if return_code:
# only exit with non-zero status to make testing easier
sys.exit(return_code)
31 changes: 31 additions & 0 deletions src/ape_test/contextmanagers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Optional, Type

from ape.exceptions import ContractLogicError


class RevertsContextManager:
def __init__(self, expected_message: Optional[str] = None):
self.expected_message = expected_message

def __enter__(self):
pass

def __exit__(self, exc_type: Type, exc_value: Exception, traceback) -> bool:
if exc_type is None:
raise AssertionError("Transaction did not revert.")

if not isinstance(exc_value, ContractLogicError):
raise AssertionError(
f"Transaction did not revert.\nHowever, an exception occurred: {exc_value}"
) from exc_value

actual = exc_value.revert_message

if self.expected_message is not None and self.expected_message != actual:
raise AssertionError(
f"Expected revert message '{self.expected_message}' but got '{actual}'."
)

# Returning True causes the expected exception not to get raised
# and the test to pass
return True
33 changes: 33 additions & 0 deletions src/ape_test/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import List

import pytest

from ape.api import ProviderAPI, TestAccountAPI
from ape.exceptions import ProviderError
from ape.managers.accounts import AccountManager
from ape.managers.networks import NetworkManager
from ape.managers.project import ProjectManager


class PytestApeFixtures:
def __init__(self, accounts: AccountManager, networks: NetworkManager, project: ProjectManager):
self._accounts = accounts
self._networks = networks
self._project = project

@pytest.fixture
def accounts(self, provider) -> List[TestAccountAPI]:
return self._accounts.test_accounts

@pytest.fixture
def provider(self) -> ProviderAPI:
active_provider = self._networks.active_provider

if active_provider is None:
raise ProviderError("Provider is not set.")

return active_provider

@pytest.fixture(scope="session")
def project(self) -> ProjectManager:
return self._project
62 changes: 62 additions & 0 deletions src/ape_test/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import sys
from pathlib import Path

import pytest

from ape import accounts, networks, project
from ape_test.fixtures import PytestApeFixtures
from ape_test.runners import PytestApeRunner


def pytest_addoption(parser):
parser.addoption(
"--showinternal",
action="store_true",
)
parser.addoption(
"--network",
action="store",
default=networks.default_ecosystem.name,
help="Override the default network and provider. (see ``ape networks list`` for options)",
)
# NOTE: Other testing plugins, such as hypothesis, should integrate with pytest separately


def pytest_configure(config):
# Do not include ape internals in tracebacks unless explicitly asked
if not config.getoption("showinternal"):
base_path = Path(sys.modules["ape"].__file__).parent.as_posix()

def is_module(v):
return getattr(v, "__file__", None) and v.__file__.startswith(base_path)

modules = [v for v in sys.modules.values() if is_module(v)]
for module in modules:
module.__tracebackhide__ = True

# Enable verbose output if stdout capture is disabled
config.option.verbose = config.getoption("capture") == "no"

# Inject the runner plugin (must happen before fixtures registration)
session = PytestApeRunner(config, project, networks)
config.pluginmanager.register(session, "ape-test")

# Inject fixtures
fixtures = PytestApeFixtures(accounts, networks, project)
config.pluginmanager.register(fixtures, "ape-fixtures")


def pytest_load_initial_conftests(early_config):
"""
Compile contracts before loading conftests.
"""
cap_sys = early_config.pluginmanager.get_plugin("capturemanager")
if not project.sources_missing:
# Suspend stdout capture to display compilation data
cap_sys.suspend()
try:
project.load_contracts()
except Exception as err:
raise pytest.UsageError(f"Unable to load project. Reason: {err}")
finally:
cap_sys.resume()
100 changes: 100 additions & 0 deletions src/ape_test/runners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from typing import Optional

import pytest

import ape
from ape.api import TestProviderAPI
from ape.logging import logger

from .contextmanagers import RevertsContextManager


class PytestApeRunner:
def __init__(self, config, project, networks):
self.config = config
self.project = project
self.networks = networks
self._warned_for_missing_features = False
ape.reverts = RevertsContextManager

@property
def _network_choice(self) -> str:
"""
The option the user providers via --network (or the default).
"""
return self.config.getoption("network")

@property
def _provider(self) -> Optional[TestProviderAPI]:
"""
The active provider.
"""
return self.networks.active_provider

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(self, item, nextitem):
snapshot_id = None

# Try to snapshot if the provider supported it.
if hasattr(self._provider, "snapshot"):
try:
snapshot_id = self._provider.snapshot()
except NotImplementedError:
self._warn_for_unimplemented_snapshot()
pass
else:
self._warn_for_unimplemented_snapshot()

yield

# Try to revert to the state before the test began.
if snapshot_id:
self._provider.revert(snapshot_id)

def _warn_for_unimplemented_snapshot(self):
if self._warned_for_missing_features:
return

logger.warning(
"The connected provider does not support snapshotting. "
"Tests will not be completely isolated."
)
self._warned_for_missing_features = True

def pytest_sessionstart(self):
"""
Called after the `Session` object has been created and before performing
collection and entering the run test loop.
Removes `PytestAssertRewriteWarning` warnings from the terminalreporter.
This prevents warnings that "the `ape` library was already imported and
so related assertions cannot be rewritten". The warning is not relevant
for end users who are performing tests with ape.
"""
reporter = self.config.pluginmanager.get_plugin("terminalreporter")
warnings = reporter.stats.pop("warnings", [])
warnings = [i for i in warnings if "PytestAssertRewriteWarning" not in i.message]
if warnings and not self.config.getoption("--disable-warnings"):
reporter.stats["warnings"] = warnings

@pytest.hookimpl(trylast=True, hookwrapper=True)
def pytest_collection_finish(self, session):
"""
Called after collection has been performed and modified.
"""
outcome = yield

# Only start provider if collected tests.
if not outcome.get_result() and session.items and not self.networks.active_provider:
self.networks.active_provider = self.networks.get_provider_from_choice(
self._network_choice
)
self.networks.active_provider.connect()

def pytest_sessionfinish(self):
"""
Called after whole test run finished, right before returning the exit
status to the system.
"""
if self._provider is not None:
self._provider.disconnect()

0 comments on commit ac2e358

Please sign in to comment.