diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31126717..f9eafeb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,11 +11,11 @@ jobs: name: Testing on Python ${{ matrix.python-version }} and ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: - max-parallel: 6 + max-parallel: 9 fail-fast: false matrix: os: [ubuntu-latest, macOS-latest, windows-latest] - python-version: [3.6, 3.7] + python-version: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 72c432cc..88b508a2 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -7,11 +7,11 @@ jobs: name: Testing on Python ${{ matrix.python-version }} and ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: - max-parallel: 6 + max-parallel: 9 fail-fast: false matrix: os: [ubuntu-latest, macOS-latest, windows-latest] - python-version: [3.6, 3.7] + python-version: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v1 diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 9740360c..229c6923 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -1,18 +1,188 @@ -from ward import expect, fixture, test +from typing import List + +from tests.test_suite import testable_test +from ward import expect, fixture, test, Scope from ward.fixtures import Fixture, FixtureCache +from ward.testing import Test @fixture def exception_raising_fixture(): + @fixture def i_raise_an_exception(): raise ZeroDivisionError() return Fixture(fn=i_raise_an_exception) -@test("FixtureCache.cache_fixture can store and retrieve a single fixture") +@test("FixtureCache.cache_fixture caches a single fixture") def _(f=exception_raising_fixture): cache = FixtureCache() - cache.cache_fixture(f) + cache.cache_fixture(f, "test_id") + + expect(cache.get(f.key, Scope.Test, "test_id")).equals(f) + + +@fixture +def recorded_events(): + return [] + + +@fixture +def global_fixture(events=recorded_events): + @fixture(scope=Scope.Global) + def g(): + yield "g" + events.append("teardown g") + + return g + + +@fixture +def module_fixture(events=recorded_events): + @fixture(scope=Scope.Module) + def m(): + yield "m" + events.append("teardown m") + + return m + + +@fixture +def default_fixture(events=recorded_events): + @fixture + def t(): + yield "t" + events.append("teardown t") + + return t + + +@fixture +def my_test( + f1=exception_raising_fixture, + f2=global_fixture, + f3=module_fixture, + f4=default_fixture, +): + # Inject these fixtures into a test, and resolve them + # to ensure they're ready to be torn down. + @testable_test + def t(f1=f1, f2=f2, f3=f3, f4=f4): + pass + + return Test(t, "") + + +@fixture +def cache( + t=my_test +): + c = FixtureCache() + t.resolve_args(c) + return c + + +@test("FixtureCache.get_fixtures_at_scope correct for Scope.Test") +def _( + cache: FixtureCache = cache, + t: Test = my_test, + default_fixture=default_fixture, +): + fixtures_at_scope = cache.get_fixtures_at_scope(Scope.Test, t.id) + + fixture = list(fixtures_at_scope.values())[0] + + expect(fixtures_at_scope).has_length(1) + expect(fixture.fn).equals(default_fixture) + + +@test("FixtureCache.get_fixtures_at_scope correct for Scope.Module") +def _( + cache: FixtureCache = cache, + module_fixture=module_fixture, +): + fixtures_at_scope = cache.get_fixtures_at_scope(Scope.Module, testable_test.path) + + fixture = list(fixtures_at_scope.values())[0] + + expect(fixtures_at_scope).has_length(1) + expect(fixture.fn).equals(module_fixture) + + +@test("FixtureCache.get_fixtures_at_scope correct for Scope.Global") +def _( + cache: FixtureCache = cache, + global_fixture=global_fixture, +): + fixtures_at_scope = cache.get_fixtures_at_scope(Scope.Global, Scope.Global) + + fixture = list(fixtures_at_scope.values())[0] + + expect(fixtures_at_scope).has_length(1) + expect(fixture.fn).equals(global_fixture) + + +@test("FixtureCache.teardown_fixtures_for_scope removes Test fixtures from cache") +def _( + cache: FixtureCache = cache, + test: Test = my_test, +): + cache.teardown_fixtures_for_scope(Scope.Test, test.id) + + fixtures_at_scope = cache.get_fixtures_at_scope(Scope.Test, test.id) + + expect(fixtures_at_scope).equals({}) + + +@test("FixtureCache.teardown_fixtures_for_scope runs teardown for Test fixtures") +def _( + cache: FixtureCache = cache, + test: Test = my_test, + events: List = recorded_events, +): + cache.teardown_fixtures_for_scope(Scope.Test, test.id) + + expect(events).equals(["teardown t"]) + + +@test("FixtureCache.teardown_fixtures_for_scope removes Module fixtures from cache") +def _( + cache: FixtureCache = cache, +): + cache.teardown_fixtures_for_scope(Scope.Module, testable_test.path) + + fixtures_at_scope = cache.get_fixtures_at_scope(Scope.Module, testable_test.path) + + expect(fixtures_at_scope).equals({}) + + +@test("FixtureCache.teardown_fixtures_for_scope runs teardown for Module fixtures") +def _( + cache: FixtureCache = cache, + events: List = recorded_events, +): + cache.teardown_fixtures_for_scope(Scope.Module, testable_test.path) + + expect(events).equals(["teardown m"]) + + +@test("FixtureCache.teardown_global_fixtures removes Global fixtures from cache") +def _( + cache: FixtureCache = cache, +): + cache.teardown_global_fixtures() + + fixtures_at_scope = cache.get_fixtures_at_scope(Scope.Global, Scope.Global) + + expect(fixtures_at_scope).equals({}) + + +@test("FixtureCache.teardown_global_fixtures runs teardown of all Global fixtures") +def _( + cache: FixtureCache = cache, + events: List = recorded_events, +): + cache.teardown_global_fixtures() - expect(cache[f.key]).equals(f) + expect(events).equals(["teardown g"]) diff --git a/tests/test_suite.py b/tests/test_suite.py index d3053fa4..e1d6efb6 100644 --- a/tests/test_suite.py +++ b/tests/test_suite.py @@ -1,12 +1,24 @@ +from collections import defaultdict from unittest import mock from ward import expect, fixture from ward.fixtures import Fixture from ward.models import Scope, SkipMarker from ward.suite import Suite -from ward.testing import Test, skip, test, TestOutcome, TestResult +from ward.testing import Test, skip, TestOutcome, TestResult, test NUMBER_OF_TESTS = 5 +FORCE_TEST_PATH = "path/of/test" + + +def testable_test(func): + return test( + "testable test description", + _force_path=FORCE_TEST_PATH, + _collect_into=defaultdict(list) + )(func) + +testable_test.path = FORCE_TEST_PATH @fixture @@ -41,6 +53,7 @@ def example_test(module=module, fixtures=fixtures): def f(): return 123 + @testable_test def t(fix_a=f): return fix_a @@ -49,7 +62,11 @@ def t(fix_a=f): @fixture def skipped_test(module=module): - return Test(fn=lambda: expect(1).equals(1), module_name=module, marker=SkipMarker()) + @testable_test + def t(): + expect(1).equals(1) + + return Test(fn=t, module_name=module, marker=SkipMarker()) @fixture @@ -85,19 +102,19 @@ def _(suite=suite): @test("Suite.generate_test_runs yields a FAIL TestResult on `assert False`") def _(module=module): - def test_i_fail(): + @testable_test + def _(): assert False - test = Test(fn=test_i_fail, module_name=module) - failing_suite = Suite(tests=[test]) + t = Test(fn=_, module_name=module) + failing_suite = Suite(tests=[t]) results = failing_suite.generate_test_runs() result = next(results) expected_result = TestResult( - test=test, outcome=TestOutcome.FAIL, error=mock.ANY, message="" + test=t, outcome=TestOutcome.FAIL, error=mock.ANY, message="" ) - expect(result).equals(expected_result) expect(result.error).instance_of(AssertionError) @@ -132,6 +149,7 @@ def fix_b(): events.append(2) return "b" + @testable_test def my_test(fix_a=fix_a, fix_b=fix_b): expect(fix_a).equals("a") expect(fix_b).equals("b") @@ -165,6 +183,7 @@ def fix_c(fix_b=fix_b): yield "c" events.append(5) + @testable_test def my_test(fix_a=fix_a, fix_c=fix_c): expect(fix_a).equals("a") expect(fix_c).equals("c") @@ -194,6 +213,7 @@ def b(a=a): def c(a=a): events.append(3) + @testable_test def test(b=b, c=c): pass @@ -214,15 +234,24 @@ def a(): yield "a" events.append("teardown") + @testable_test def test1(a=a): events.append("test1") + @testable_test def test2(a=a): events.append("test2") + @testable_test def test3(a=a): events.append("test3") + # For testing purposes we need to assign paths ourselves, + # since our test functions are all defined at the same path + test1.ward_meta.path = "module1" + test2.ward_meta.path = "module2" + test3.ward_meta.path = "module2" + suite = Suite( tests=[ Test(fn=test1, module_name="module1"), @@ -244,7 +273,6 @@ def test3(a=a): "teardown", # Teardown at end of module2 ] ) - expect(len(suite.cache)).equals(0) @test("Suite.generate_test_runs resolves and tears down global fixtures once only") @@ -257,12 +285,15 @@ def a(): yield "a" events.append("teardown") + @testable_test def test1(a=a): events.append("test1") + @testable_test def test2(a=a): events.append("test2") + @testable_test def test3(a=a): events.append("test3") @@ -285,7 +316,6 @@ def test3(a=a): "teardown", # Teardown only at end of run ] ) - expect(len(suite.cache)).equals(0) # Teardown includes cache cleanup @test("Suite.generate_test_runs resolves mixed scope fixtures correctly") @@ -310,26 +340,32 @@ def c(): yield "c" events.append("teardown c") - def test1(a=a, b=b, c=c): + @testable_test + def test_1(a=a, b=b, c=c): events.append("test1") - def test2(a=a, b=b, c=c): + @testable_test + def test_2(a=a, b=b, c=c): events.append("test2") - def test3(a=a, b=b, c=c): + @testable_test + def test_3(a=a, b=b, c=c): events.append("test3") + test_1.ward_meta.path = "module1" + test_2.ward_meta.path = "module2" + test_3.ward_meta.path = "module2" + suite = Suite( tests=[ - Test(fn=test1, module_name="module1"), - Test(fn=test2, module_name="module2"), - Test(fn=test3, module_name="module2"), + Test(fn=test_1, module_name="module1"), + Test(fn=test_2, module_name="module2"), + Test(fn=test_3, module_name="module2"), ] ) list(suite.generate_test_runs()) - # Note that the ordering of the final teardowns aren't well-defined expect(events).equals( [ "resolve a", # global fixture so resolved at start @@ -345,11 +381,10 @@ def test3(a=a, b=b, c=c): "resolve c", # test fixture resolved at start of test3 "test3", "teardown c", # test fixture teardown at end of test3 - "teardown a", # global fixtures are torn down at the very end "teardown b", # module fixture teardown at end of module2 + "teardown a", # global fixtures are torn down at the very end ] ) - expect(len(suite.cache)).equals(0) @skip("WIP") diff --git a/tests/test_testing.py b/tests/test_testing.py index 78560abd..b02da9b1 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,7 +1,8 @@ from unittest import mock from unittest.mock import Mock -from ward import expect, raises +from tests.test_suite import testable_test +from ward import expect, raises, Scope from ward.errors import ParameterisationError from ward.fixtures import fixture from ward.testing import Test, test, each, ParamMeta @@ -17,6 +18,7 @@ def f(): @fixture def anonymous_test(): + @testable_test def _(): expect(1).equals(1) @@ -98,7 +100,28 @@ def test(): expect(t.is_parameterised).equals(False) -@test("Test.get_parameterised_instances returns test in list if not parameterised") +@test("Test.scope_key_from(Scope.Test) returns the test ID") +def _(t: Test = anonymous_test): + scope_key = t.scope_key_from(Scope.Test) + + expect(scope_key).equals(t.id) + + +@test("Test.scope_key_from(Scope.Module) returns the path of the test module") +def _(t: Test = anonymous_test): + scope_key = t.scope_key_from(Scope.Module) + + expect(scope_key).equals(testable_test.path) + + +@test("Test.scope_key_from(Scope.Global) returns Scope.Global") +def _(t: Test = anonymous_test): + scope_key = t.scope_key_from(Scope.Global) + + expect(scope_key).equals(Scope.Global) + + +@test("Test.get_parameterised_instances returns [self] if not parameterised") def _(): def test(): pass @@ -144,4 +167,4 @@ def invalid_test(a=each(1, 2), b=each(3, 4, 5)): t = Test(fn=invalid_test, module_name=mod) with raises(ParameterisationError): - a = t.get_parameterised_instances() + t.get_parameterised_instances() diff --git a/ward/collect.py b/ward/collect.py index f792abdb..7b002223 100644 --- a/ward/collect.py +++ b/ward/collect.py @@ -7,8 +7,8 @@ from importlib._bootstrap_external import FileFinder from typing import Any, Callable, Generator, Iterable, List +from ward.models import WardMeta from ward.testing import Test, anonymous_tests -from ward.models import Marker, WardMeta def is_test_module(module: pkgutil.ModuleInfo) -> bool: @@ -54,15 +54,6 @@ def get_tests_in_modules(modules: Iterable) -> Generator[Test, None, None]: description=meta.description or "", ) - # Collect named tests from the module - for item in dir(mod): - if item.startswith("test_") and not item == "_": - test_name = item - test_fn = getattr(mod, test_name) - marker: Marker = getattr(test_fn, "ward_meta", WardMeta()).marker - if test_fn: - yield Test(fn=test_fn, module_name=mod_name, marker=marker) - def search_generally( tests: Iterable[Test], query: str = "" diff --git a/ward/fixtures.py b/ward/fixtures.py index b0ffcee0..e1d8ff94 100644 --- a/ward/fixtures.py +++ b/ward/fixtures.py @@ -2,28 +2,21 @@ from contextlib import suppress from dataclasses import dataclass, field from functools import partial, wraps -from typing import Callable, Dict, Union, Optional, List +from pathlib import Path +from typing import Callable, Dict, Union, Optional, Any, Generator from ward.models import WardMeta, Scope @dataclass class Fixture: - def __init__( - self, - fn: Callable, - last_resolved_module_name: Optional[str] = None, - last_resolved_test_id: Optional[str] = None, - ): - self.fn = fn - self.gen = None - self.resolved_val = None - self.last_resolved_module_name = last_resolved_module_name - self.last_resolved_test_id = last_resolved_test_id + fn: Callable + gen: Generator = None + resolved_val: Any = None @property def key(self) -> str: - path = inspect.getfile(fixture) + path = self.path name = self.name return f"{path}::{name}" @@ -35,6 +28,10 @@ def scope(self) -> Scope: def name(self): return self.fn.__name__ + @property + def path(self): + return self.fn.ward_meta.path + @property def is_generator_fixture(self): return inspect.isgeneratorfunction(inspect.unwrap(self.fn)) @@ -46,57 +43,75 @@ def teardown(self): # Suppress because we can't know whether there's more code # to execute below the yield. with suppress(StopIteration, RuntimeError): - if self.is_generator_fixture: + if self.is_generator_fixture and self.gen: next(self.gen) +FixtureKey = str +TestId = str +ModulePath = str +ScopeKey = Union[TestId, ModulePath, Scope] +ScopeCache = Dict[Scope, Dict[ScopeKey, Dict[FixtureKey, Fixture]]] + + +def _scope_cache_factory(): + return {scope: {} for scope in Scope} + + @dataclass class FixtureCache: - _fixtures: Dict[str, Fixture] = field(default_factory=dict) + """ + A collection of caches, each storing data for a different scope. - def cache_fixture(self, fixture: Fixture): - self._fixtures[fixture.key] = fixture + When a fixture is resolved, it is stored in the appropriate cache given + the scope of the fixture. - def teardown_all(self): - """Run the teardown code for all generator fixtures in the cache""" - vals = [f for f in self._fixtures.values()] - for fixture in vals: - with suppress(RuntimeError, StopIteration): - fixture.teardown() - del self[fixture.key] - - def get( - self, scope: Optional[Scope], module_name: Optional[str], test_id: Optional[str] - ) -> List[Fixture]: - filtered_by_mod = [ - f - for f in self._fixtures.values() - if f.scope == scope and f.last_resolved_module_name == module_name - ] - - if test_id: - return [f for f in filtered_by_mod if f.last_resolved_test_id == test_id] - else: - return filtered_by_mod + A lookup into this cache is a 3 stage process: - def teardown_fixtures(self, fixtures: List[Fixture]): - for fixture in fixtures: - if fixture.key in self: - with suppress(RuntimeError, StopIteration): - fixture.teardown() - del self[fixture.key] + Scope -> ScopeKey -> FixtureKey + + The first 2 lookups (Scope and ScopeKey) let us determine: + e.g. has a test-scoped fixture been cached for the current test? + e.g. has a module-scoped fixture been cached for the current test module? + + The final lookup lets us retrieve the actual fixture given a fixture key. + """ + _scope_cache: ScopeCache = field(default_factory=_scope_cache_factory) + + def _get_subcache(self, scope: Scope) -> Dict[str, Any]: + return self._scope_cache[scope] + + def get_fixtures_at_scope(self, scope: Scope, scope_key: ScopeKey) -> Dict[FixtureKey, Fixture]: + subcache = self._get_subcache(scope) + if scope_key not in subcache: + subcache[scope_key] = {} + return subcache.get(scope_key) - def __contains__(self, key: str) -> bool: - return key in self._fixtures + def cache_fixture(self, fixture: Fixture, scope_key: ScopeKey): + """ + Cache a fixture at the appropriate scope for the given test. + """ + fixtures = self.get_fixtures_at_scope(fixture.scope, scope_key) + fixtures[fixture.key] = fixture + + def teardown_fixtures_for_scope(self, scope: Scope, scope_key: ScopeKey): + fixture_dict = self.get_fixtures_at_scope(scope, scope_key) + fixtures = list(fixture_dict.values()) + for fixture in fixtures: + with suppress(RuntimeError, StopIteration): + fixture.teardown() + del fixture_dict[fixture.key] - def __getitem__(self, key: str) -> Fixture: - return self._fixtures[key] + def teardown_global_fixtures(self): + self.teardown_fixtures_for_scope(Scope.Global, Scope.Global) - def __delitem__(self, key: str): - del self._fixtures[key] + def contains(self, fixture: Fixture, scope: Scope, scope_key: ScopeKey) -> bool: + fixtures = self.get_fixtures_at_scope(scope, scope_key) + return fixture.key in fixtures - def __len__(self): - return len(self._fixtures) + def get(self, fixture_key: FixtureKey, scope: Scope, scope_key: ScopeKey) -> Fixture: + fixtures = self.get_fixtures_at_scope(scope, scope_key) + return fixtures.get(fixture_key) def fixture(func=None, *, scope: Optional[Union[Scope, str]] = Scope.Test): @@ -109,10 +124,16 @@ def fixture(func=None, *, scope: Optional[Union[Scope, str]] = Scope.Test): # By setting is_fixture = True, the framework will know # that if this fixture is provided as a default arg, it # is responsible for resolving the value. + path = Path(inspect.getfile(func)).absolute() if hasattr(func, "ward_meta"): func.ward_meta.is_fixture = True + func.ward_meta.path = path else: - func.ward_meta = WardMeta(is_fixture=True, scope=scope) + func.ward_meta = WardMeta( + is_fixture=True, + scope=scope, + path=path, + ) @wraps(func) def wrapper(*args, **kwargs): diff --git a/ward/models.py b/ward/models.py index 6a13a541..9957c633 100644 --- a/ward/models.py +++ b/ward/models.py @@ -43,3 +43,4 @@ class WardMeta: is_fixture: bool = False scope: Scope = Scope.Test bound_args: Optional[Signature] = None + path: Optional[str] = None diff --git a/ward/suite.py b/ward/suite.py index 1799e924..7ab15cc2 100644 --- a/ward/suite.py +++ b/ward/suite.py @@ -1,11 +1,10 @@ -import io -from contextlib import redirect_stderr, redirect_stdout +from collections import defaultdict from dataclasses import dataclass, field from typing import Generator, List +from ward import Scope from ward.errors import FixtureError -from ward.fixtures import FixtureCache, Fixture -from ward.models import Scope +from ward.fixtures import FixtureCache from ward.testing import Test, TestOutcome, TestResult @@ -18,63 +17,48 @@ class Suite: def num_tests(self): return len(self.tests) + def _test_counts_per_module(self): + module_paths = [test.path for test in self.tests] + counts = defaultdict(int) + for path in module_paths: + counts[path] += 1 + return counts + def generate_test_runs(self) -> Generator[TestResult, None, None]: - previous_test_module = None + num_tests_per_module = self._test_counts_per_module() for test in self.tests: - if previous_test_module and test.module_name != previous_test_module: - # We've moved into a different module, so clear out all of - # the module scoped fixtures from the previous module. - to_teardown = self.cache.get( - scope=Scope.Module, module_name=previous_test_module, test_id=None - ) - self.cache.teardown_fixtures(to_teardown) - generated_tests = test.get_parameterised_instances() for i, generated_test in enumerate(generated_tests): + num_tests_per_module[generated_test.path] -= 1 marker = generated_test.marker.name if generated_test.marker else None if marker == "SKIP": yield generated_test.get_result(TestOutcome.SKIP) - previous_test_module = generated_test.module_name continue try: resolved_vals = generated_test.resolve_args(self.cache, iteration=i) - - # Run the test, while capturing output. generated_test(**resolved_vals) - - # The test has completed without exception and therefore passed outcome = ( TestOutcome.XPASS if marker == "XFAIL" else TestOutcome.PASS ) yield generated_test.get_result(outcome) - except FixtureError as e: - # We can't run teardown code here because we can't know how much - # of the fixture has been executed. yield generated_test.get_result(TestOutcome.FAIL, e) - previous_test_module = generated_test.module_name continue - except Exception as e: - # TODO: Differentiate between ExpectationFailed and other Exceptions. outcome = ( TestOutcome.XFAIL if marker == "XFAIL" else TestOutcome.FAIL ) yield generated_test.get_result(outcome, e) + finally: + self.cache.teardown_fixtures_for_scope( + Scope.Test, + scope_key=generated_test.id, + ) + if num_tests_per_module[generated_test.path] == 0: + self.cache.teardown_fixtures_for_scope( + Scope.Module, + scope_key=generated_test.path, + ) - self._teardown_fixtures_scoped_to_test(generated_test) - previous_test_module = generated_test.module_name - - # Take care of any additional teardown. - self.cache.teardown_all() - - def _teardown_fixtures_scoped_to_test(self, test: Test): - """ - Get all the test-scoped fixtures that were used to form this result, - tear them down from the cache, and return the result. - """ - to_teardown = self.cache.get( - scope=Scope.Test, test_id=test.id, module_name=test.module_name - ) - self.cache.teardown_fixtures(to_teardown) + self.cache.teardown_global_fixtures() diff --git a/ward/testing.py b/ward/testing.py index b9540a76..f29ab4d2 100644 --- a/ward/testing.py +++ b/ward/testing.py @@ -4,14 +4,15 @@ from collections import defaultdict from contextlib import closing, redirect_stderr, redirect_stdout from dataclasses import dataclass, field -from enum import Enum, auto +from enum import auto, Enum from io import StringIO +from pathlib import Path from types import MappingProxyType from typing import Callable, Dict, List, Optional, Any, Tuple, Union from ward.errors import FixtureError, ParameterisationError -from ward.fixtures import Fixture, FixtureCache, Scope -from ward.models import Marker, SkipMarker, XfailMarker, WardMeta +from ward.fixtures import Fixture, FixtureCache, ScopeKey +from ward.models import Marker, SkipMarker, XfailMarker, WardMeta, Scope @dataclass @@ -104,6 +105,10 @@ def __call__(self, *args, **kwargs): def name(self): return self.fn.__name__ + @property + def path(self): + return self.fn.ward_meta.path + @property def qualified_name(self): name = self.name or "" @@ -127,6 +132,14 @@ def is_parameterised(self) -> bool: default_args = self._get_default_args() return any(isinstance(arg, Each) for arg in default_args.values()) + def scope_key_from(self, scope: Scope) -> ScopeKey: + if scope == Scope.Test: + return self.id + elif scope == Scope.Module: + return self.path + else: + return Scope.Global + def get_parameterised_instances(self) -> List["Test"]: """ If the test is parameterised, return a list of `Test` objects representing @@ -159,7 +172,7 @@ def get_parameterised_instances(self) -> List["Test"]: def deps(self) -> MappingProxyType: return inspect.signature(self.fn).parameters - def resolve_args(self, cache: FixtureCache, iteration: int) -> Dict[str, Any]: + def resolve_args(self, cache: FixtureCache, iteration: int = 0) -> Dict[str, Any]: """ Resolve fixtures and return the resultant name -> Fixture dict. If the argument is not a fixture, the raw argument will be used. @@ -171,7 +184,6 @@ def resolve_args(self, cache: FixtureCache, iteration: int) -> Dict[str, Any]: return {} default_args = self._get_default_args() - resolved_args: Dict[str, Any] = {} for name, arg in default_args.items(): # In the case of parameterised testing, grab the arg corresponding @@ -183,7 +195,7 @@ def resolve_args(self, cache: FixtureCache, iteration: int) -> Dict[str, Any]: else: resolved = arg resolved_args[name] = resolved - return self._resolve_fixture_values(resolved_args) + return self._unpack_resolved(resolved_args) def get_result(self, outcome, exception=None): with closing(self.sout), closing(self.serr): @@ -237,20 +249,12 @@ def _resolve_single_arg( return arg fixture = Fixture(arg) - if fixture.key in cache: - cached_fixture = cache[fixture.key] - if fixture.scope == Scope.Global: - return cached_fixture - elif fixture.scope == Scope.Module: - if cached_fixture.last_resolved_module_name == self.module_name: - return cached_fixture - elif fixture.scope == Scope.Test: - if cached_fixture.last_resolved_test_id == self.id: - return cached_fixture - - # Cache miss, so update the fixture metadata before we resolve and cache it - fixture.last_resolved_test_id = self.id - fixture.last_resolved_module_name = self.module_name + if cache.contains(fixture, fixture.scope, self.scope_key_from(fixture.scope)): + return cache.get( + fixture.key, + fixture.scope, + self.scope_key_from(fixture.scope), + ) has_deps = len(fixture.deps()) > 0 is_generator = fixture.is_generator_fixture @@ -263,7 +267,8 @@ def _resolve_single_arg( fixture.resolved_val = arg() except Exception as e: raise FixtureError(f"Unable to resolve fixture '{fixture.name}'") from e - cache.cache_fixture(fixture) + scope_key = self.scope_key_from(fixture.scope) + cache.cache_fixture(fixture, scope_key) return fixture signature = inspect.signature(arg) @@ -273,20 +278,21 @@ def _resolve_single_arg( for name, child_fixture in children_defaults.arguments.items(): child_resolved = self._resolve_single_arg(child_fixture, cache) children_resolved[name] = child_resolved + try: + args_to_inject = self._unpack_resolved(children_resolved) if is_generator: - fixture.gen = arg(**self._resolve_fixture_values(children_resolved)) + fixture.gen = arg(**args_to_inject) fixture.resolved_val = next(fixture.gen) else: - fixture.resolved_val = arg( - **self._resolve_fixture_values(children_resolved) - ) + fixture.resolved_val = arg(**args_to_inject) except Exception as e: raise FixtureError(f"Unable to resolve fixture '{fixture.name}'") from e - cache.cache_fixture(fixture) + scope_key = self.scope_key_from(fixture.scope) + cache.cache_fixture(fixture, scope_key) return fixture - def _resolve_fixture_values(self, fixture_dict: Dict[str, Any]) -> Dict[str, Any]: + def _unpack_resolved(self, fixture_dict: Dict[str, Any]) -> Dict[str, Any]: resolved_vals = {} for (k, arg) in fixture_dict.items(): if isinstance(arg, Fixture): @@ -304,14 +310,29 @@ def _resolve_fixture_values(self, fixture_dict: Dict[str, Any]) -> Dict[str, Any anonymous_tests: Dict[str, List[Callable]] = defaultdict(list) -def test(description: str): +def test(description: str, *args, **kwargs): def decorator_test(func): - if func.__name__ == "_": - mod_name = func.__module__ - if hasattr(func, "ward_meta"): - func.ward_meta.description = description - else: - func.ward_meta = WardMeta(description=description) + mod_name = func.__module__ + + force_path = kwargs.get("_force_path") + if force_path: + path = force_path + else: + path = Path(inspect.getfile(func)).absolute() + + if hasattr(func, "ward_meta"): + func.ward_meta.description = description + func.ward_meta.path = path + else: + func.ward_meta = WardMeta( + description=description, + path=path, + ) + + collect_into = kwargs.get("_collect_into") + if collect_into is not None: + collect_into[mod_name].append(func) + else: anonymous_tests[mod_name].append(func) @functools.wraps(func)