Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Commit

Permalink
Merge branch 'master' of github.com:darrenburns/ward
Browse files Browse the repository at this point in the history
  • Loading branch information
Darren Burns committed Oct 16, 2019
2 parents b878eda + c35bde5 commit 7a49b2e
Show file tree
Hide file tree
Showing 8 changed files with 118 additions and 29 deletions.
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ An experimental test runner for Python 3.6+ that is heavily inspired by `pytest`

## Examples

### Dependency Injection
### Dependency injection with fixtures

In the example below, we define a single fixture named `cities`.
Our test takes a single parameter, which is also named `cities`.
Expand All @@ -26,6 +26,24 @@ def test_using_cities(cities):
expect(cities).equals(["Glasgow", "Edinburgh"])
```

Fixtures are great for extracting common setup code that you'd otherwise need to repeat at the top of your tests,
but they can also execute teardown code:

```python
@fixture
def database():
db_conn = setup_database()
yield db_conn
db_conn.close()


def test_database_connection(database):
# The database connection can be used in this test,
# and will be closed after the test has completed.
users = get_all_users(database)
expect(users).contains("Bob")
```

### The Expect API

In the (contrived) `test_capital_cities` test, we want to determine whether
Expand Down
4 changes: 2 additions & 2 deletions tests/test_fixtures.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ward import expect, fixture, raises, skip
from ward import expect, fixture, raises
from ward.fixtures import Fixture, FixtureExecutionError, FixtureRegistry


Expand Down Expand Up @@ -35,7 +35,7 @@ def parent(child_a, child_b):

# Each of the fixtures add 1, so the final value returned
# by the tree should be 4, since there are 4 fixtures.
expect(resolved_parent).equals(4)
expect(resolved_parent.resolved_val).equals(4)


def test_fixture_registry_cache_fixture(exception_raising_fixture):
Expand Down
41 changes: 39 additions & 2 deletions tests/test_suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,43 @@ def test_generate_test_runs__yields_skipped_test_result_on_test_with_skip_marker
expect(test_runs).equals(expected_runs)


def test_fixture_teardown_occurs_and_in_expected_order(module):
events = []

def fix_a():
events.append(1)
yield "a"
events.append(3)

def fix_b():
events.append(2)
return "b"

def my_test(fix_a, fix_b):
expect(fix_a).equals("a")
expect(fix_b).equals("b")

reg = FixtureRegistry()
reg.cache_fixtures(
fixtures=[
Fixture(key="fix_a", fn=fix_a, is_generator_fixture=True),
Fixture(key="fix_b", fn=fix_b, is_generator_fixture=False),
]
)

suite = Suite(
tests=[
Test(fn=my_test, module=module)
],
fixture_registry=reg,
)

# Exhaust the test runs generator
list(suite.generate_test_runs())

expect(events).equals([1, 2, 3])


# region example

def get_capitals_from_server():
Expand All @@ -114,8 +151,8 @@ def get_capitals_from_server():

@fixture
def cities():
return {"edinburgh": "scotland", "tokyo": "japan", "london": "england", "warsaw": "poland", "berlin": "germany",
"masdid": "spain"}
yield {"edinburgh": "scotland", "tokyo": "japan", "london": "england", "warsaw": "poland", "berlin": "germany",
"madrid": "spain"}


@skip
Expand Down
43 changes: 29 additions & 14 deletions ward/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import inspect
from typing import Any, Callable, Dict, Iterable
from typing import Callable, Dict, Iterable


class TestSetupError(Exception):
Expand All @@ -15,50 +15,61 @@ class FixtureExecutionError(Exception):


class Fixture:
def __init__(self, key: str, fn: Callable):
def __init__(self, key: str, fn: Callable, is_generator_fixture: bool = False):
self.key = key
self.fn = fn
self.is_generator_fixture = is_generator_fixture
self.gen = None
self.resolved_val = None
self.is_resolved = False

def fn(self):
return self.fn

def deps(self):
return inspect.signature(self.fn).parameters

def resolve(self, fix_registry) -> Any:
def resolve(self, fix_registry) -> "Fixture":
"""Traverse the fixture tree to resolve the value of this fixture"""

# If this fixture has no children, cache and return the resolved value
if not self.deps():
try:
self.resolved_val = self.fn()
if inspect.isgeneratorfunction(self.fn):
self.gen = self.fn()
self.resolved_val = next(self.gen)
else:
self.resolved_val = self.fn()
except Exception as e:
raise FixtureExecutionError(
f"Unable to execute fixture '{self.key}'"
) from e
fix_registry.cache_fixture(self)
return self.resolved_val
return self

# Otherwise, we have to find the child fixture vals, and call self
children = self.deps()
children_resolved = []
for child in children:
child_fixture = fix_registry[child]
child_resolved_val = child_fixture.resolve(fix_registry)
children_resolved.append(child_resolved_val)
child_fixture = fix_registry[child].resolve(fix_registry)
children_resolved.append(child_fixture)

# We've resolved the values of all child fixtures
try:
self.resolved_val = self.fn(*children_resolved)
child_resolved_vals = [child.resolved_val for child in children_resolved]
if inspect.isgeneratorfunction(self.fn):
self.gen = self.fn(*child_resolved_vals)
self.resolved_val = next(self.gen)
else:
self.resolved_val = self.fn(*child_resolved_vals)
except Exception as e:
raise FixtureExecutionError(
f"Unable to execute fixture '{self.key}'"
) from e

fix_registry.cache_fixture(self)
return self.resolved_val
return self

def cleanup(self):
if self.is_generator_fixture:
next(self.gen)


class FixtureRegistry:
Expand All @@ -68,7 +79,11 @@ def __init__(self):
def wrapper(func):
name = func.__name__
if name not in self._fixtures:
self._fixtures[name] = Fixture(key=name, fn=func)
self._fixtures[name] = Fixture(
key=name,
fn=func,
is_generator_fixture=inspect.isgeneratorfunction(func),
)
else:
raise CollectionError(f"Multiple fixtures named '{func.__name__}'.")
return func
Expand Down
1 change: 0 additions & 1 deletion ward/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ def run(path, filter, fail_limit):
tests = list(get_tests_in_modules(modules, filter=filter))

suite = Suite(tests=tests, fixture_registry=fixture_registry)

test_results = suite.generate_test_runs()

writer = SimpleTestResultWrite(terminal=term, suite=suite)
Expand Down
12 changes: 11 additions & 1 deletion ward/suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,17 @@ def generate_test_runs(self) -> Generator[TestResult, None, None]:
yield TestResult(test, TestOutcome.FAIL, e, message="[Error] " + str(e))
continue
try:
test(**resolved_fixtures)
resolved_vals = {k: fix.resolved_val for (k, fix) in resolved_fixtures.items()}
test(**resolved_vals)
yield TestResult(test, TestOutcome.PASS, None, message="")
except Exception as e:
yield TestResult(test, TestOutcome.FAIL, e, message="")
finally:
for fixture in resolved_fixtures.values():
if fixture.is_generator_fixture:
try:
fixture.cleanup()
except (RuntimeError, StopIteration):
# In Python 3.7, a RuntimeError is raised if we fall off the end of a generator
# (instead of a StopIteration)
pass
22 changes: 16 additions & 6 deletions ward/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@ def write_test_failure_output(term, test_result):
test_result_heading = f"{Fore.BLACK}{Back.RED}{test_module}.{test_name} failed: "
write_over_line(f"{test_result_heading}{Style.RESET_ALL}", 0, term)
err = test_result.error
if term.width:
width = term.width - 30
else:
width = 60

# Body of failure output, depends on how the test failed
if isinstance(err, TestSetupError):
write_over_line(str(err), 0, term)
elif isinstance(err, ExpectationFailed):
print()
write_over_line(
f" Given {truncate(repr(err.history[0].this), num_chars=term.width - 30)}",
f" Given {truncate(repr(err.history[0].this), num_chars=width)}",
0,
term,
)
Expand All @@ -46,9 +50,9 @@ def write_test_failure_output(term, test_result):
result_marker = f"[ {Fore.RED}{Style.RESET_ALL} ]{Fore.RED}"

if expect.op == "satisfies" and hasattr(expect.that, "__name__"):
expect_that = truncate(expect.that.__name__, num_chars=term.width - 30)
expect_that = truncate(expect.that.__name__, num_chars=width)
else:
expect_that = truncate(repr(expect.that), num_chars=term.width - 30)
expect_that = truncate(repr(expect.that), num_chars=width)
write_over_line(
f" {result_marker} it {expect.op} {expect_that}{Style.RESET_ALL}",
0,
Expand Down Expand Up @@ -77,6 +81,9 @@ def write_test_result(test_result: TestResult, term: Terminal):


def write_over_progress_bar(green_pct: float, red_pct: float, term: Terminal):
if not term.is_a_tty:
return

num_green_bars = int(green_pct * term.width)
num_red_bars = int(red_pct * term.width)

Expand Down Expand Up @@ -146,7 +153,8 @@ def run_and_write_test_results(self) -> ExitCode:
pass_pct = passed / max(passed + failed, 1)
fail_pct = 1.0 - pass_pct

write_over_progress_bar(pass_pct, fail_pct, self.terminal)
if self.terminal.is_a_tty:
write_over_progress_bar(pass_pct, fail_pct, self.terminal)

info_bar = (
f"{Fore.CYAN}{next(spinner)} "
Expand Down Expand Up @@ -287,7 +295,8 @@ def output_why_test_failed(self, test_result: TestResult):

def output_test_result_summary(self, test_results: List[TestResult]):
num_passed, num_failed, num_skipped = self._get_num_passed_failed_skipped(test_results)
print(self.generate_chart(num_passed=num_passed, num_failed=num_failed, num_skipped=num_skipped))
if self.terminal.is_a_tty:
print(self.generate_chart(num_passed=num_passed, num_failed=num_failed, num_skipped=num_skipped))
print(f"Test run complete [ "
f"{Fore.RED}{num_failed} failed "
f"{Fore.YELLOW} {num_skipped} skipped "
Expand Down Expand Up @@ -329,7 +338,8 @@ def generate_chart(self, num_passed, num_failed, num_skipped):
def output_test_run_post_failure_summary(self, test_results: List[TestResult]):
num_passed, num_failed, num_skipped = self._get_num_passed_failed_skipped(test_results)
if any(r.outcome == TestOutcome.FAIL for r in test_results):
print(self.generate_chart(num_passed, num_failed, num_skipped))
if self.terminal.is_a_tty:
print(self.generate_chart(num_passed, num_failed, num_skipped))

def _get_num_passed_failed_skipped(self, test_results: List[TestResult]) -> Tuple[int, int, int]:
num_passed = len([r for r in test_results if r.outcome == TestOutcome.PASS])
Expand Down
4 changes: 2 additions & 2 deletions ward/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from types import MappingProxyType, ModuleType
from typing import Any, Callable, Dict

from ward.fixtures import FixtureRegistry
from ward.fixtures import FixtureRegistry, Fixture


class WardMarker(Enum):
Expand Down Expand Up @@ -41,7 +41,7 @@ def deps(self) -> MappingProxyType:
def has_deps(self) -> bool:
return len(self.deps()) > 0

def resolve_args(self, fixture_registry: FixtureRegistry) -> Dict[str, Any]:
def resolve_args(self, fixture_registry: FixtureRegistry) -> Dict[str, Fixture]:
"""Resolve fixture that has been injected into this test"""
if not self.has_deps():
return {}
Expand Down

0 comments on commit 7a49b2e

Please sign in to comment.