From 8e8441815de17b52510656f539b0a3f5ab6bb5e1 Mon Sep 17 00:00:00 2001 From: Robin De Schepper <robin.deschepper93@gmail.com> Date: Wed, 2 Nov 2022 13:21:30 +0100 Subject: [PATCH] Added fixture dev docs. Made fixtures more robust. (#2025) * Added fixture dev docs. Made fixtures more robust. Co-authored-by: Brent Huisman <brenthuisman@users.noreply.github.com> --- doc/contrib/test.rst | 39 ++++++++++- python/test/cases.py | 4 +- python/test/fixtures.py | 64 +++++++++++++------ python/test/unit/test_catalogues.py | 2 +- python/test/unit/test_clear_samplers.py | 2 +- python/test/unit/test_multiple_connections.py | 24 +++---- python/test/unit/test_spikes.py | 2 +- 7 files changed, 98 insertions(+), 39 deletions(-) diff --git a/doc/contrib/test.rst b/doc/contrib/test.rst index 7ccfa474..acf7ef6b 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 657bd428..b04b7a05 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 4fbb3514..fe6f2894 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 38175fda..fef3965e 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 f294c69f..78481e56 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 1f7dab5a..b01c2fd3 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 f0e67fe0..cd1e6b81 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) -- GitLab