diff --git a/doc/contrib/test.rst b/doc/contrib/test.rst
index 7ccfa474a60aef89413115e2ae1807367a213e0d..acf7ef6b03faa5d82c0eec8d4616e26a2c65bdef 100644
--- a/doc/contrib/test.rst
+++ b/doc/contrib/test.rst
@@ -3,7 +3,7 @@
 Tests
 =====
 
-C++ tests are located in ``/tests`` and Python (binding) tests in 
+C++ tests are located in ``/tests`` and Python (binding) tests in
 ``/python/test``. See the documentation on :ref:`building <building>` for the
 C++ tests and ``/python/test/readme.md`` for the latter.
 
@@ -101,6 +101,43 @@ mechanism. For tests to be discovered they must meet the following criteria:
 
 To run the tests locally use `python -m unittest` from the `python` directory.
 
+Fixtures
+^^^^^^^^
+
+Multiple tests may require the same reusable piece of test setup to run. You
+can speed up the test writing process for everyone by writing these reusable
+pieces as a `fixture <https://en.wikipedia.org/wiki/Test_fixture#Software>`_.
+A fixture is a decorator that injects the reusable piece into the test
+function. Fixtures, and helpers to write them, are available in
+``python/test/fixtures.py``. The following example shows you how to create
+a fixture that returns the arbor version, and optionally the path to it:
+
+.. code-block:: python
+
+  import arbor
+
+  # This decorator converts your function into a fixture decorator.
+  @_fixture
+  def arbor_info(return_path=False):
+    if return_path:
+      return (arbor.__version__, arbor.__path__)
+    else:
+      return (arbor.__version__,)
+
+Whenever you are writing a test you can now apply your fixture by calling it
+with the required parameters, and adding a parameter to your function with the
+same name as the fixture:
+
+.. code-block:: python
+
+  # Import fixtures.py
+  from .. import fixtures
+
+  @fixtures.arbor_info(return_path=True)
+  def test_up_to_date(arbor_info):
+    ...
+
+
 Feature dependent tests
 -----------------------
 
diff --git a/python/test/cases.py b/python/test/cases.py
index 657bd428fd5e143130f0924f6ca9b8b04d5d4387..b04b7a05ce73d6dcdad220ea4519ec1cb56616bd 100644
--- a/python/test/cases.py
+++ b/python/test/cases.py
@@ -5,7 +5,7 @@ from . import fixtures
 _mpi_enabled = arbor.__config__
 
 
