Skip to content

Commit b15e5f4

Browse files
Add equal_to_approx matcher for approximate numeric assertions (#39443)
* Add equal_to_approx matcher for approximate numeric assertions equal_to only supports exact equality, so tests over floating point pipeline output had to supply a custom equals_fn. Add equal_to_approx, which compares real number elements (including numbers nested in tuples or lists) with math.isclose using configurable rel_tol and abs_tol, reusing the existing equal_to matching. Includes unit tests and a CHANGES.md entry. Fixes #18028 * Address review feedback: show int inputs and expand equal_to_approx docstring Apply reviewer suggestions on #39443: mix an int into the equal_to_approx tests to show integer inputs are accepted, and expand the equal_to_approx docstring with an Args section and an example.
1 parent a3703d3 commit b15e5f4

3 files changed

Lines changed: 88 additions & 0 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070

7171
* (Python) Removed the `envoy-data-plane` (and transitive `betterproto`) dependency; `EnvoyRateLimiter` now uses a small vendored protobuf definition instead, resolving dependency conflicts for downstream projects ([#37854](https://github.com/apache/beam/issues/37854)).
7272
* (Java) Supported acknowledge mode for JmsIO ([#39253](https://github.com/apache/beam/issues/39253)).
73+
* (Python) Added `equal_to_approx`, an `assert_that` matcher that compares numeric pipeline outputs with a configurable tolerance ([#18028](https://github.com/apache/beam/issues/18028)).
7374
* X feature added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
7475

7576
## Breaking Changes

sdks/python/apache_beam/testing/util.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import collections
2323
import glob
2424
import io
25+
import math
26+
import numbers
2527
import tempfile
2628
from typing import Any
2729
from typing import Iterable
@@ -44,6 +46,7 @@
4446
__all__ = [
4547
'assert_that',
4648
'equal_to',
49+
'equal_to_approx',
4750
'equal_to_per_window',
4851
'has_at_least_one',
4952
'is_empty',
@@ -231,6 +234,46 @@ def row_namedtuple_equals_fn(expected, actual, fallback_equals_fn=None):
231234
return True
232235

233236

237+
def equal_to_approx(expected, rel_tol=1e-09, abs_tol=0.0):
238+
"""Matcher used by assert_that that compares numeric elements approximately.
239+
240+
Behaves similarly to `equal_to` for sequence ordering and membership, but any
241+
real number elements (integers and floats) are compared using `math.isclose`
242+
instead of exact equality.
243+
244+
Approximate comparisons are also applied to real numbers nested within lists
245+
and tuples. Note that `equal_to`'s advanced handling for Beam `Row` and
246+
`NamedTuple` is not supported here. All other elements are compared using
247+
standard `==` equality.
248+
249+
Args:
250+
expected: The expected output or sequence to compare against.
251+
rel_tol: The relative tolerance used by `math.isclose` (default: 1e-09).
252+
abs_tol: The absolute tolerance used by `math.isclose` (default: 0.0).
253+
254+
Example:
255+
assert_that(
256+
pipeline | beam.Create([1.000000001, 2.0]),
257+
equal_to_approx([1.0, 2.0]))
258+
"""
259+
def _approx_equals(expected_element, actual_element):
260+
return _elements_approx_equal(
261+
expected_element, actual_element, rel_tol, abs_tol)
262+
263+
return equal_to(expected, equals_fn=_approx_equals)
264+
265+
266+
def _elements_approx_equal(expected, actual, rel_tol, abs_tol):
267+
if all(isinstance(x, numbers.Real) for x in (expected, actual)):
268+
return math.isclose(expected, actual, rel_tol=rel_tol, abs_tol=abs_tol)
269+
if (isinstance(expected, (list, tuple)) and type(actual) is type(expected) and
270+
len(expected) == len(actual)):
271+
return all(
272+
_elements_approx_equal(e, a, rel_tol, abs_tol)
273+
for e, a in zip(expected, actual))
274+
return expected == actual
275+
276+
234277
def matches_all(expected):
235278
"""Matcher used by assert_that to check a set of matchers.
236279

sdks/python/apache_beam/testing/util_test.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from apache_beam.testing.util import TestWindowedValue
3030
from apache_beam.testing.util import assert_that
3131
from apache_beam.testing.util import equal_to
32+
from apache_beam.testing.util import equal_to_approx
3233
from apache_beam.testing.util import equal_to_per_window
3334
from apache_beam.testing.util import is_empty
3435
from apache_beam.testing.util import is_not_empty
@@ -93,6 +94,49 @@ def test_assert_with_custom_comparator(self):
9394
p | Create([1, 2, 3]),
9495
equal_to(['1', '2', '3'], equals_fn=lambda e, a: int(e) == int(a)))
9596

97+
def test_equal_to_approx(self):
98+
with TestPipeline() as p:
99+
assert_that(
100+
p | Create([1.0, 2, 3.0]), equal_to_approx([3.0000000001, 2.0, 1.0]))
101+
102+
def test_equal_to_approx_nested(self):
103+
with TestPipeline() as p:
104+
assert_that(
105+
p | Create([('a', 1.0), ('b', 2.0)]),
106+
equal_to_approx([('b', 2.0000000001), ('a', 1)]))
107+
108+
def test_equal_to_approx_with_abs_tol(self):
109+
with TestPipeline() as p:
110+
assert_that(p | Create([0.0]), equal_to_approx([1e-10], abs_tol=1e-9))
111+
112+
def test_equal_to_approx_fails_outside_tolerance(self):
113+
with self.assertRaises(Exception):
114+
with TestPipeline() as p:
115+
assert_that(p | Create([1.0]), equal_to_approx([1.1]))
116+
117+
def test_equal_to_approx_nested_list(self):
118+
with TestPipeline() as p:
119+
assert_that(
120+
p | Create([[1.0, 2.0]]), equal_to_approx([[1.0000000001, 2.0]]))
121+
122+
def test_equal_to_approx_non_numeric(self):
123+
with TestPipeline() as p:
124+
assert_that(p | Create(['a', 'b']), equal_to_approx(['b', 'a']))
125+
126+
def test_equal_to_approx_empty(self):
127+
with TestPipeline() as p:
128+
assert_that(p | Create([]), equal_to_approx([]))
129+
130+
def test_equal_to_approx_with_rel_tol(self):
131+
with TestPipeline() as p:
132+
assert_that(
133+
p | Create([100.0]), equal_to_approx([100.00001], rel_tol=1e-6))
134+
135+
def test_equal_to_approx_nested_fails_outside_tolerance(self):
136+
with self.assertRaises(Exception):
137+
with TestPipeline() as p:
138+
assert_that(p | Create([('a', 1.0)]), equal_to_approx([('a', 1.2)]))
139+
96140
def test_reified_value_passes(self):
97141
expected = [
98142
TestWindowedValue(v, MIN_TIMESTAMP, [GlobalWindow()])

0 commit comments

Comments
 (0)