Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Changes and improvements to testtools_, grouped by release.
2.8.1
~~~~~

Improvements
------------

* Add ``Nearly`` matcher for approximate numerical comparisons. (#222)

Changes
-------

Expand Down
2 changes: 2 additions & 0 deletions testtools/matchers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"MatchesRegex",
"MatchesSetwise",
"MatchesStructure",
"Nearly",
"Never",
"Not",
"NotEquals",
Expand All @@ -71,6 +72,7 @@
IsInstance,
LessThan,
MatchesRegex,
Nearly,
NotEquals,
SameMembers,
StartsWith,
Expand Down
60 changes: 60 additions & 0 deletions testtools/matchers/_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"IsInstance",
"LessThan",
"MatchesRegex",
"Nearly",
"NotEquals",
"SameMembers",
"StartsWith",
Expand Down Expand Up @@ -148,6 +149,65 @@ class GreaterThan(_BinaryComparison):
mismatch_string = "<="


class _NotNearlyEqual(Mismatch):
"""Mismatch for Nearly matcher."""

def __init__(self, actual, expected, delta):
self.actual = actual
self.expected = expected
self.delta = delta

def describe(self):
try:
diff = abs(self.actual - self.expected)
return (
f"{self.actual!r} is not nearly equal to {self.expected!r}: "
f"difference {diff!r} exceeds tolerance {self.delta!r}"
)
except (TypeError, AttributeError):
return (
f"{self.actual!r} is not nearly equal to {self.expected!r} "
f"within {self.delta!r}"
)


class Nearly(Matcher):
"""Matches if a value is nearly equal to the expected value.

This matcher is useful for comparing floating point values where exact
equality cannot be relied upon due to precision limitations.

The matcher checks if the absolute difference between the actual and
expected values is less than or equal to a specified tolerance (delta).

This works for any type that supports subtraction and absolute value
operations (e.g., integers, floats, Decimal, etc.).
"""

def __init__(self, expected, delta=0.001):
"""Create a Nearly matcher.

:param expected: The expected value to compare against.
:param delta: The maximum allowed absolute difference (tolerance).
Default is 0.001.
"""
self.expected = expected
self.delta = delta

def __str__(self):
return f"Nearly({self.expected!r}, delta={self.delta!r})"

def match(self, actual):
try:
diff = abs(actual - self.expected)
if diff <= self.delta:
return None
except (TypeError, AttributeError):
# Can't compute difference - definitely not nearly equal
pass
return _NotNearlyEqual(actual, self.expected, self.delta)


class SameMembers(Matcher):
"""Matches if two iterators have the same members.

Expand Down
111 changes: 111 additions & 0 deletions testtools/tests/matchers/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
IsInstance,
LessThan,
MatchesRegex,
Nearly,
NotEquals,
SameMembers,
StartsWith,
_BinaryMismatch,
_NotNearlyEqual,
)
from testtools.tests.helpers import FullStackRunTest
from testtools.tests.matchers.helpers import TestMatchersInterface
Expand Down Expand Up @@ -443,6 +445,115 @@ class TestHasLength(TestCase, TestMatchersInterface):
]


class TestNearlyInterface(TestCase, TestMatchersInterface):
matches_matcher: ClassVar = Nearly(4.0, delta=0.5)
matches_matches: ClassVar = [4.0, 4.5, 3.5, 4.25, 3.75]
matches_mismatches: ClassVar = [4.51, 3.49, 5.0, 2.0, "not a number"]

str_examples: ClassVar = [
("Nearly(4.0, delta=0.5)", Nearly(4.0, delta=0.5)),
("Nearly(1.5, delta=0.001)", Nearly(1.5, delta=0.001)),
("Nearly(10, delta=1)", Nearly(10, delta=1)),
]

describe_examples: ClassVar = [
(
"5.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5",
5.0,
Nearly(4.0, delta=0.5),
),
(
"3.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5",
3.0,
Nearly(4.0, delta=0.5),
),
]


class TestNearlyMismatch(TestCase):
"""Tests for the _NotNearlyEqual mismatch class."""

def test_describe_with_numeric_values(self):
"""Test describe() with valid numeric values."""
mismatch = _NotNearlyEqual(5.0, 4.0, 0.5)
self.assertEqual(
"5.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5",
mismatch.describe(),
)

def test_describe_with_non_numeric_values(self):
"""Test describe() when subtraction is not supported."""
mismatch = _NotNearlyEqual("string", 4.0, 0.5)
self.assertEqual(
"'string' is not nearly equal to 4.0 within 0.5", mismatch.describe()
)


class TestNearlyBehavior(TestCase):
"""Additional tests for Nearly matcher behavior."""

def test_integers_match(self):
"""Test that Nearly works with integers."""
matcher = Nearly(10, delta=2)
self.assertIsNone(matcher.match(11))
self.assertIsNone(matcher.match(9))
self.assertIsNone(matcher.match(10))

def test_integers_mismatch(self):
"""Test that Nearly correctly fails with integers."""
matcher = Nearly(10, delta=2)
mismatch = matcher.match(13)
self.assertIsNotNone(mismatch)
self.assertIn("13", mismatch.describe())

def test_exact_boundary_matches(self):
"""Test that values at exactly the boundary match."""
matcher = Nearly(1.0, delta=0.25)
# Exactly at the boundary should match (<=)
self.assertIsNone(matcher.match(1.25))
self.assertIsNone(matcher.match(0.75))

def test_just_outside_boundary_mismatches(self):
"""Test that values just outside the boundary don't match."""
matcher = Nearly(1.0, delta=0.1)
# Just outside the boundary should not match
self.assertIsNotNone(matcher.match(1.10001))
self.assertIsNotNone(matcher.match(0.89999))

def test_negative_numbers(self):
"""Test that Nearly works with negative numbers."""
matcher = Nearly(-5.0, delta=1.0)
self.assertIsNone(matcher.match(-4.5))
self.assertIsNone(matcher.match(-5.5))
self.assertIsNotNone(matcher.match(-3.5))

def test_zero_delta(self):
"""Test Nearly with zero delta (exact match required)."""
matcher = Nearly(1.0, delta=0.0)
self.assertIsNone(matcher.match(1.0))
self.assertIsNotNone(matcher.match(1.0001))

def test_default_delta(self):
"""Test that default delta is 0.001."""
matcher = Nearly(1.0)
self.assertEqual(0.001, matcher.delta)
self.assertIsNone(matcher.match(1.0005))
self.assertIsNotNone(matcher.match(1.002))

def test_non_numeric_type_mismatch(self):
"""Test that non-numeric types result in a mismatch."""
matcher = Nearly(1.0, delta=0.1)
mismatch = matcher.match("string")
self.assertIsNotNone(mismatch)
self.assertIn("string", mismatch.describe())

def test_none_type_mismatch(self):
"""Test that None results in a mismatch."""
matcher = Nearly(1.0, delta=0.1)
mismatch = matcher.match(None)
self.assertIsNotNone(mismatch)


def test_suite():
from unittest import TestLoader

Expand Down