diff --git a/lib/iris/tests/_shared_utils.py b/lib/iris/tests/_shared_utils.py index c6cab7ab08..a23c77a622 100644 --- a/lib/iris/tests/_shared_utils.py +++ b/lib/iris/tests/_shared_utils.py @@ -698,20 +698,6 @@ def _ensure_folder(path): os.makedirs(dir_path) -# todo: need to find equlivalence for `unique_id` in pytest -def check_graphic(): - """Check the hash of the current matplotlib figure matches the expected - image hash for the current graphic test. - - To create missing image test results, set the IRIS_TEST_CREATE_MISSING - environment variable before running the tests. This will result in new - and appropriately ".png" image files being generated in the image - output directory, and the imagerepo.json file being updated. - - """ - assert False - - # todo: relied on unitest functionality, need to find a pytest alternative def patch(*args, **kwargs): """Install a mock.patch, to be removed after the current test. diff --git a/lib/iris/tests/conftest.py b/lib/iris/tests/conftest.py new file mode 100644 index 0000000000..8e96c9afeb --- /dev/null +++ b/lib/iris/tests/conftest.py @@ -0,0 +1,53 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Top-level fixture infra-structure. + +Before adding to this: consider if :mod:`iris.tests.unit.conftest` or +:mod:`iris.tests.integration.conftest` might be more appropriate. +""" +from collections import defaultdict + +import pytest + +import iris.tests.graphics + + +@pytest.fixture(scope="session", autouse=True) +def test_call_counter(): + """Provide a session-persistent tracker of the number of calls per test name. + + Used by :func:`_unique_id` to ensure uniqueness if called multiple times + per test. + """ + counter = defaultdict(int) + return counter + + +@pytest.fixture +def _unique_id(request: pytest.FixtureRequest, test_call_counter) -> callable: + """Provide a function returning a unique ID of calling test and call number. + + Example: ``iris.tests.unit.test_cube.TestCube.test_data.my_param.0`` + + Used by :func:`iris.tests.graphics.check_graphic_caller` to ensure unique + image names. + """ + id_sequence = [request.module.__name__, request.node.originalname] + if request.cls is not None: + id_sequence.insert(-1, request.cls.__name__) + if hasattr(request.node, "callspec"): + id_sequence.append(request.node.callspec.id) + test_id = ".".join(id_sequence) + + def generate_id(): + assertion_id = test_call_counter[test_id] + test_call_counter[test_id] += 1 + return f"{test_id}.{assertion_id}" + + return generate_id + + +# Share this existing fixture from the expected location. +check_graphic_caller = iris.tests.graphics._check_graphic_caller diff --git a/lib/iris/tests/graphics/README.md b/lib/iris/tests/graphics/README.md index 069fc01f70..b345843109 100644 --- a/lib/iris/tests/graphics/README.md +++ b/lib/iris/tests/graphics/README.md @@ -24,8 +24,9 @@ perceived as it may be a simple pixel shift. ## Testing Strategy -The `iris.tests.IrisTest.check_graphic` test routine calls out to -`iris.tests.graphics.check_graphic` which tests against the **acceptable** +The `iris.tests.graphics.check_graphic` function - accessed via the +`check_graphic_caller` fixture (PyTest) or `iris.tests.IrisTest.check_graphic` +(unittest) - tests against the **acceptable** result. It does this using an image **hash** comparison technique which allows us to be robust against minor variations based on underlying library updates. @@ -48,4 +49,4 @@ This consists of: * The utility script `iris/tests/idiff.py` automates checking, enabling the developer to easily compare the proposed new **acceptable** result image - against the existing accepted baseline image, for each failing test. \ No newline at end of file + against the existing accepted baseline image, for each failing test. diff --git a/lib/iris/tests/graphics/__init__.py b/lib/iris/tests/graphics/__init__.py index a1b6b24bcc..d72bebf7e3 100644 --- a/lib/iris/tests/graphics/__init__.py +++ b/lib/iris/tests/graphics/__init__.py @@ -19,9 +19,9 @@ import sys import threading from typing import Callable, Dict, Union -import unittest import filelock +import pytest # Test for availability of matplotlib. # (And remove matplotlib as an iris.tests dependency.) @@ -50,7 +50,7 @@ _DISPLAY_FIGURES = True # Threading non re-entrant blocking lock to ensure thread-safe plotting in the -# GraphicsTestMixin. +# GraphicsTestMixin and check_graphics_caller. _lock = threading.Lock() #: Default perceptual hash size. @@ -241,6 +241,7 @@ def _create_missing(phash: str) -> None: class GraphicsTestMixin: + # TODO: deprecate this in favour of check_graphic_caller. def setUp(self) -> None: # Acquire threading non re-entrant blocking lock to ensure # thread-safe plotting. @@ -263,15 +264,57 @@ def skip_plot(fn: Callable) -> Callable: """Decorator to choose whether to run tests, based on the availability of the matplotlib library. - Example usage: - @skip_plot - class MyPlotTests(test.GraphicsTest): - ... + Examples + -------- + >>> @skip_plot + >>> class TestMyPlots: + ... def test_my_plot(self, check_graphic_caller): + ... pass + ... + >>> @skip_plot + >>> def test_my_plot(check_graphic_caller): + ... pass """ - skip = unittest.skipIf( + skip = pytest.mark.skipIf( condition=not MPL_AVAILABLE, reason="Graphics tests require the matplotlib library.", ) return skip(fn) + + +@pytest.fixture +def _check_graphic_caller(_unique_id) -> callable: + """Provide a function calling :func:`check_graphic` with safe configuration. + + Ensures a safe Matplotlib setup (and tears down afterwards), and generates + a unique test id for each call. + + Examples + -------- + >>> def test_my_plot(check_graphic_caller): + ... # ... do some plotting ... + ... check_graphic_caller() + """ + from iris.tests import _RESULT_PATH + + # Acquire threading non re-entrant blocking lock to ensure + # thread-safe plotting. + _lock.acquire() + # Make sure we have no unclosed plots from previous tests before + # generating this one. + if MPL_AVAILABLE: + plt.close("all") + + def call_check_graphic(): + check_graphic(_unique_id(), _RESULT_PATH) + + yield call_check_graphic + + # If a plotting test bombs out it can leave the current figure + # in an odd state, so we make sure it's been disposed of. + if MPL_AVAILABLE: + plt.close("all") + # Release the non re-entrant blocking lock. + _lock.release() diff --git a/lib/iris/tests/graphics/idiff.py b/lib/iris/tests/graphics/idiff.py index 64d690e55d..cbd9d3b891 100755 --- a/lib/iris/tests/graphics/idiff.py +++ b/lib/iris/tests/graphics/idiff.py @@ -28,6 +28,7 @@ from iris.warnings import IrisIgnoringWarning # noqa import iris.tests # noqa +from iris.tests import _shared_utils import iris.tests.graphics as graphics # noqa # Allows restoration of test id from result image name @@ -118,7 +119,7 @@ def step_over_diffs(result_dir, display=True): for fname in result_dir.glob(f"*{_POSTFIX_DIFF}"): fname.unlink() - reference_image_dir = Path(iris.tests.get_data_path("images")) + reference_image_dir = Path(_shared_utils.get_data_path("images")) repo = graphics.read_repo_json() # Filter out all non-test result image files. diff --git a/lib/iris/tests/graphics/recreate_imagerepo.py b/lib/iris/tests/graphics/recreate_imagerepo.py index 5261f0cc29..ca2f65279f 100755 --- a/lib/iris/tests/graphics/recreate_imagerepo.py +++ b/lib/iris/tests/graphics/recreate_imagerepo.py @@ -10,7 +10,7 @@ from imagehash import hex_to_hash -import iris.tests +from iris.tests import _shared_utils import iris.tests.graphics as graphics @@ -47,7 +47,7 @@ def update_json(baseline_image_dir: Path, dry_run: bool = False): if __name__ == "__main__": - default_baseline_image_dir = Path(iris.tests.IrisTest.get_data_path("images")) + default_baseline_image_dir = Path(_shared_utils.get_data_path("images")) description = ( "Update imagerepo.json based on contents of the baseline image directory" )