Skip to content

feat: add IsCloseTo and IsWithinPercentOf numeric assertions#4940

Open
thomhurst wants to merge 2 commits intomainfrom
feat/numeric-tolerance
Open

feat: add IsCloseTo and IsWithinPercentOf numeric assertions#4940
thomhurst wants to merge 2 commits intomainfrom
feat/numeric-tolerance

Conversation

@thomhurst
Copy link
Owner

Summary

  • Adds IsCloseTo(expected, tolerance) assertion for absolute tolerance comparison: |actual - expected| <= tolerance
  • Adds IsWithinPercentOf(expected, percent) assertion for relative tolerance comparison: |actual - expected| <= |expected * percent / 100|
  • Both work with double, float, int, long, and decimal types
  • Follows existing patterns using [AssertionExtension] for source-generated extension methods
  • Includes proper NaN/infinity handling for floating-point types

Usage

// Absolute tolerance
await Assert.That(10.5).IsCloseTo(10.0, 0.5);  // passes
await Assert.That(100).IsCloseTo(105, 10);       // passes

// Relative tolerance (percentage)
await Assert.That(105.0).IsWithinPercentOf(100.0, 10.0);  // passes (within 10%)
await Assert.That(120.0).IsWithinPercentOf(100.0, 10.0);  // fails (20% off)

Test plan

  • 33 new tests covering all numeric types (double, float, int, long, decimal)
  • Tests for passing cases, failing cases, and edge cases (NaN, infinity, zero expected)
  • Existing numeric assertion tests still pass (69 tests)
  • Existing double equals tolerance tests still pass (10 tests)
  • Build succeeds across all target frameworks (netstandard2.0, net8.0, net9.0, net10.0)

Closes #4870

@claude
Copy link
Contributor

claude bot commented Feb 19, 2026

Code Review

Overall this is a well-structured addition with good test coverage and proper NaN/infinity handling. Two bugs need addressing before merge.


Bug 1 (High): Integer overflow in LongIsCloseToAssertion — silently wrong results

File: TUnit.Assertions/Conditions/NumericIsCloseToAssertion.cs, line 212

var diff = Math.Abs(value - _expected);

When both value and _expected are long, the subtraction is done in long arithmetic with no overflow protection. This causes two failure modes:

  1. Silent wrong result: long.MaxValue - long.MinValue wraps around to -1, so Math.Abs(-1) == 1. An assertion comparing long.MaxValue to long.MinValue with tolerance >= 1 would incorrectly pass, reporting a difference of 1 when the true difference is ~1.84 × 10¹⁹.
  2. Unhandled exception: When the subtraction result is exactly long.MinValue, Math.Abs(long.MinValue) throws OverflowException (since the minimum value has no positive counterpart).

The pattern for fixing this is already established in the same PR — IntIsCloseToAssertion (line 168) correctly widens to long, and LongIsWithinPercentOfAssertion (line 219) correctly widens to double:

// IntIsCloseToAssertion (correct)
var diff = Math.Abs((long)value - _expected);

// LongIsWithinPercentOfAssertion (correct)  
var diff = Math.Abs((double)value - _expected);

The fix for LongIsCloseToAssertion is to widen to double:

var diff = Math.Abs((double)value - (double)_expected);

Note: double loses precision for values near long.MaxValue (~9.2 × 10¹⁸), but for an assertion tolerance comparison this is acceptable. If exact arithmetic is required, decimal or BigInteger could be used instead.


Bug 2 (Low): Missing zero guard produces confusing NaN% in error messages

Files:

$"found {value}, which differs by {diff} ({(diff / Math.Abs(_expected)) * 100:F2}% of expected)"

When _expected == 0, diff / Math.Abs(0.0) produces Infinity or NaN (when both are zero: 0.0 / 0.0 = NaN). The assertion still correctly fails — the pass/fail logic is correct — but the error message becomes:

found 0, which differs by 0 (NaN% of expected)

This is actively confusing. The int, long, and decimal variants already guard against this. Apply the same pattern:

// Already done correctly in IntIsWithinPercentOfAssertion (line 180):
var actualPercent = _expected != 0 ? (diff / Math.Abs(_expected)) * 100 : double.PositiveInfinity;
return Task.FromResult(AssertionResult.Failed(
    $"found {value}, which differs by {diff} ({actualPercent:F2}% of expected)"));

@thomhurst thomhurst force-pushed the feat/numeric-tolerance branch from 00f9e5a to 49924bb Compare February 19, 2026 08:04
@thomhurst thomhurst force-pushed the feat/numeric-tolerance branch from 49924bb to e8e6bbf Compare February 19, 2026 09:38
@thomhurst thomhurst force-pushed the feat/numeric-tolerance branch from e8e6bbf to 9187312 Compare February 19, 2026 11:46
@thomhurst thomhurst force-pushed the feat/numeric-tolerance branch from 9187312 to bfe1e2e Compare February 19, 2026 14:06
Add relative and absolute tolerance assertions for numeric types
(double, float, int, long, decimal):

- IsCloseTo(expected, tolerance): asserts |actual - expected| <= tolerance
- IsWithinPercentOf(expected, percent): asserts |actual - expected| <= |expected * percent / 100|

Both assertions follow the existing pattern using [AssertionExtension]
for source-generated extension methods. Includes proper handling of
NaN, infinity, and edge cases for floating-point types.

Closes #4870
…o double

Cast `value` to `double` before subtraction and compare against
`(double)_tolerance` so that extreme long values (e.g., MaxValue vs MinValue)
no longer silently wrap around.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add relative tolerance for numeric assertions

1 participant