-@fixtures.context
+@fixtures.context()
 def skipIfNotDistributed(context):
     skipSingleNode = unittest.skipIf(
         context.ranks < 2, "Skipping distributed test on single node."
@@ -20,7 +20,7 @@ def skipIfNotDistributed(context):
     return skipper
 
 
-@fixtures.context
+@fixtures.context()
 def skipIfDistributed(context):
     return unittest.skipIf(
         context.ranks > 1, "Skipping single node test on multiple nodes."
diff --git a/python/test/fixtures.py b/python/test/fixtures.py
index 4fbb351473046cb310ef48689410fb39a59c8ceb..fe6f289473a4d77c1a9ef74c8147828be48dd2e8 100644
--- a/python/test/fixtures.py
+++ b/python/test/fixtures.py
@@ -4,6 +4,7 @@ from functools import lru_cache as cache
 from pathlib import Path
 import subprocess
 import atexit
+import inspect
 
 _mpi_enabled = arbor.__config__["mpi"]
 _mpi4py_enabled = arbor.__config__["mpi4py"]
@@ -23,21 +24,39 @@ def _fix(param_name, fixture, func):
     """
     Decorates `func` to inject the `fixture` callable result as `param_name`.
     """
+    sig = inspect.signature(func)
+    if param_name not in sig.parameters:
+        raise TypeError(
+            f"{param_name} fixture can't be applied to a function without {param_name}"
+            " parameter"
+        )
 
     @functools.wraps(func)
-    def wrapper(*args, **kwargs):
-        kwargs[param_name] = fixture()
-        return func(*args, **kwargs)
+    def inject_fixture(*args, **kwargs):
+        bound = sig.bind_partial(*args, **kwargs)
+        if param_name not in bound.arguments:
+            bound.arguments[param_name] = fixture
+        return func(*bound.args, **bound.kwargs)
+
+    return inject_fixture
+
 
-    return wrapper
+def _fixture(fixture_factory):
+    """
+    Takes a fixture factory and returns a decorator factory, so that when the fixture
+    factory is called, a decorator is produced that injects the fixture values when the
+    decorated function is called.
+    """
 
+    @functools.wraps(fixture_factory)
+    def decorator_fatory(*args, **kwargs):
+        def decorator(func):
+            fixture = fixture_factory(*args, **kwargs)
+            return _fix(fixture_factory.__name__, fixture, func)
 
-def _fixture(decorator):
-    @functools.wraps(decorator)
-    def fixture_decorator(func):
-        return _fix(decorator.__name__, decorator, func)
+        return decorator
 
-    return fixture_decorator
+    return decorator_fatory
 
 
 def _singleton_fixture(f):
@@ -90,7 +109,8 @@ def _build_cat_local(name, path):
         )
     except subprocess.CalledProcessError as e:
         raise _BuildCatError(
-            f"Tests can't build catalogue '{name}' from '{path}':\n{e.stderr.decode()}\n\n{e.stdout.decode()}"
+            f"Tests can't build catalogue '{name}' from '{path}':\n"
+            f"{e.stderr.decode()}\n\n{e.stdout.decode()}"
         ) from None
 
 
@@ -116,7 +136,7 @@ def _build_cat_distributed(comm, name, path):
         raise build_err
 
 
-@context
+@context()
 def _build_cat(name, path, context):
     if context.has_mpi:
         try:
@@ -124,7 +144,7 @@ def _build_cat(name, path, context):
         except ImportError:
             raise _BuildCatError(
                 "Building catalogue in an MPI context, but `mpi4py` not found."
-                + " Concurrent identical catalogue builds might occur."
+                " Concurrent identical catalogue builds might occur."
             ) from None
 
         _build_cat_distributed(comm, name, path)
@@ -134,7 +154,7 @@ def _build_cat(name, path, context):
 
 
 @_singleton_fixture
-@repo_path
+@repo_path()
 def dummy_catalogue(repo_path):
     """
     Fixture that returns a dummy `arbor.catalogue`
@@ -190,7 +210,8 @@ class art_spiker_recipe(arbor.recipe):
             return [arbor.cable_probe_membrane_voltage('"midpoint"')]
 
     def _cable_cell_elements(self):
-        # (1) Create a morphology with a single (cylindrical) segment of length=diameter=6 μm
+        # (1) Create a morphology with a single (cylindrical) segment of length=diameter
+        #  = # 6 μm
         tree = arbor.segment_tree()
         tree.append(
             arbor.mnpos,
@@ -209,7 +230,8 @@ class art_spiker_recipe(arbor.recipe):
         decor.place('"midpoint"', arbor.iclamp(10, 2, 0.8), "iclamp")
         decor.place('"midpoint"', arbor.threshold_detector(-10), "detector")
 
-        # return tuple of tree, labels, and decor for creating a cable cell (can still be modified before calling arbor.cable_cell())
+        # return tuple of tree, labels, and decor for creating a cable cell (can still
+        # be modified before calling arbor.cable_cell())
         return tree, labels, decor
 
     def cell_description(self, gid):
@@ -225,8 +247,8 @@ class art_spiker_recipe(arbor.recipe):
 @_fixture
 def sum_weight_hh_spike():
     """
-    Fixture returning connection weight for 'expsyn_stdp' mechanism which is just enough to evoke an immediate spike
-    at t=1ms in the 'hh' neuron in 'art_spiker_recipe'
+    Fixture returning connection weight for 'expsyn_stdp' mechanism which is just enough
+    to evoke an immediate spike at t=1ms in the 'hh' neuron in 'art_spiker_recipe'
     """
     return 0.4
 
@@ -234,15 +256,15 @@ def sum_weight_hh_spike():
 @_fixture
 def sum_weight_hh_spike_2():
     """
-    Fixture returning connection weight for 'expsyn_stdp' mechanism which is just enough to evoke an immediate spike
-    at t=1.8ms in the 'hh' neuron in 'art_spiker_recipe'
+    Fixture returning connection weight for 'expsyn_stdp' mechanism which is just enough
+    to evoke an immediate spike at t=1.8ms in the 'hh' neuron in 'art_spiker_recipe'
     """
     return 0.36
 
 
 @_fixture
-@context
-@art_spiker_recipe
+@context()
+@art_spiker_recipe()
 def art_spiking_sim(context, art_spiker_recipe):
     dd = arbor.partition_load_balance(art_spiker_recipe, context)
     return arbor.simulation(art_spiker_recipe, context, dd)
diff --git a/python/test/unit/test_catalogues.py b/python/test/unit/test_catalogues.py
index 38175fda768b506c273c9dd39bc6e8fa55b5acd1..fef3965e9c3d9d97333d8aeda820997b70cd29bb 100644
--- a/python/test/unit/test_catalogues.py
+++ b/python/test/unit/test_catalogues.py
@@ -43,7 +43,7 @@ class TestCatalogues(unittest.TestCase):
         with self.assertRaises(FileNotFoundError):
             arb.load_catalogue("_NO_EXIST_.so")
 
-    @fixtures.dummy_catalogue
+    @fixtures.dummy_catalogue()
     def test_shared_catalogue(self, dummy_catalogue):
         cat = dummy_catalogue
         nms = [m for m in cat]
diff --git a/python/test/unit/test_clear_samplers.py b/python/test/unit/test_clear_samplers.py
index f294c69f9aa834f42310aa3f53fd8c6badd5f6bc..78481e56af1b0c51a7aeae97e26ff524f043eb56 100644
--- a/python/test/unit/test_clear_samplers.py
+++ b/python/test/unit/test_clear_samplers.py
@@ -17,7 +17,7 @@ all tests for the simulator wrapper
 @cases.skipIfDistributed()
 class TestClearSamplers(unittest.TestCase):
     # test that all spikes are sorted by time then by gid
-    @fixtures.art_spiking_sim
+    @fixtures.art_spiking_sim()
     def test_spike_clearing(self, art_spiking_sim):
         sim = art_spiking_sim
         sim.record(A.spike_recording.all)
diff --git a/python/test/unit/test_multiple_connections.py b/python/test/unit/test_multiple_connections.py
index 1f7dab5a2026b9b64b1b79612be0f96b21e0697a..b01c2fd3aa22ba400b91ec4f0c6c286ffbc9ba78 100644
--- a/python/test/unit/test_multiple_connections.py
+++ b/python/test/unit/test_multiple_connections.py
@@ -154,10 +154,10 @@ class TestMultipleConnections(unittest.TestCase):
         return sim, handle_mem
 
     # Test #1 (for 'round_robin')
-    @fixtures.context
-    @fixtures.art_spiker_recipe
-    @fixtures.sum_weight_hh_spike
-    @fixtures.sum_weight_hh_spike_2
+    @fixtures.context()
+    @fixtures.art_spiker_recipe()
+    @fixtures.sum_weight_hh_spike()
+    @fixtures.sum_weight_hh_spike_2()
     def test_multiple_connections_rr_no_halt(
         self, context, art_spiker_recipe, sum_weight_hh_spike, sum_weight_hh_spike_2
     ):
@@ -216,10 +216,10 @@ class TestMultipleConnections(unittest.TestCase):
         self.evaluate_additional_outcome_1(sim, handle_mem)
 
     # Test #2 (for the combination of 'round_robin_halt' and 'round_robin')
-    @fixtures.context
-    @fixtures.art_spiker_recipe
-    @fixtures.sum_weight_hh_spike
-    @fixtures.sum_weight_hh_spike_2
+    @fixtures.context()
+    @fixtures.art_spiker_recipe()
+    @fixtures.sum_weight_hh_spike()
+    @fixtures.sum_weight_hh_spike_2()
     def test_multiple_connections_rr_halt(
         self, context, art_spiker_recipe, sum_weight_hh_spike, sum_weight_hh_spike_2
     ):
@@ -279,10 +279,10 @@ class TestMultipleConnections(unittest.TestCase):
         self.evaluate_additional_outcome_2_3(sim, handle_mem)
 
     # Test #3 (for 'univalent')
-    @fixtures.context
-    @fixtures.art_spiker_recipe
-    @fixtures.sum_weight_hh_spike
-    @fixtures.sum_weight_hh_spike_2
+    @fixtures.context()
+    @fixtures.art_spiker_recipe()
+    @fixtures.sum_weight_hh_spike()
+    @fixtures.sum_weight_hh_spike_2()
     def test_multiple_connections_uni(
         self, context, art_spiker_recipe, sum_weight_hh_spike, sum_weight_hh_spike_2
     ):
diff --git a/python/test/unit/test_spikes.py b/python/test/unit/test_spikes.py
index f0e67fe0ba22826fd5138d548afbbb847a7533d4..cd1e6b81e5e00066e38117b6bd0deed4249836db 100644
--- a/python/test/unit/test_spikes.py
+++ b/python/test/unit/test_spikes.py
@@ -13,7 +13,7 @@ all tests for the simulator wrapper
 
 class TestSpikes(unittest.TestCase):
     # test that all spikes are sorted by time then by gid
-    @fixtures.art_spiking_sim
+    @fixtures.art_spiking_sim()
     def test_spikes_sorted(self, art_spiking_sim):
         sim = art_spiking_sim
         sim.record(A.spike_recording.all)