From d4d8cedb370f32998585e422e79ecbfb08d8055e Mon Sep 17 00:00:00 2001
From: Robin De Schepper <robin.deschepper93@gmail.com>
Date: Wed, 13 Oct 2021 10:36:51 +0200
Subject: [PATCH] Automatic test discovery sans boilerplate (#1693)

---
 .github/workflows/basic.yml                   |   6 +-
 .gitignore                                    |   1 +
 doc/contrib/test.rst                          |  15 +-
 python/test/cases.py                          |  19 ++
 python/test/fixtures.py                       | 219 ++++++++++++++++++
 python/test/options.py                        |  18 --
 python/test/readme.md                         |  79 ++-----
 python/test/unit/runner.py                    |  74 ------
 python/test/unit/test_cable_probes.py         |  25 +-
 python/test/unit/test_catalogues.py           |  37 +--
 python/test/unit/test_clear_samplers.py       |  94 +-------
 python/test/unit/test_contexts.py             |  25 +-
 python/test/unit/test_decor.py                |  24 +-
 .../test/unit/test_domain_decompositions.py   |  25 +-
 python/test/unit/test_event_generators.py     |  25 +-
 python/test/unit/test_identifiers.py          |  25 +-
 python/test/unit/test_morphology.py           |  25 +-
 python/test/unit/test_profiling.py            |  24 +-
 python/test/unit/test_schedules.py            |  34 +--
 python/test/unit/test_spikes.py               |  69 +-----
 python/test/unit_distributed/__init__.py      |   3 -
 python/test/unit_distributed/runner.py        |  81 -------
 .../unit_distributed/test_contexts_arbmpi.py  |  47 +---
 .../unit_distributed/test_contexts_mpi4py.py  |  38 +--
 .../test_domain_decompositions.py             |  45 +---
 .../test/unit_distributed/test_simulator.py   |  45 +---
 26 files changed, 321 insertions(+), 801 deletions(-)
 create mode 100644 python/test/cases.py
 create mode 100644 python/test/fixtures.py
 delete mode 100644 python/test/options.py
 delete mode 100644 python/test/unit/runner.py
 delete mode 100644 python/test/unit_distributed/__init__.py
 delete mode 100644 python/test/unit_distributed/runner.py

diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml
index 5e22c96e..8b7f7373 100644
--- a/.github/workflows/basic.yml
+++ b/.github/workflows/basic.yml
@@ -172,12 +172,10 @@ jobs:
         run: scripts/run_cpp_examples.sh "mpirun -n 4 -oversubscribe"
       - name: Run python tests
         run: |
-          cd build
-          python ../python/test/unit/runner.py -v2
-          cd -
+          python3 -m unittest discover -v -s python
       - if:   ${{ matrix.config.mpi == 'ON' }}
         name: Run python+MPI tests
-        run:  mpirun -n 4 -oversubscribe python python/test/unit_distributed/runner.py -v2
+        run:  mpirun -n 4 -oversubscribe python3 -m unittest discover -v -s python
       - name: Run Python examples
         run: scripts/run_python_examples.sh
       - name: Build a catalogue
diff --git a/.gitignore b/.gitignore
index f2c26f5f..ba311b4a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
 *.so
 
 # intermediate python files
+__pycache__
 *.pyc
 
 # python dev env files
diff --git a/doc/contrib/test.rst b/doc/contrib/test.rst
index bddc4191..e447931c 100644
--- a/doc/contrib/test.rst
+++ b/doc/contrib/test.rst
@@ -1,7 +1,20 @@
 .. _contribtest:
 
 Tests
-============
+=====
 
 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.
+
+Python tests
+============
+
+The Python tests uses the `unittest
+<https://docs.python.org/3/library/unittest.html>`_ and its test discovery
+mechanism. For tests to be discovered they must meet the following criteria:
+
+* Located in an importable code folder starting from the ``python/test`` root.
+  If you introduce subfolders they must all contain a ``__init__.py`` file.
+* The filenames must start with ``test_``.
+* The test case classes must begin with ``Test``.
+* The test functions inside the cases must begin with ``test_``.
diff --git a/python/test/cases.py b/python/test/cases.py
new file mode 100644
index 00000000..4c21056a
--- /dev/null
+++ b/python/test/cases.py
@@ -0,0 +1,19 @@
+import unittest
+import arbor
+from . import fixtures
+
+_mpi_enabled = arbor.__config__
+
+@fixtures.context
+def skipIfNotDistributed(context):
+    skipSingleNode = unittest.skipIf(context.ranks < 2, "Skipping distributed test on single node.")
+    skipNotEnabled = unittest.skipIf(not _mpi_enabled, "Skipping distributed test, no MPI support in arbor.")
+    def skipper(f):
+        return skipSingleNode(skipNotEnabled(f))
+
+    return skipper
+
+
+@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
new file mode 100644
index 00000000..64145597
--- /dev/null
+++ b/python/test/fixtures.py
@@ -0,0 +1,219 @@
+import arbor
+import functools
+from functools import lru_cache as cache
+import unittest
+from pathlib import Path
+import subprocess
+import warnings
+import atexit
+
+_mpi_enabled = arbor.__config__["mpi"]
+_mpi4py_enabled = arbor.__config__["mpi4py"]
+
+# The API of `functools`'s caches went through a bunch of breaking changes from
+# 3.6 to 3.9. Patch them up in a local `cache` function.
+try:
+    cache(lambda: None)
+except TypeError:
+    # If `lru_cache` does not accept user functions as first arg, it expects
+    # the max cache size as first arg, we pass None to produce a cache decorator
+    # without max size.
+    cache = cache(None)
+
+def _fix(param_name, fixture, func):
+    """
+    Decorates `func` to inject the `fixture` callable result as `param_name`.
+    """
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        kwargs[param_name] = fixture()
+        return func(*args, **kwargs)
+
+    return wrapper
+
+def _fixture(decorator):
+    @functools.wraps(decorator)
+    def fixture_decorator(func):
+        return _fix(decorator.__name__, decorator, func)
+
+    return fixture_decorator
+
+def _singleton_fixture(f):
+    return _fixture(cache(f))
+
+
+@_fixture
+def repo_path():
+    """
+    Fixture that returns the repo root path.
+    """
+    return Path(__file__).parent.parent.parent
+
+
+def _finalize_mpi():
+    print("Context fixture finalizing mpi")
+    arbor.mpi_finalize()
+
+
+@_fixture
+def context():
+    """
+    Fixture that produces an MPI sensitive `arbor.context`
+    """
+    args = [arbor.proc_allocation()]
+    if _mpi_enabled:
+        if not arbor.mpi_is_initialized():
+            print("Context fixture initializing mpi", flush=True)
+            arbor.mpi_init()
+            atexit.register(_finalize_mpi)
+        if _mpi4py_enabled:
+            from mpi4py.MPI import COMM_WORLD as comm
+        else:
+            comm = arbor.mpi_comm()
+        args.append(comm)
+    return arbor.context(*args)
+
+
+class _BuildCatError(Exception): pass
+
+
+def _build_cat_local(name, path):
+    try:
+        subprocess.run(["build-catalogue", name, str(path)], check=True, stderr=subprocess.PIPE)
+    except subprocess.CalledProcessError as e:
+        raise _BuildCatError("Tests can't build catalogues:\n" + e.stderr.decode()) from None
+
+
+def _build_cat_distributed(comm, name, path):
+    # Control flow explanation:
+    # * `build_err` starts out as `None`
+    # * Rank 1 to N wait for a broadcast from rank 0 to receive the new value
+    #   for `build_err`
+    # * Rank 0 splits off from the others and executes the build.
+    #   * If it builds correctly it finishes the collective `build_err`
+    #     broadcast with the initial value `None`: all nodes continue.
+    #   * If it errors, it finishes the collective broadcast with the caught err
+    #
+    # All MPI ranks either continue or raise the same err. (prevents stalling)
+    build_err = None
+    if not comm.Get_rank():
+        try:
+            _build_cat_local(name, path)
+        except Exception as e:
+            build_err = e
+    build_err = comm.bcast(build_err, root=0)
+    if build_err:
+        raise build_err
+
+@context
+def _build_cat(name, path, context):
+    if context.has_mpi:
+        try:
+            from mpi4py.MPI import COMM_WORLD as comm
+        except ImportError:
+            raise _BuildCatError(
+                "Building catalogue in an MPI context, but `mpi4py` not found."
+                + " Concurrent identical catalogue builds might occur."
+            ) from None
+
+        _build_cat_distributed(comm, name, path)
+    else:
+        _build_cat_local(name, path)
+    return Path.cwd() / (name + "-catalogue.so")
+
+
+@_singleton_fixture
+@repo_path
+def dummy_catalogue(repo_path):
+    """
+    Fixture that returns a dummy `arbor.catalogue`
+    which contains the `dummy` mech.
+    """
+    path = repo_path / "test" / "unit" / "dummy"
+    cat_path = _build_cat("dummy", path)
+    return arbor.load_catalogue(str(cat_path))
+
+@_fixture
+class empty_recipe(arbor.recipe):
+    """
+    Blank recipe fixture.
+    """
+    pass
+
+
+@_fixture
+def cable_cell():
+    # (1) Create a morphology with a single (cylindrical) segment of length=diameter=6 μm
+    tree = arbor.segment_tree()
+    tree.append(
+        arbor.mnpos,
+        arbor.mpoint(-3, 0, 0, 3),
+        arbor.mpoint(3, 0, 0, 3),
+        tag=1,
+    )
+
+    # (2) Define the soma and its midpoint
+    labels = arbor.label_dict({'soma':   '(tag 1)',
+                               'midpoint': '(location 0 0.5)'})
+
+    # (3) Create cell and set properties
+    decor = arbor.decor()
+    decor.set_property(Vm=-40)
+    decor.paint('"soma"', 'hh')
+    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), "iclamp")
+    decor.place('"midpoint"', arbor.spike_detector(-10), "detector")
+    return arbor.cable_cell(tree, labels, decor)
+
+@_fixture
+class art_spiker_recipe(arbor.recipe):
+    """
+    Recipe fixture with 3 artificial spiking cells.
+    """
+    def __init__(self):
+        super().__init__()
+        self.the_props = arbor.neuron_cable_properties()
+        self.trains = [
+                [0.8, 2, 2.1, 3],
+                [0.4, 2, 2.2, 3.1, 4.5],
+                [0.2, 2, 2.8, 3]]
+
+    def num_cells(self):
+        return 4
+
+    def cell_kind(self, gid):
+        if gid < 3:
+            return arbor.cell_kind.spike_source
+        else:
+            return arbor.cell_kind.cable
+
+    def connections_on(self, gid):
+        return []
+
+    def event_generators(self, gid):
+        return []
+
+    def global_properties(self, kind):
+        return self.the_props
+
+    def probes(self, gid):
+        if gid < 3:
+            return []
+        else:
+            return [arbor.cable_probe_membrane_voltage('"midpoint"')]
+
+    @cable_cell
+    def _cable_cell(self, cable_cell):
+        return cable_cell
+
+    def cell_description(self, gid):
+        if gid < 3:
+            return arbor.spike_source_cell("src", arbor.explicit_schedule(self.trains[gid]))
+        else:
+            return self._cable_cell()
+
+@_fixture
+@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, dd, context)
diff --git a/python/test/options.py b/python/test/options.py
deleted file mode 100644
index 9e191c1d..00000000
--- a/python/test/options.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# options.py
-
-import argparse
-
-import os
-
-import arbor as arb
-
-def parse_arguments(args=None, namespace=None):
-    parser = argparse.ArgumentParser()
-
-    # add arguments as needed (e.g. -d, --dryrun Number of dry run ranks)
-    parser.add_argument("-v", "--verbosity", nargs='?', const=0, type=int, choices=[0, 1, 2], default=0, help="increase output verbosity")
-    #parser.add_argument("-d", "--dryrun", type=int, default=100 , help="number of dry run ranks")
-    args = parser.parse_args()
-    return args
diff --git a/python/test/readme.md b/python/test/readme.md
index dee3e090..515366df 100644
--- a/python/test/readme.md
+++ b/python/test/readme.md
@@ -1,87 +1,44 @@
 ## Directory Structure
 ```
 |- test\
-    |- options.py
     |- unit\
-        |- runner.py
         |- test_contexts.py
         |- ...
     |- unit-distributed\
-        |- runner.py
         |- test_contexts_arbmpi.py
         |- test_contexts_mpi4py.py
         |- ...
 ```
 
