Skip to content

Commit 339332f

Browse files
authored
Merge pull request #491 from testing-cabal/nearly
Add Nearly matcher for approximate numerical comparisons
2 parents 723114a + ee705b8 commit 339332f

File tree

4 files changed

+178
-0
lines changed

4 files changed

+178
-0
lines changed

NEWS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Changes and improvements to testtools_, grouped by release.
66
2.8.1
77
~~~~~
88

9+
Improvements
10+
------------
11+
12+
* Add ``Nearly`` matcher for approximate numerical comparisons. (#222)
13+
914
Changes
1015
-------
1116

testtools/matchers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"MatchesRegex",
4848
"MatchesSetwise",
4949
"MatchesStructure",
50+
"Nearly",
5051
"Never",
5152
"Not",
5253
"NotEquals",
@@ -71,6 +72,7 @@
7172
IsInstance,
7273
LessThan,
7374
MatchesRegex,
75+
Nearly,
7476
NotEquals,
7577
SameMembers,
7678
StartsWith,

testtools/matchers/_basic.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"IsInstance",
1111
"LessThan",
1212
"MatchesRegex",
13+
"Nearly",
1314
"NotEquals",
1415
"SameMembers",
1516
"StartsWith",
@@ -148,6 +149,65 @@ class GreaterThan(_BinaryComparison):
148149
mismatch_string = "<="
149150

150151

152+
class _NotNearlyEqual(Mismatch):
153+
"""Mismatch for Nearly matcher."""
154+
155+
def __init__(self, actual, expected, delta):
156+
self.actual = actual
157+
self.expected = expected
158+
self.delta = delta
159+
160+
def describe(self):
161+
try:
162+
diff = abs(self.actual - self.expected)
163+
return (
164+
f"{self.actual!r} is not nearly equal to {self.expected!r}: "
165+
f"difference {diff!r} exceeds tolerance {self.delta!r}"
166+
)
167+
except (TypeError, AttributeError):
168+
return (
169+
f"{self.actual!r} is not nearly equal to {self.expected!r} "
170+
f"within {self.delta!r}"
171+
)
172+
173+
174+
class Nearly(Matcher):
175+
"""Matches if a value is nearly equal to the expected value.
176+
177+
This matcher is useful for comparing floating point values where exact
178+
equality cannot be relied upon due to precision limitations.
179+
180+
The matcher checks if the absolute difference between the actual and
181+
expected values is less than or equal to a specified tolerance (delta).
182+
183+
This works for any type that supports subtraction and absolute value
184+
operations (e.g., integers, floats, Decimal, etc.).
185+
"""
186+
187+
def __init__(self, expected, delta=0.001):
188+
"""Create a Nearly matcher.
189+
190+
:param expected: The expected value to compare against.
191+
:param delta: The maximum allowed absolute difference (tolerance).
192+
Default is 0.001.
193+
"""
194+
self.expected = expected
195+
self.delta = delta
196+
197+
def __str__(self):
198+
return f"Nearly({self.expected!r}, delta={self.delta!r})"
199+
200+
def match(self, actual):
201+
try:
202+
diff = abs(actual - self.expected)
203+
if diff <= self.delta:
204+
return None
205+
except (TypeError, AttributeError):
206+
# Can't compute difference - definitely not nearly equal
207+
pass
208+
return _NotNearlyEqual(actual, self.expected, self.delta)
209+
210+
151211
class SameMembers(Matcher):
152212
"""Matches if two iterators have the same members.
153213

testtools/tests/matchers/test_basic.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
IsInstance,
2121
LessThan,
2222
MatchesRegex,
23+
Nearly,
2324
NotEquals,
2425
SameMembers,
2526
StartsWith,
2627
_BinaryMismatch,
28+
_NotNearlyEqual,
2729
)
2830
from testtools.tests.helpers import FullStackRunTest
2931
from testtools.tests.matchers.helpers import TestMatchersInterface
@@ -443,6 +445,115 @@ class TestHasLength(TestCase, TestMatchersInterface):
443445
]
444446

445447

448+
class TestNearlyInterface(TestCase, TestMatchersInterface):
449+
matches_matcher: ClassVar = Nearly(4.0, delta=0.5)
450+
matches_matches: ClassVar = [4.0, 4.5, 3.5, 4.25, 3.75]
451+
matches_mismatches: ClassVar = [4.51, 3.49, 5.0, 2.0, "not a number"]
452+
453+
str_examples: ClassVar = [
454+
("Nearly(4.0, delta=0.5)", Nearly(4.0, delta=0.5)),
455+
("Nearly(1.5, delta=0.001)", Nearly(1.5, delta=0.001)),
456+
("Nearly(10, delta=1)", Nearly(10, delta=1)),
457+
]
458+
459+
describe_examples: ClassVar = [
460+
(
461+
"5.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5",
462+
5.0,
463+
Nearly(4.0, delta=0.5),
464+
),
465+
(
466+
"3.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5",
467+
3.0,
468+
Nearly(4.0, delta=0.5),
469+
),
470+
]
471+
472+
473+
class TestNearlyMismatch(TestCase):
474+
"""Tests for the _NotNearlyEqual mismatch class."""
475+
476+
def test_describe_with_numeric_values(self):
477+
"""Test describe() with valid numeric values."""
478+
mismatch = _NotNearlyEqual(5.0, 4.0, 0.5)
479+
self.assertEqual(
480+
"5.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5",
481+
mismatch.describe(),
482+
)
483+
484+
def test_describe_with_non_numeric_values(self):
485+
"""Test describe() when subtraction is not supported."""
486+
mismatch = _NotNearlyEqual("string", 4.0, 0.5)
487+
self.assertEqual(
488+
"'string' is not nearly equal to 4.0 within 0.5", mismatch.describe()
489+
)
490+
491+
492+
class TestNearlyBehavior(TestCase):
493+
"""Additional tests for Nearly matcher behavior."""
494+
495+
def test_integers_match(self):
496+
"""Test that Nearly works with integers."""
497+
matcher = Nearly(10, delta=2)
498+
self.assertIsNone(matcher.match(11))
499+
self.assertIsNone(matcher.match(9))
500+
self.assertIsNone(matcher.match(10))
501+
502+
def test_integers_mismatch(self):
503+
"""Test that Nearly correctly fails with integers."""
504+
matcher = Nearly(10, delta=2)
505+
mismatch = matcher.match(13)
506+
self.assertIsNotNone(mismatch)
507+
self.assertIn("13", mismatch.describe())
508+
509+
def test_exact_boundary_matches(self):
510+
"""Test that values at exactly the boundary match."""
511+
matcher = Nearly(1.0, delta=0.25)
512+
# Exactly at the boundary should match (<=)
513+
self.assertIsNone(matcher.match(1.25))
514+
self.assertIsNone(matcher.match(0.75))
515+
516+
def test_just_outside_boundary_mismatches(self):
517+
"""Test that values just outside the boundary don't match."""
518+
matcher = Nearly(1.0, delta=0.1)
519+
# Just outside the boundary should not match
520+
self.assertIsNotNone(matcher.match(1.10001))
521+
self.assertIsNotNone(matcher.match(0.89999))
522+
523+
def test_negative_numbers(self):
524+
"""Test that Nearly works with negative numbers."""
525+
matcher = Nearly(-5.0, delta=1.0)
526+
self.assertIsNone(matcher.match(-4.5))
527+
self.assertIsNone(matcher.match(-5.5))
528+
self.assertIsNotNone(matcher.match(-3.5))
529+
530+
def test_zero_delta(self):
531+
"""Test Nearly with zero delta (exact match required)."""
532+
matcher = Nearly(1.0, delta=0.0)
533+
self.assertIsNone(matcher.match(1.0))
534+
self.assertIsNotNone(matcher.match(1.0001))
535+
536+
def test_default_delta(self):
537+
"""Test that default delta is 0.001."""
538+
matcher = Nearly(1.0)
539+
self.assertEqual(0.001, matcher.delta)
540+
self.assertIsNone(matcher.match(1.0005))
541+
self.assertIsNotNone(matcher.match(1.002))
542+
543+
def test_non_numeric_type_mismatch(self):
544+
"""Test that non-numeric types result in a mismatch."""
545+
matcher = Nearly(1.0, delta=0.1)
546+
mismatch = matcher.match("string")
547+
self.assertIsNotNone(mismatch)
548+
self.assertIn("string", mismatch.describe())
549+
550+
def test_none_type_mismatch(self):
551+
"""Test that None results in a mismatch."""
552+
matcher = Nearly(1.0, delta=0.1)
553+
mismatch = matcher.match(None)
554+
self.assertIsNotNone(mismatch)
555+
556+
446557
def test_suite():
447558
from unittest import TestLoader
448559

0 commit comments

Comments
 (0)