Skip to content

Commit 23a52c3

Browse files
authored
Merge pull request #134 from ConorMacBride/hybrid-mode-baseline
Test against JSON summaries (and bugfixes)
2 parents e1bf8dd + 33338ba commit 23a52c3

34 files changed

+1657
-120
lines changed

README.rst

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ directly in the right directory.
7575
With a Hash Library
7676
^^^^^^^^^^^^^^^^^^^
7777

78-
Instead of comparing to baseline images, you can instead compare against a json
79-
library of sha256 hashes. This has the advantage of not having to check baseline
78+
Instead of comparing to baseline images, you can instead compare against a JSON
79+
library of SHA-256 hashes. This has the advantage of not having to check baseline
8080
images into the repository with the tests, or download them from a remote
8181
source.
8282

@@ -91,8 +91,11 @@ Hybrid Mode: Hashes and Images
9191
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
9292

9393
It is possible to configure both hashes and baseline images. In this scenario
94-
the hashes will be compared first, with the baseline images only used if the hash
95-
comparison fails.
94+
only the hash comparison can determine the test result. If the hash comparison
95+
fails, the test will fail, however a comparison to the baseline image will be
96+
carried out so the actual difference can be seen. If the hash comparison passes,
97+
the comparison to the baseline image is skipped (unless **results always** is
98+
configured).
9699

97100
This is especially useful if the baseline images are external to the repository
98101
containing the tests, and are accessed via HTTP. In this situation, if the hashes
@@ -104,7 +107,7 @@ without having to modify the external images.
104107
Running Tests
105108
^^^^^^^^^^^^^
106109

107-
Once tests are written with either baseline images or a hash library to compare
110+
Once tests are written with baseline images, a hash library, or both to compare
108111
against, the tests can be run with::
109112

110113
pytest --mpl
@@ -118,12 +121,15 @@ Generating a Test Summary
118121
^^^^^^^^^^^^^^^^^^^^^^^^^
119122

120123
By specifying the ``--mpl-generate-summary=html`` CLI argument, a HTML summary
121-
page will be generated showing the result, log entry and RMS of each test,
122-
and the hashes if configured. The baseline, diff and result image for each
123-
failing test will be shown. If **Results always** is configured
124-
(see section below), images for passing tests will also be shown.
125-
If no baseline images are configured, just the result images will
126-
be displayed.
124+
page will be generated showing the test result, log entry and generated result
125+
image. When in the (default) image comparison mode, the baseline image, diff
126+
image and RMS (if any), and tolerance of each test will also be shown.
127+
When in the hash comparison mode, the baseline hash and result hash will
128+
also be shown. When in hybrid mode, all of these are included.
129+
130+
When generating a HTML summary, the ``--mpl-results-always`` option is
131+
automatically applied (see section below). Therefore images for passing
132+
tests will also be shown.
127133

128134
+---------------+---------------+---------------+
129135
| |html all| | |html filter| | |html result| |
@@ -188,28 +194,36 @@ running tests by running ``pytest`` with::
188194

189195
pytest --mpl --mpl-baseline-path=baseline_images
190196

191-
This directory will be interpreted as being relative to where the tests
192-
are run. In addition, if both this option and the ``baseline_dir``
197+
This directory will be interpreted as being relative to where pytest
198+
is run. However, if the ``--mpl-baseline-relative`` option is also
199+
included, this directory will be interpreted as being relative to
200+
the current test directory.
201+
In addition, if both this option and the ``baseline_dir``
193202
option in the ``mpl_image_compare`` decorator are used, the one in the
194203
decorator takes precedence.
195204

196205
Results always
197206
^^^^^^^^^^^^^^
198207

199-
By default, result images are only generated for tests that fail.
208+
By default, result images are only saved for tests that fail.
200209
Passing ``--mpl-results-always`` to pytest will force result images
201-
to be generated for all tests, even for tests that pass.
202-
If a baseline image exists, a diff image will also be generated.
203-
All of these images will be shown in the summary (if requested).
210+
to be saved for all tests, even for tests that pass.
211+
212+
When in **hybrid mode**, even if a test passes hash comparison,
213+
a comparison to the baseline image will also be carried out,
214+
with the baseline image and diff image (if image comparison fails)
215+
saved for all tests. This secondary comparison will not affect
216+
the success status of the test.
204217