-In parent folder `test`: 
-- `options.py`: set global options (define arg parser)
-
-In subfolders `unit`/`unit_distributed`: 
-- `test_xxxs.py`: define unittest class with test methods and own test suite (named: test module)
-- `runner.py`: run all tests in this subfolder (which are defined as suite in test modules) 
+In subfolders `unit`/`unit_distributed`:
+- `test_xxxs.py`: define `TestMyTestCase(unittest.TestCase)` classes with
+  test methods
 
 ## Usage
-### with `unittest` from SUBFOLDER: 
 
-* to run all tests in subfolder:  
+* to run all tests:
+
 ```             
-python -m unittest [-v]
+[mpiexec -n X] python -m unittest discover [-v] -s python
 ```
-* to run module: 
-```  
-python -m unittest module [-v]
-```  
-, e.g. in `test/unit` use `python -m unittest test_contexts -v`
-* to run class in module: 
-```
-python -m unittest module.class [-v]
-```  
-, eg. in `test/unit` use `python -m unittest test_contexts.Contexts -v`
-* to run method in class in module: 
-```  
-python -m unittest module.class.method [-v]
-```  
-, eg. in `test/unit` use `python -m unittest test_contexts.Contexts.test_context -v`
-
-### with `runner.py` and argument(s) `-v {0,1,2}` from SUBFOLDER: 
 
