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)