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)