205-
This option is useful for always *comparing* the result images again
218+
This option is useful for always *comparing* the result images against
206219
the baseline images, while only *assessing* the tests against the
207220
hash library.
208221
If you only update your baseline images after merging a PR, this
209222
option means that the generated summary will always show how the
210223
PR affects the baseline images, with the success status of each
211224
test (based on the hash library) also shown in the generated
212-
summary.
225+
summary. This option is applied automatically when generating
226+
a HTML summary.
213227

214228
Base style
215229
^^^^^^^^^^

pytest_mpl/plugin.py

Lines changed: 45 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def pytest_addoption(parser):
140140
group.addoption('--mpl-results-path', help=results_path_help, action='store')
141141
parser.addini('mpl-results-path', help=results_path_help)
142142

143-
results_always_help = ("Always generate result images, not just for failed tests. "
143+
results_always_help = ("Always compare to baseline images and save result images, even for passing tests. "
144144
"This option is automatically applied when generating a HTML summary.")
145145
group.addoption('--mpl-results-always', action='store_true',
146146
help=results_always_help)
@@ -272,7 +272,7 @@ def __init__(self,
272272
if len(unsupported_formats) > 0:
273273
raise ValueError(f"The mpl summary type(s) '{sorted(unsupported_formats)}' "
274274
"are not supported.")
275-
# Ignore `results_always` and always save result images for HTML output
275+
# When generating HTML always apply `results_always`
276276
if generate_summary & {'html', 'basic-html'}:
277277
results_always = True
278278
self.generate_summary = generate_summary
@@ -431,7 +431,7 @@ def compare_image_to_baseline(self, item, fig, result_dir, summary=None):
431431

432432
test_image = (result_dir / "result.png").absolute()
433433
fig.savefig(str(test_image), **savefig_kwargs)
434-
summary['result_image'] = '%EXISTS%'
434+
summary['result_image'] = test_image.relative_to(self.results_dir).as_posix()
435435

436436
if not os.path.exists(baseline_image_ref):
437437
summary['status'] = 'failed'
@@ -447,7 +447,7 @@ def compare_image_to_baseline(self, item, fig, result_dir, summary=None):
447447
# copy to our tmpdir to be sure to keep them in case of failure
448448
baseline_image = (result_dir / "baseline.png").absolute()
449449
shutil.copyfile(baseline_image_ref, baseline_image)
450-
summary['baseline_image'] = '%EXISTS%'
450+
summary['baseline_image'] = baseline_image.relative_to(self.results_dir).as_posix()
451451

452452
# Compare image size ourselves since the Matplotlib
453453
# exception is a bit cryptic in this case and doesn't show
@@ -472,7 +472,8 @@ def compare_image_to_baseline(self, item, fig, result_dir, summary=None):
472472
else:
473473
summary['status'] = 'failed'
474474
summary['rms'] = results['rms']
475-
summary['diff_image'] = '%EXISTS%'
475+
diff_image = (result_dir / 'result-failed-diff.png').absolute()
476+
summary['diff_image'] = diff_image.relative_to(self.results_dir).as_posix()
476477
template = ['Error: Image files did not match.',
477478
'RMS Value: {rms}',
478479
'Expected: \n {expected}',
@@ -488,9 +489,7 @@ def load_hash_library(self, library_path):
488489
return json.load(fp)
489490

490491
def compare_image_to_hash_library(self, item, fig, result_dir, summary=None):
491-
new_test = False
492492
hash_comparison_pass = False
493-
baseline_image_path = None
494493
if summary is None:
495494
summary = {}
496495

@@ -505,87 +504,58 @@ def compare_image_to_hash_library(self, item, fig, result_dir, summary=None):
505504

506505
hash_library = self.load_hash_library(hash_library_filename)
507506
hash_name = self.generate_test_name(item)
507+
baseline_hash = hash_library.get(hash_name, None)
508+
summary['baseline_hash'] = baseline_hash
508509

509510
test_hash = self.generate_image_hash(item, fig)
510511
summary['result_hash'] = test_hash
511512

512-
if hash_name not in hash_library:
513-
new_test = True
513+
if baseline_hash is None: # hash-missing
514514
summary['status'] = 'failed'
515-
error_message = (f"Hash for test '{hash_name}' not found in {hash_library_filename}. "
516-
f"Generated hash is {test_hash}.")
517-
summary['status_msg'] = error_message
518-
else:
519-
summary['baseline_hash'] = hash_library[hash_name]
515+
summary['status_msg'] = (f"Hash for test '{hash_name}' not found in {hash_library_filename}. "
516+
f"Generated hash is {test_hash}.")
517+
elif test_hash == baseline_hash: # hash-match
518+
hash_comparison_pass = True
519+
summary['status'] = 'passed'
520+
summary['status_msg'] = 'Test hash matches baseline hash.'
521+
else: # hash-diff
522+
summary['status'] = 'failed'
523+
summary['status_msg'] = (f"Hash {test_hash} doesn't match hash "
524+
f"{baseline_hash} in library "
525+
f"{hash_library_filename} for test {hash_name}.")
520526

521527
# Save the figure for later summary (will be removed later if not needed)
522528
test_image = (result_dir / "result.png").absolute()
523529
fig.savefig(str(test_image), **savefig_kwargs)
524-
summary['result_image'] = '%EXISTS%'
530+
summary['result_image'] = test_image.relative_to(self.results_dir).as_posix()
525531

526-
if not new_test:
527-
if test_hash == hash_library[hash_name]:
528-
hash_comparison_pass = True
529-
summary['status'] = 'passed'
530-
summary['status_msg'] = 'Test hash matches baseline hash.'
531-
else:
532-
error_message = (f"Hash {test_hash} doesn't match hash "
533-
f"{hash_library[hash_name]} in library "
534-
f"{hash_library_filename} for test {hash_name}.")
535-
summary['status'] = 'failed'
536-
summary['status_msg'] = 'Test hash does not match baseline hash.'
537-
538-
# If the compare has only been specified with hash and not baseline
539-
# dir, don't attempt to find a baseline image at the default path.
540-
if not hash_comparison_pass and not self.baseline_directory_specified(item) or new_test:
541-
return error_message
532+
# Hybrid mode (hash and image comparison)
533+
if self.baseline_directory_specified(item):
542534

543-
# If this is not a new test try and get the baseline image.
544-
if not new_test:
545-
baseline_error = None
546-
baseline_summary = {}
547-
# Ignore Errors here as it's possible the reference image dosen't exist yet.
548-
try:
549-
baseline_image_path = self.obtain_baseline_image(item, result_dir)
550-
baseline_image = baseline_image_path
551-
if baseline_image and not baseline_image.exists():
552-
baseline_image = None
553-
# Get the baseline and generate a diff image, always so that
554-
# --mpl-results-always can be respected.
535+
# Skip image comparison if hash matches (unless `--mpl-results-always`)
536+
if hash_comparison_pass and not self.results_always:
537+
return
538+
539+
# Run image comparison
540+
baseline_summary = {} # summary for image comparison to merge with hash comparison summary
541+
try: # Ignore all errors as success does not influence the overall test result
555542
baseline_comparison = self.compare_image_to_baseline(item, fig, result_dir,
556543
summary=baseline_summary)
557-
except Exception as e:
558-
baseline_image = None
559-
baseline_error = e
560-
for k in ['baseline_image', 'diff_image', 'rms', 'tolerance', 'result_image']:
561-
summary[k] = summary[k] or baseline_summary.get(k)
562-
563-
# If the hash comparison passes then return
564-
if hash_comparison_pass:
544+
except Exception as baseline_error: # Append to test error later
545+
baseline_comparison = str(baseline_error)
546+
else: # Update main summary
547+
for k in ['baseline_image', 'diff_image', 'rms', 'tolerance', 'result_image']:
548+
summary[k] = summary[k] or baseline_summary.get(k)
549+
550+
# Append the log from image comparison
551+
r = baseline_comparison or "The comparison to the baseline image succeeded."
552+
summary['status_msg'] += ("\n\n"
553+
"Image comparison test\n"
554+
"---------------------\n") + r
555+
556+
if hash_comparison_pass: # Return None to indicate test passed
565557
return
566-
567-
if baseline_image is None:
568-
error_message += f"\nUnable to find baseline image for {item}."
569-
if baseline_error:
570-
error_message += f"\n{baseline_error}"
571-
summary['status'] = 'failed'
572-
summary['status_msg'] = error_message
573-
return error_message
574-
575-
summary['baseline_image'] = '%EXISTS%'
576-
577-
# Override the tolerance (if not explicitly set) to 0 as the hashes are not forgiving
578-
tolerance = compare.kwargs.get('tolerance', None)
579-
if not tolerance:
580-
compare.kwargs['tolerance'] = 0
581-
582-
comparison_error = (baseline_comparison or
583-
"\nHowever, the comparison to the baseline image succeeded.")
584-
585-
error_message = f"{error_message}\n{comparison_error}"
586-
summary['status'] = 'failed'
587-
summary['status_msg'] = error_message
588-
return error_message
558+
return summary['status_msg']
589559

590560
def pytest_runtest_setup(self, item): # noqa
591561

@@ -673,7 +643,7 @@ def item_function_wrapper(*args, **kwargs):
673643
if not self.results_always:
674644
shutil.rmtree(result_dir)
675645
for image_type in ['baseline_image', 'diff_image', 'result_image']:
676-
summary[image_type] = None # image no longer %EXISTS%
646+
summary[image_type] = None # image no longer exists
677647
else:
678648
self._test_results[str(pathify(test_name))] = summary
679649
pytest.fail(msg, pytrace=False)
@@ -704,21 +674,6 @@ def pytest_unconfigure(self, config):
704674
json.dump(self._generated_hash_library, fp, indent=2)
705675

706676
if self.generate_summary:
707-
# Generate a list of test directories
708-
dir_list = [p.relative_to(self.results_dir)
709-
for p in self.results_dir.iterdir() if p.is_dir()]
710-
711-
# Resolve image paths
712-
for directory in dir_list:
713-
test_name = directory.parts[-1]
714-
for image_type, filename in [
715-
('baseline_image', 'baseline.png'),
716-
('diff_image', 'result-failed-diff.png'),
717-
('result_image', 'result.png'),
718-
]:
719-
if self._test_results[test_name][image_type] == '%EXISTS%':
720-
self._test_results[test_name][image_type] = str(directory / filename)
721-
722677
if 'json' in self.generate_summary:
723678
summary = self.generate_summary_json()
724679
print(f"A JSON report can be found at: {summary}")

pytest_mpl/summary/templates/filter.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ <h5>Sort tests by...</h5>
1616
{{ sort_option('status-sort', 'status', 'desc', default=true) }}
1717
{{ sort_option('collected-sort', 'collected', 'asc') }}
1818
{{ sort_option('test-name', 'name') }}
19+
{% if results.warn_missing['baseline_image'] -%}
1920
{{ sort_option('rms-sort', 'RMS', 'desc') }}
21+
{%- endif %}
2022
</div>
2123
<form id="filterForm" onsubmit="return false;">
2224
<h5>Show tests which have...</h5>
@@ -32,16 +34,20 @@ <h5>Show tests which have...</h5>
3234
{{ filter_option('overall-failed', 'failed') }}
3335
{{ filter_option('overall-skipped', 'skipped') }}
3436
</div>
37+
{% if results.warn_missing['baseline_image'] -%}
3538
<div class="list-group m-2">
3639
{{ filter_option('image-match', 'matching images') }}
3740
{{ filter_option('image-diff', 'differing images') }}
3841
{{ filter_option('image-missing', 'no baseline image') }}
3942
</div>
43+
{%- endif %}
44+
{% if results.warn_missing['baseline_hash'] -%}
4045
<div class="list-group m-2 mpl-hash">
4146
{{ filter_option('hash-match', 'matching hashes') }}
4247
{{ filter_option('hash-diff', 'differing hashes') }}
4348
{{ filter_option('hash-missing', 'no baseline hash') }}
4449
</div>
50+
{%- endif %}
4551
<div class="d-flex">
4652
<button type="submit" class="btn btn-primary m-2" data-bs-dismiss="offcanvas">Apply</button>
4753
<button type="submit" class="btn btn-outline-secondary m-2" onclick="resetFilters()">Reset</button>

0 commit comments

Comments
 (0)