-* to run all tests in subfolder:   
-```  
-python -m runner[-v2]
-```   
-or `python runner.py [-v2]`
-* to run module: 
-```  
-python -m test_xxxs [-v2]
-```   
-or `python test_xxxs.py [-v2]`
-* running classes or methods not possible this way
+* to run pattern matched test file(s):
 
-### from any other folder: 
-
-* to run all tests:   
-```
-python path/to/runner.py [-v2]
+```             
+[mpiexec -n X] python -m unittest discover [-v] -s python -p test_some_file.py
+[mpiexec -n X] python -m unittest discover [-v] -s python -p test_some_*.py
 ```
-* to run module: 
-```  
-python path/to/test_xxxs.py [-v2]
-```   
+
 
 ## Adding new tests
 
-1. In suitable folder `test/unit` (no MPI) or `test/unit_distributed` (MPI), create `test_xxxs.py` file
-2. In  `test_xxxs.py` file, define 
-  a) a unittest `class Xxxs(unittest.TestCase)` with test methods `test_yyy` 
-  b) a suite function `suite()` consisting of all desired tests returning a unittest suite `unittest.makeSuite(Xxxs, ('test'))` (for all defined tests, tuple of selected tests possible); steering of which tests to include happens here!
-  c) a run function `run()` with a unittest runner `unittest.TextTestRunner` running the `suite()` via `runner.run(suite())`
-  d) a `if __name__ == "__main__":` calling `run()`
-3. Add module to `runner.py` in subfolder by adding `test_xxxs`
-  a) to import: in `try` add `import test_xxxs`, in `except` add `from test.subfolder import test_xxxs`
-  b) to `test_modules` list
+1. In suitable folder `test/unit` (no MPI) or `test/unit_distributed` (MPI),
+  create `test_xxxs.py` file
+1. Create tests suitable for local and distributed
+  testing, or mark with the appropriate `cases.skipIf(Not)Distributed` decorator
 
 ## Naming convention
 
-- modules: `test_xxxs` (all lower case, ending with `s` since module can consist of multiple classes)
-- class(es): `Xxxs` (first letter upper case, ending with `s` since class can consist of multiple test functions)
-- functions: `test_yyy` (always starting with `test`since suite is build from all methods starting with `test`)
+- modules: `test_xxxs` (ending with `s` since module can consist of multiple classes)
+- class(es): `TestXxxs` (ending with `s` since class can consist of multiple test functions)
+- functions: `test_yyy`
diff --git a/python/test/unit/runner.py b/python/test/unit/runner.py
deleted file mode 100644
index 224f1957..00000000
--- a/python/test/unit/runner.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# runner.py
-
-import unittest
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-    import test_cable_probes
-    import test_catalogues
-    import test_clear_samplers
-    import test_contexts
-    import test_decor
-    import test_domain_decomposition
-    import test_event_generators
-    import test_identifiers
-    import test_morphology
-    import test_profiling
-    import test_schedules
-    import test_spikes
-    import test_tests
-    # add more if needed
-except ModuleNotFoundError:
-    from test import options
-    from test.unit import test_cable_probes
-    from test.unit import test_catalogues
-    from test.unit import test_clear_samplers
-    from test.unit import test_contexts
-    from test.unit import test_decor
-    from test.unit import test_domain_decompositions
-    from test.unit import test_event_generators
-    from test.unit import test_identifiers
-    from test.unit import test_morphology
-    from test.unit import test_profiling
-    from test.unit import test_schedules
-    from test.unit import test_spikes
-    # add more if needed
-
-test_modules = [\
-    test_cable_probes,\
-    test_catalogues, \
-    test_clear_samplers, \
-    test_contexts,\
-    test_decor,\
-    test_domain_decompositions,\
-    test_event_generators,\
-    test_identifiers,\
-    test_morphology,\
-    test_profiling,\
-    test_schedules,\
-    test_spikes,\
-] # add more if needed
-
-def suite():
-    loader = unittest.TestLoader()
-
-    suites = []
-    for test_module in test_modules:
-        test_module_suite = test_module.suite()
-        suites.append(test_module_suite)
-
-    suite = unittest.TestSuite(suites)
-
-    return suite
-
-if __name__ == "__main__":
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    result = runner.run(suite())
-    sys.exit(not(result.wasSuccessful()))
diff --git a/python/test/unit/test_cable_probes.py b/python/test/unit/test_cable_probes.py
index 33d2c254..b992b4a6 100644
--- a/python/test/unit/test_cable_probes.py
+++ b/python/test/unit/test_cable_probes.py
@@ -2,15 +2,7 @@
 
 import unittest
 import arbor as A
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 tests for cable probe wrappers
@@ -91,7 +83,7 @@ class cc_recipe(A.recipe):
     def cell_description(self, gid):
         return self.cell
 
-class CableProbes(unittest.TestCase):
+class TestCableProbes(unittest.TestCase):
     def test_probe_addr_metadata(self):
         recipe = cc_recipe()
         context = A.context()
@@ -172,16 +164,3 @@ class CableProbes(unittest.TestCase):
         m = sim.probe_metadata((0, 16))
         self.assertEqual(1, len(m))
         self.assertEqual(all_cv_cables, m[0])
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(CableProbes, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_catalogues.py b/python/test/unit/test_catalogues.py
index 00cc2ff8..f94dc737 100644
--- a/python/test/unit/test_catalogues.py
+++ b/python/test/unit/test_catalogues.py
@@ -1,16 +1,7 @@
+from .. import fixtures
 import unittest
-
 import arbor as arb
 
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
-
 """
 tests for (dynamically loaded) catalogues
 """
@@ -46,17 +37,14 @@ class recipe(arb.recipe):
         return self.cell
 
 
-class Catalogues(unittest.TestCase):
+class TestCatalogues(unittest.TestCase):
     def test_nonexistent(self):
         with self.assertRaises(FileNotFoundError):
             arb.load_catalogue("_NO_EXIST_.so")
 
-    def test_shared_catalogue(self):
-        try:
-            cat = arb.load_catalogue("lib/dummy-catalogue.so")
-        except:
-            print("BBP catalogue not found. Are you running from build directory?")
-            raise
+    @fixtures.dummy_catalogue
+    def test_shared_catalogue(self, dummy_catalogue):
+        cat = dummy_catalogue
         nms = [m for m in cat]
         self.assertEqual(nms, ['dummy'], "Expected equal names.")
         for nm in nms:
@@ -94,18 +82,3 @@ class Catalogues(unittest.TestCase):
         cat = arb.catalogue()
         cat.extend(other, "prefix/")
         self.assertNotEqual(hash_(other), hash_(cat), "Extending empty with prefixed cat should not yield cat")
-
-
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(Catalogues, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_clear_samplers.py b/python/test/unit/test_clear_samplers.py
index c54db908..bb7c2018 100644
--- a/python/test/unit/test_clear_samplers.py
+++ b/python/test/unit/test_clear_samplers.py
@@ -8,87 +8,18 @@ import numpy as np
 
 # to be able to run .py file from child directory
 import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures, cases
 
 """
 all tests for the simulator wrapper
 """
 
-def make_cable_cell():
-    # (1) Create a morphology with a single (cylindrical) segment of length=diameter=6 μm
-    tree = A.segment_tree()
-    tree.append(A.mnpos, A.mpoint(-3, 0, 0, 3), A.mpoint(3, 0, 0, 3), tag=1)
-
-    # (2) Define the soma and its midpoint
-    labels = A.label_dict({'soma':   '(tag 1)',
-                               'midpoint': '(location 0 0.5)'})
-
-    # (3) Create cell and set properties
-    decor = A.decor()
-    decor.set_property(Vm=-40)
-    decor.paint('"soma"', 'hh')
-    decor.place('"midpoint"', A.iclamp( 10, 2, 0.8), "iclamp")
-    decor.place('"midpoint"', A.spike_detector(-10), "detector")
-    return A.cable_cell(tree, labels, decor)
-
-# Test recipe art_spiker_recipe comprises three artificial spiking cells
-class art_spiker_recipe(A.recipe):
-    def __init__(self):
-        A.recipe.__init__(self)
-        self.the_props = A.neuron_cable_properties()
-        self.trains = [
-                [0.8, 2, 2.1, 3],
-                [0.4, 2, 2.2, 3.1, 4.5],
-                [0.2, 2, 2.8, 3]]
-
-    def num_cells(self):
-        return 4
-
-    def cell_kind(self, gid):
-        if gid < 3:
-            return A.cell_kind.spike_source
-        else:
-            return A.cell_kind.cable
-
-    def connections_on(self, gid):
-        return []
-
-    def event_generators(self, gid):
-        return []
-
-    def global_properties(self, kind):
-        return self.the_props
-
-    def probes(self, gid):
-        if gid < 3:
-            return []
-        else:
-            return [A.cable_probe_membrane_voltage('"midpoint"')]
-
-    def cell_description(self, gid):
-        if gid < 3:
-            return A.spike_source_cell("src", A.explicit_schedule(self.trains[gid]))
-        else:
-            return make_cable_cell()
-
-
-
-class Clear_samplers(unittest.TestCase):
-    # Helper for constructing a simulation from a recipe using default context and domain decomposition.
-    def init_sim(self, recipe):
-        context = A.context()
-        dd = A.partition_load_balance(recipe, context)
-        return A.simulation(recipe, dd, context)
-
+@cases.skipIfDistributed()
+class TestClearSamplers(unittest.TestCase):
     # test that all spikes are sorted by time then by gid
-    def test_spike_clearing(self):
-
-        sim = self.init_sim(art_spiker_recipe())
+    @fixtures.art_spiking_sim
+    def test_spike_clearing(self, art_spiking_sim):
+        sim = art_spiking_sim
         sim.record(A.spike_recording.all)
         handle = sim.sample((3, 0), A.regular_schedule(0.1))
 
@@ -133,16 +64,3 @@ class Clear_samplers(unittest.TestCase):
         self.assertEqual(times_t, times)
         self.assertEqual(list(data[:, 0]), list(data_t[:, 0]))
         self.assertEqual(list(data[:, 1]), list(data_t[:, 1]))
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(Clear_samplers, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_contexts.py b/python/test/unit/test_contexts.py
index a445bb15..d5728d82 100644
--- a/python/test/unit/test_contexts.py
+++ b/python/test/unit/test_contexts.py
@@ -5,21 +5,13 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 all tests for non-distributed arb.context
 """
 
-class Contexts(unittest.TestCase):
+class TestContexts(unittest.TestCase):
     def test_default_allocation(self):
         alloc = arb.proc_allocation()
 
@@ -86,16 +78,3 @@ class Contexts(unittest.TestCase):
         self.assertEqual(ctx.has_gpu, alloc.has_gpu)
         self.assertEqual(ctx.ranks, 1)
         self.assertEqual(ctx.rank, 0)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(Contexts, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_decor.py b/python/test/unit/test_decor.py
index 2ec68d75..cb835673 100644
--- a/python/test/unit/test_decor.py
+++ b/python/test/unit/test_decor.py
@@ -3,21 +3,14 @@
 import unittest
 import arbor as A
 
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 Tests for decor and decoration wrappers.
 TODO: Coverage for more than just iclamp.
 """
 
-class DecorClasses(unittest.TestCase):
+class TestDecorClasses(unittest.TestCase):
     def test_iclamp(self):
         # Constant amplitude iclamp:
         clamp = A.iclamp(10);
@@ -46,16 +39,3 @@ class DecorClasses(unittest.TestCase):
         clamp = A.iclamp(envelope, frequency=7);
         self.assertEqual(7, clamp.frequency)
         self.assertEqual(envelope, clamp.envelope)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(DecorClasses, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_domain_decompositions.py b/python/test/unit/test_domain_decompositions.py
index eae6b2d4..9dd86ae8 100644
--- a/python/test/unit/test_domain_decompositions.py
+++ b/python/test/unit/test_domain_decompositions.py
@@ -5,15 +5,7 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 # check Arbor's configuration of mpi and gpu
 gpu_enabled = arb.__config__["gpu"]
@@ -56,7 +48,7 @@ class hetero_recipe (arb.recipe):
         else:
             return arb.cell_kind.cable
 
-class Domain_Decompositions(unittest.TestCase):
+class TestDomain_Decompositions(unittest.TestCase):
     # 1 cpu core, no gpus; assumes all cells will be put into cell groups of size 1
     def test_domain_decomposition_homogenous_CPU(self):
         n_cells = 10
@@ -242,16 +234,3 @@ class Domain_Decompositions(unittest.TestCase):
         with self.assertRaisesRegex(RuntimeError,
             "unable to perform load balancing because cell_kind::spike_source has invalid suggested gpu_cell_group size of 0"):
             decomp = arb.partition_load_balance(recipe, context, hints)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(Domain_Decompositions, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_event_generators.py b/python/test/unit/test_event_generators.py
index c45e591f..2ea56490 100644
--- a/python/test/unit/test_event_generators.py
+++ b/python/test/unit/test_event_generators.py
@@ -5,21 +5,13 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 all tests for event generators (regular, explicit, poisson)
 """
 
-class EventGenerator(unittest.TestCase):
+class TestEventGenerator(unittest.TestCase):
 
     def test_event_generator_regular_schedule(self):
         cm = arb.cell_local_label("tgt0")
@@ -43,16 +35,3 @@ class EventGenerator(unittest.TestCase):
         self.assertEqual(pg.target.label, "tgt2")
         self.assertEqual(pg.target.policy, arb.selection_policy.univalent)
         self.assertEqual(pg.weight, 42.)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class EventGenerator
-    suite = unittest.makeSuite(EventGenerator, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_identifiers.py b/python/test/unit/test_identifiers.py
index ba425be2..61469cb3 100644
--- a/python/test/unit/test_identifiers.py
+++ b/python/test/unit/test_identifiers.py
@@ -5,21 +5,13 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 all tests for identifiers, indexes, kinds
 """
 
-class CellMembers(unittest.TestCase):
+class TestCellMembers(unittest.TestCase):
 
     def test_gid_index_ctor_cell_member(self):
         cm = arb.cell_member(17,42)
@@ -32,16 +24,3 @@ class CellMembers(unittest.TestCase):
         cm.index = 23
         self.assertEqual(cm.gid, 13)
         self.assertEqual(cm.index, 23)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(CellMembers, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_morphology.py b/python/test/unit/test_morphology.py
index bbc37da9..4c271f2d 100644
--- a/python/test/unit/test_morphology.py
+++ b/python/test/unit/test_morphology.py
@@ -6,15 +6,7 @@ import unittest
 import arbor as A
 import numpy as N
 import math
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 tests for morphology-related classes
@@ -24,7 +16,7 @@ def as_matrix(iso):
     trans = N.array(iso((0, 0, 0)))
     return N.c_[N.array([iso(v) for v in [(1,0,0),(0,1,0),(0,0,1)]]).transpose()-N.c_[trans, trans, trans], trans]
 
-class PlacePwlin(unittest.TestCase):
+class TestPlacePwlin(unittest.TestCase):
     def test_identity(self):
         self.assertTrue(N.isclose(as_matrix(A.isometry()), N.eye(3, 4)).all())
 
@@ -130,16 +122,3 @@ class PlacePwlin(unittest.TestCase):
 
         Chalf_all = [(s.prox, s.dist) for s in place.all_segments([A.cable(0, 0., 0.5)])]
         self.assertEqual([(x0p, x0d), (x1p, x1p)], Chalf_all)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(PlacePwlin, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_profiling.py b/python/test/unit/test_profiling.py
index bd0a99b5..41f406c0 100644
--- a/python/test/unit/test_profiling.py
+++ b/python/test/unit/test_profiling.py
@@ -6,15 +6,7 @@ import unittest
 
 import arbor as arb
 import functools
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 all tests for profiling
@@ -91,17 +83,3 @@ class TestProfiling(unittest.TestCase):
         summary = arb.profiler_summary()
         self.assertEqual(str, type(summary), 'profiler summary must be str')
         self.assertTrue(summary, 'empty summary')
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from classes RegularSchedule, ExplicitSchedule and PoissonSchedule
-    suite = unittest.TestSuite()
-    suite.addTests(unittest.makeSuite(TestProfiling, ('test')))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit/test_schedules.py b/python/test/unit/test_schedules.py
index 44eec19d..3742fe20 100644
--- a/python/test/unit/test_schedules.py
+++ b/python/test/unit/test_schedules.py
@@ -5,21 +5,13 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 all tests for schedules (regular, explicit, poisson)
 """
 
-class RegularSchedule(unittest.TestCase):
+class TestRegularSchedule(unittest.TestCase):
     def test_none_ctor_regular_schedule(self):
         rs = arb.regular_schedule(tstart=0, dt=0.1, tstop=None)
         self.assertEqual(rs.dt, 0.1)
@@ -73,7 +65,7 @@ class RegularSchedule(unittest.TestCase):
             rs = arb.regular_schedule(0., 1., 10.)
             rs.events(0, -10)
 
-class ExplicitSchedule(unittest.TestCase):
+class TestExplicitSchedule(unittest.TestCase):
     def test_times_contor_explicit_schedule(self):
         es = arb.explicit_schedule([1, 2, 3, 4.5])
         self.assertEqual(es.times, [1, 2, 3, 4.5])
@@ -108,7 +100,7 @@ class ExplicitSchedule(unittest.TestCase):
             rs = arb.regular_schedule(0.1)
             rs.events(1., -1.)
 
-class PoissonSchedule(unittest.TestCase):
+class TestPoissonSchedule(unittest.TestCase):
     def test_freq_poisson_schedule(self):
         ps = arb.poisson_schedule(42.)
         self.assertEqual(ps.freq, 42.)
@@ -181,20 +173,4 @@ class PoissonSchedule(unittest.TestCase):
     def test_tstop_poisson_schedule(self):
         tstop = 50
         events = arb.poisson_schedule(0., 1, 0, tstop).events(0, 100)
-        self.assertTrue(max(events) < tstop)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from classes RegularSchedule, ExplicitSchedule and PoissonSchedule
-    suite = unittest.TestSuite()
-    suite.addTests(unittest.makeSuite(RegularSchedule, ('test')))
-    suite.addTests(unittest.makeSuite(ExplicitSchedule, ('test')))
-    suite.addTests(unittest.makeSuite(PoissonSchedule, ('test')))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
+        self.assertTrue(max(events) < tstop)
\ No newline at end of file
diff --git a/python/test/unit/test_spikes.py b/python/test/unit/test_spikes.py
index 8211f5de..d7b32d34 100644
--- a/python/test/unit/test_spikes.py
+++ b/python/test/unit/test_spikes.py
@@ -4,63 +4,17 @@
 
 import unittest
 import arbor as A
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures
 
 """
 all tests for the simulator wrapper
 """
 
-# Test recipe art_spiker_recipe comprises three artificial spiking cells 
-
-class art_spiker_recipe(A.recipe):
-    def __init__(self):
-        A.recipe.__init__(self)
-        self.props = A.neuron_cable_properties()
-        self.trains = [
-                [0.8, 2, 2.1, 3],
-                [0.4, 2, 2.2, 3.1, 4.5],
-                [0.2, 2, 2.8, 3]]
-
-    def num_cells(self):
-        return 3
-
-    def cell_kind(self, gid):
-        return A.cell_kind.spike_source
-
-    def connections_on(self, gid):
-        return []
-
-    def event_generators(self, gid):
-        return []
-
-    def global_properties(self, kind):
-        return self.the_props
-
-    def probes(self, gid):
-        return []
-
-    def cell_description(self, gid):
-        return A.spike_source_cell("src", A.explicit_schedule(self.trains[gid]))
-
-
-class Spikes(unittest.TestCase):
-    # Helper for constructing a simulation from a recipe using default context and domain decomposition.
-    def init_sim(self, recipe):
-        context = A.context()
-        dd = A.partition_load_balance(recipe, context)
-        return A.simulation(recipe, dd, context)
-
+class TestSpikes(unittest.TestCase):
     # test that all spikes are sorted by time then by gid
-    def test_spikes_sorted(self):
-        sim = self.init_sim(art_spiker_recipe())
+    @fixtures.art_spiking_sim
+    def test_spikes_sorted(self, art_spiking_sim):
+        sim = art_spiking_sim
         sim.record(A.spike_recording.all)
         # run simulation in 5 steps, forcing 5 epochs
         sim.run(1, 0.01)
@@ -75,16 +29,3 @@ class Spikes(unittest.TestCase):
 
         self.assertEqual([2, 1, 0, 0, 1, 2, 0, 1, 2, 0, 2, 1, 1], gids)
         self.assertEqual([0.2, 0.4, 0.8, 2., 2., 2., 2.1, 2.2, 2.8, 3., 3., 3.1, 4.5], times)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(Spikes, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-    runner = unittest.TextTestRunner(verbosity = v)
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit_distributed/__init__.py b/python/test/unit_distributed/__init__.py
deleted file mode 100644
index e08cb8eb..00000000
--- a/python/test/unit_distributed/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# __init__.py
diff --git a/python/test/unit_distributed/runner.py b/python/test/unit_distributed/runner.py
deleted file mode 100644
index 48c6c3a5..00000000
--- a/python/test/unit_distributed/runner.py
+++ /dev/null
@@ -1,81 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# runner.py
-
-import unittest
-import arbor as arb
-
-# check Arbor's configuration of mpi
-mpi_enabled    = arb.__config__["mpi"]
-mpi4py_enabled = arb.__config__["mpi4py"]
-
-if (mpi_enabled and mpi4py_enabled):
-    import mpi4py.MPI as mpi
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-    import test_contexts_arbmpi
-    import test_contexts_mpi4py
-    import test_domain_decompositions
-    import test_simulator
-    # add more if needed
-except ModuleNotFoundError:
-    from test import options
-    from test.unit_distributed import test_contexts_arbmpi
-    from test.unit_distributed import test_contexts_mpi4py
-    from test.unit_distributed import test_domain_decompositions
-    from test.unit_distributed import test_simulator
-    # add more if needed
-
-test_modules = [\
-    test_contexts_arbmpi,\
-    test_contexts_mpi4py,\
-    test_domain_decompositions,\
-    test_simulator\
-] # add more if needed
-
-def suite():
-    loader = unittest.TestLoader()
-
-    suites = []
-    for test_module in test_modules:
-        test_module_suite = test_module.suite()
-        suites.append(test_module_suite)
-
-    suite = unittest.TestSuite(suites)
-
-    return suite
-
-
-if __name__ == "__main__":
-    v = options.parse_arguments().verbosity
-
-    if not arb.mpi_is_initialized():
-        print(" Runner initializing mpi")
-        arb.mpi_init()
-
-    if mpi4py_enabled:
-        comm = arb.mpi_comm(mpi.COMM_WORLD)
-    elif mpi_enabled:
-        comm = arb.mpi_comm()
-
-    alloc = arb.proc_allocation()
-    ctx = arb.context(alloc, comm)
-    rank = ctx.rank
-
-    if rank == 0:
-        runner = unittest.TextTestRunner(verbosity = v)
-    else:
-        sys.stdout = open(os.devnull, 'w')
-        runner = unittest.TextTestRunner(stream=sys.stdout)
-
-    result = runner.run(suite())
-
-    if not arb.mpi_is_finalized():
-       arb.mpi_finalize()
-
-    sys.exit(not(result.wasSuccessful()))
diff --git a/python/test/unit_distributed/test_contexts_arbmpi.py b/python/test/unit_distributed/test_contexts_arbmpi.py
index 30328cc7..d7165a90 100644
--- a/python/test/unit_distributed/test_contexts_arbmpi.py
+++ b/python/test/unit_distributed/test_contexts_arbmpi.py
@@ -5,24 +5,13 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
-
-# check Arbor's configuration of mpi
-mpi_enabled = arb.__config__["mpi"]
+from .. import fixtures, cases
 
 """
 all tests for distributed arb.context using arbor mpi wrappers
 """
-@unittest.skipIf(mpi_enabled == False, "MPI not enabled")
-class Contexts_arbmpi(unittest.TestCase):
+@cases.skipIfNotDistributed()
+class TestContexts_arbmpi(unittest.TestCase):
     # Initialize mpi only once in this class (when adding classes move initialization to setUpModule()
     @classmethod
     def setUpClass(self):
@@ -74,33 +63,3 @@ class Contexts_arbmpi(unittest.TestCase):
 
     def test_finalized_arbmpi(self):
         self.assertFalse(arb.mpi_is_finalized())
-
-def suite():
-    # specify class and test functions as tuple (here: all tests starting with 'test' from class Contexts_arbmpi
-    suite = unittest.makeSuite(Contexts_arbmpi, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-
-    if not arb.mpi_is_initialized():
-        arb.mpi_init()
-
-    comm = arb.mpi_comm()
-    alloc = arb.proc_allocation()
-    ctx = arb.context(alloc, comm)
-    rank = ctx.rank
-
-    if rank == 0:
-        runner = unittest.TextTestRunner(verbosity = v)
-    else:
-        sys.stdout = open(os.devnull, 'w')
-        runner = unittest.TextTestRunner(stream=sys.stdout)
-
-    runner.run(suite())
-
-    if not arb.mpi_is_finalized():
-        arb.mpi_finalize()
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit_distributed/test_contexts_mpi4py.py b/python/test/unit_distributed/test_contexts_mpi4py.py
index 82b3c345..a22d065c 100644
--- a/python/test/unit_distributed/test_contexts_mpi4py.py
+++ b/python/test/unit_distributed/test_contexts_mpi4py.py
@@ -5,15 +5,7 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures, cases
 
 # check Arbor's configuration of mpi
 mpi_enabled    = arb.__config__["mpi"]
@@ -26,8 +18,8 @@ if (mpi_enabled and mpi4py_enabled):
 all tests for distributed arb.context using mpi4py
 """
 # Only test class if env var ARB_WITH_MPI4PY=ON
-@unittest.skipIf(mpi_enabled == False or mpi4py_enabled == False, "MPI/mpi4py not enabled")
-class Contexts_mpi4py(unittest.TestCase):
+@cases.skipIfNotDistributed()
+class TestContexts_mpi4py(unittest.TestCase):
     def test_initialized_mpi4py(self):
         # test mpi initialization (automatically when including mpi4py: https://mpi4py.readthedocs.io/en/stable/mpi4py.run.html)
         self.assertTrue(mpi.Is_initialized())
@@ -68,27 +60,3 @@ class Contexts_mpi4py(unittest.TestCase):
     def test_finalized_mpi4py(self):
         # test mpi finalization (automatically when including mpi4py, but only just before the Python process terminates)
         self.assertFalse(mpi.Is_finalized())
-
-def suite():
-    # specify class and test functions as tuple (here: all tests starting with 'test' from class Contexts_mpi4py
-    suite = unittest.makeSuite(Contexts_mpi4py, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-
-    comm = arb.mpi_comm(mpi.COMM_WORLD)
-    alloc = arb.proc_allocation()
-    ctx = arb.context(alloc, comm)
-    rank = ctx.rank
-
-    if rank == 0:
-        runner = unittest.TextTestRunner(verbosity = v)
-    else:
-        sys.stdout = open(os.devnull, 'w')
-        runner = unittest.TextTestRunner(stream=sys.stdout)
-
-    runner.run(suite())
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit_distributed/test_domain_decompositions.py b/python/test/unit_distributed/test_domain_decompositions.py
index 439fd425..3c125c75 100644
--- a/python/test/unit_distributed/test_domain_decompositions.py
+++ b/python/test/unit_distributed/test_domain_decompositions.py
@@ -5,15 +5,7 @@
 import unittest
 
 import arbor as arb
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures, cases
 
 # check Arbor's configuration of mpi and gpu
 mpi_enabled = arb.__config__["mpi"]
@@ -145,8 +137,8 @@ class gj_non_symmetric (arb.recipe):
         else:
             return []
 
-@unittest.skipIf(mpi_enabled == False, "MPI not enabled")
-class Domain_Decompositions_Distributed(unittest.TestCase):
+@cases.skipIfNotDistributed()
+class TestDomain_Decompositions_Distributed(unittest.TestCase):
     # Initialize mpi only once in this class (when adding classes move initialization to setUpModule()
     @classmethod
     def setUpClass(self):
@@ -432,34 +424,3 @@ class Domain_Decompositions_Distributed(unittest.TestCase):
         with self.assertRaisesRegex(RuntimeError,
             "unable to perform load balancing because cell_kind::cable has invalid suggested gpu_cell_group size of 0"):
             decomp2 = arb.partition_load_balance(recipe, context, hints2)
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(Domain_Decompositions_Distributed, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-
-    if not arb.mpi_is_initialized():
-        arb.mpi_init()
-
-    comm = arb.mpi_comm()
-
-    alloc = arb.proc_allocation()
-    ctx = arb.context(alloc, comm)
-    rank = ctx.rank
-
-    if rank == 0:
-        runner = unittest.TextTestRunner(verbosity = v)
-    else:
-        sys.stdout = open(os.devnull, 'w')
-        runner = unittest.TextTestRunner(stream=sys.stdout)
-
-    runner.run(suite())
-
-    if not arb.mpi_is_finalized():
-        arb.mpi_finalize()
-
-if __name__ == "__main__":
-    run()
diff --git a/python/test/unit_distributed/test_simulator.py b/python/test/unit_distributed/test_simulator.py
index 0dcf1374..249d5149 100644
--- a/python/test/unit_distributed/test_simulator.py
+++ b/python/test/unit_distributed/test_simulator.py
@@ -5,15 +5,7 @@
 import unittest
 import numpy as np
 import arbor as A
-
-# to be able to run .py file from child directory
-import sys, os
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
-
-try:
-    import options
-except ModuleNotFoundError:
-    from test import options
+from .. import fixtures, cases
 
 mpi_enabled = A.__config__["mpi"]
 
@@ -56,8 +48,8 @@ class lifN_recipe(A.recipe):
             c.t_ref = 4
         return c
 
-@unittest.skipIf(mpi_enabled == False, "MPI not enabled")
-class Simulator(unittest.TestCase):
+@cases.skipIfNotDistributed()
+class TestSimulator(unittest.TestCase):
     def init_sim(self):
         comm = A.mpi_comm()
         context = A.context(threads=1, gpu_id=None, mpi=A.mpi_comm())
@@ -98,34 +90,3 @@ class Simulator(unittest.TestCase):
 
         expected = [((s, 0), t) for s in range(0, self.ranks) for t in ([0, 2, 4, 6, 8] if s%2==0 else [0, 4, 8])]
         self.assertEqual(expected, sorted(spikes))
-
-
-def suite():
-    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
-    suite = unittest.makeSuite(Simulator, ('test'))
-    return suite
-
-def run():
-    v = options.parse_arguments().verbosity
-
-    if not A.mpi_is_initialized():
-        A.mpi_init()
-
-    comm = A.mpi_comm()
-    alloc = A.proc_allocation()
-    ctx = A.context(alloc, comm)
-    rank = ctx.rank
-
-    if rank == 0:
-        runner = unittest.TextTestRunner(verbosity = v)
-    else:
-        sys.stdout = open(os.devnull, 'w')
-        runner = unittest.TextTestRunner(stream=sys.stdout)
-
-    runner.run(suite())
-
-    if not A.mpi_is_finalized():
-        A.mpi_finalize()
-
-if __name__ == "__main__":
-    run()
-- 
GitLab