Skip to content
Merged
14 changes: 0 additions & 14 deletions lib/iris/tests/_shared_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<hash>.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.
Expand Down
53 changes: 53 additions & 0 deletions lib/iris/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions lib/iris/tests/graphics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Comment thread
ESadek-MO marked this conversation as resolved.
(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.

Expand All @@ -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.
against the existing accepted baseline image, for each failing test.
57 changes: 50 additions & 7 deletions lib/iris/tests/graphics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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()
3 changes: 2 additions & 1 deletion lib/iris/tests/graphics/idiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/iris/tests/graphics/recreate_imagerepo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"
)
Expand Down