JoeMjr2
JoeMjr2

Reputation: 3944

Java List.contains object with double with tolerance

Let's say I have this class:

public class Student
{
  long studentId;
  String name;
  double gpa;

  // Assume constructor here...
}

And I have a test something like:

List<Student> students = getStudents();
Student expectedStudent = new Student(1234, "Peter Smith", 3.89)
Assert(students.contains(expectedStudent)

Now, if the getStudents() method calculates Peter's GPA as something like 3.8899999999994, then this test will fail because 3.8899999999994 != 3.89.

I know that I can do an assertion with a tolerance for an individual double/float value, but is there an easy way to make this work with "contains", so that I don't have to compare each field of Student individually (I will be writing many similar tests, and the actual class I will be testing will contain many more fields).

I also need to avoid modifying the class in question (i.e. Student) to add custom equality logic.

Also, in my actual class, there will be nested lists of other double values that need to be tested with a tolerance, which will complicate the assertion logic even more if I have to assert each field individually.

Ideally, I'd like to say "Tell me if this list contains this student, and for any float/double fields, do the comparison with tolerance of .0001"

Any suggestions to keep these assertions simple are appreciated.

Upvotes: 3

Views: 2454

Answers (4)

davidxxx
davidxxx

Reputation: 131456

1) Don't override equals/hashCode only for unit testing purposes

These methods have a semantic and their semantic is not taking into consideration all fields of the class to make a test assertion possible.

2) Rely on testing library to perform your assertions

Assert(students.contains(expectedStudent)

or that (posted in the John Bollinger answer):

Assert(students.stream().anyMatch(s -> expectedStudent.matches(s)));

are great anti patterns in terms of unit testing.
When an assertion fails, the first thing that you need is knowing the cause of the error to correct the test.
Relying on a boolean to assert the list comparison doesn't allow that at all.
KISS (Keep it simple and stupid): Use testing tools/features to assert and don't reinvent the wheel because these will provide the feedback needed when your test fails.

3) Don't assert double with equals(expected, actual).

To assert double values, unit testing libraries provide a third parameter in the assertion to specify the allowed delta such as :

public static void assertEquals(double expected, double actual, double delta) 

in JUnit 5 (JUnit 4 has a similarly thing).

Or favor BigDecimal to double/float that is more suitable for this kind of comparison.

But it will not completely solve your requirement as you need to assert multiple fields of your actual object. Using a loop to do that is clearly not a fine solution.
Matcher libraries provide a meaningful and elegant way to solve that.

4) Use Matcher libraries to perform assertions on specific properties of objects of the actual List

With AssertJ :

//GIVEN
...

//WHEN
List<Student> students = getStudents();

//THEN
Assertions.assertThat(students)
           // 0.1 allowed delta for the double value
          .usingComparatorForType(new DoubleComparator(0.1), Double.class) 
          .extracting(Student::getId, Student::getName, Student::getGpa)
          .containsExactly(tuple(1234, "Peter Smith", 3.89),
                           tuple(...),
          );

Some explanations (all of these are AssertJ features) :

  • usingComparatorForType() allows to set a specific comparator for the given type of elements or their fields.

  • DoubleComparator is a AssertJ comparator providing the facility to take an epsilon into consideration in the double comparison.

  • extracting defines values to assert from the instances contained in the List.

  • containsExactly() asserts that the extracted values are exactly (that is no more, no less and in the exact order) these defined in the Tuples.

Upvotes: 10

Michael
Michael

Reputation: 44210

I'm not particularly familiar with the concept of GPA, but I would imagine that it it's never used beyond 2 decimal places of precision. A 3.8899999999994 GPA simply doesn't make a great deal of sense, or at least is not meaningful.

You are effectively facing the same problem that people often face when storing monetary values. £3.89 makes sense, but £3.88999999 does not. There is a wealth of information out there already for handling this. See this article, for example.

TL;DR: I would store the number as an integer. So 3.88 GPA would be stored as 388. When you need to print the value, simply divide by 100.0. Integers do not have the same precision problems as floating point values, so your objects will naturally be easier to compare.

Upvotes: 1

John Bollinger
John Bollinger

Reputation: 181018

The behavior of List.contains() is defined in terms of the equals() methods of the elements. Therefore, if your Student.equals() method compares gpas for exact equality and you cannot change it then List.contains() is not a viable method for your purpose.

And probably Student.equals() shouldn't use a comparison with tolerance, because it's very hard to see how you could make that class's hashCode() method consistent with such an equals() method.

Perhaps what you can do is write an alternative, equals-like method, say "matches()", that contains your fuzzy-comparison logic. You could then test a list for a student fitting your criteria with something like

Assert(students.stream().anyMatch(s -> expectedStudent.matches(s)));

There is an implicit iteration in that, but the same is true of List.contains().

Upvotes: 4

Michael
Michael

Reputation: 1184

If you want to use contains or equals, then you need to take care of rounding in the equals method of Student.

However, I recommend using a proper assertion library such as AssertJ.

Upvotes: 1

Related Questions