Study/Java

equals() 메서드의 유효성 단위 테스트

cat_alan3 2023. 9. 23. 11:15

오늘(9월 22일) 책 Effective Java에서는 equals() 메서드에 관한 부분을 읽었다. 한 번 읽고는 잘 이해가 되지 않는 부분들이 있어서 같은 내용을 두 세번씩은 더 읽었던 것 같다. 아이템 제목은 '아이템 10. equals는 일반 규약을 지켜 재정의하라'였다. 내용에서는 equals() 메서드를 만들 때 지켜야하는 몇 가지 규칙들에 대해 자세히 설명한다. 그 규칙들은 equivalance class(동치류)의 정의로부터 오는 것들인데, 그것들의 단위 테스트를 작성해서 실행해보라는 굵은 글씨를 그냥 지나치기가 좀 그래서, 테스트 코드를 연습할 겸 간단하게 만들어보았다.

단위 테스트를 실행할 클래스는 오늘 풀었던 BOJ 알고리즘 문제 2887번: 행성 터널에서 만들었던 클래스를 사용했다. 물론 문제를 풀 때 equals() 메서드를 구현해서 풀었던 것은 아니지만, 내가 필요하다고 생각하는 조건을 넣어서 코드를 짜보았다. 아래는 3차원 좌표를 갖고 있는 Planet 클래스와 2개의 Planet 객체와 그 사이의 거리를 필드로 갖는 Tunnel 클래스이다. '아이템 11. equals를 재정의하려거든 hashCode도 재정의하라'의 정신을 따라 hashCode() 메서드도 추가해보았다.

class Planet {
    private final int x, y, z;
    private int hashCode;
    private Planet(int x, int y, int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
    public static Planet create(int x, int y, int z) { return new Planet(x, y, z); }
    ...
    ...
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Planet planet = (Planet) o;
        return x == planet.x && y == planet.y && z == planet.z;
    }
    @Override
    public int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = Integer.hashCode(x);
            result = 31 * result + Integer.hashCode(y);
            result = 31 * result + Integer.hashCode(z);
            hashCode = result;
        }
        return result;
    }
}

class Tunnel implements Comparable<Tunnel> {
    private final Planet star1, star2;
    private final int distance;
    private Tunnel(Planet star1, Planet star2) {
        if (star1.getX() > star2.getX() ||
            (star1.getX() == star2.getX() && star1.getY() > star2.getY()) ||
            (star1.getX() == star2.getX() && star1.getY() == star2.getY() && star1.getZ() > star2.getZ())) {
            this.star1 = star2;
            this.star2 = star1;
        } else {
            this.star1 = star1;
            this.star2 = star2;
        }
        this.distance = Math.min(Math.abs(star1.getX()- star2.getX()),
                Math.min(Math.abs(star1.getY() - star2.getY()), Math.abs(star1.getZ() - star2.getZ())));
    }
    public static Tunnel create(Planet star1, Planet star2) {
        return new Tunnel(star1, star2);
    }
    ...
    ...
    @Override
    public int compareTo(Tunnel other) {
        return this.distance - other.distance;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Tunnel tunnel = (Tunnel) o;
        return (Objects.equals(star1, tunnel.star1) && Objects.equals(star2, tunnel.star2));
    }
    @Override
    public int hashCode() {
        return Objects.hash(star1, star2);
    }
}

먼저 Planet 클래스의 equals() 메서드가 반사성(reflexivity), 대칭성(symmetry), 추이성(transitivity), 일관성(consistency), null-아님을 확인해야하는데, 책에서는 대칭성, 추이성, 일관성만 확인해도 충분하다고 쓰여있다. 반사성과 null-아님은 equals() 메서드 내부에서 이미 확인이 되기 때문에 그런 것 같다. 나는 IntelliJ를 사용하고 있는데, equals() 메서드를 기본적으로 만들어줄 때 그것에 관한 코드가 포함되어 있다. 아래는 Planet 클래스의 equals() 메서드 테스트 코드이다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Random;

import static org.junit.jupiter.api.Assertions.*;

class PlanetTest {
    Random randomNumber = new Random();
    private final int a1 = randomNumber.nextInt(100),
            a2 = randomNumber.nextInt(100),
            a3 = randomNumber.nextInt(100);

    @Test
    @DisplayName("check symmetry")
    void testEqualsSymmetry1() {
        // given
        final Planet planet1 = Planet.create(a1, a2, a3);
        final Planet copyOfPlanet1 = Planet.create(planet1.getX(), planet1.getY(), planet1.getZ());
        // when
        final boolean symmetry1 = planet1.equals(copyOfPlanet1);
        // then
        assertTrue(symmetry1);
        // when
        final boolean symmetry2 = copyOfPlanet1.equals(planet1);
        // then
        assertTrue(symmetry2);
    }

    @Test
    @DisplayName("check transitivity")
    void testEqualsTransitivity() {
        // given
        final Planet planet1 = Planet.create(a1, a2, a3);
        final Planet planet2 = Planet.create(planet1.getX(), planet1.getY(), planet1.getZ());
        final Planet planet3 = Planet.create(planet2.getX(), planet2.getY(), planet2.getZ());
        // when
        final boolean transitivity12 = planet1.equals(planet2);
        final boolean transitivity23 = planet2.equals(planet3);
        // then
        assertTrue(transitivity12 && transitivity23);
        // when
        final boolean transitivity13 = planet1.equals(planet3);
        // then
        assertTrue(transitivity13);
    }

    @Test
    @DisplayName("check consistency")
    void testEqualsConsistency() {
        // given
        final Planet planet1 = Planet.create(a1, a2, a3);
        final Planet planet2 = Planet.create(planet1.getX(), planet1.getY(), planet1.getZ());
        // when
        final boolean consistency1 = planet1.equals(planet2);
        // then
        assertTrue(consistency1);
        // when
        final Planet modifiedPlanet2 = Planet.create(planet2.getX() - 100, planet2.getY(), planet2.getZ());
        final boolean consistency2 = planet1.equals(modifiedPlanet2);
        // then
        assertFalse(consistency2);
    }
}

일관성을 확인하는 코드에서 약간 억지스러운 느낌이 있지만, 연습한다는 생각으로 그냥 만들어 보았다. 예제를 고를 때 좀 더 적절한 것을 골랐어야 하나 싶기도 했다. 다음은 Tunnel 클래스의 equals() 메서드 테스트 코드이다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Random;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class TunnelTest {
    Random randomNumber = new Random();
    private final int a1 = randomNumber.nextInt(100),
            a2 = randomNumber.nextInt(100),
            a3 = randomNumber.nextInt(100);
    private final Planet star1 = Planet.create(a1, a2, a3),
            star2 = Planet.create(a1 + 100, a2 + 100, a3 + 100);

    @Test
    @DisplayName("check Symmetry")
    void testEqualsSymmetry() {
        // given
        final Tunnel tunnel1 = Tunnel.create(star1, star2);
        final Tunnel tunnel2 = Tunnel.create(star2, star1);
        // when
        final boolean symmetry1 = tunnel1.equals(tunnel2);
        // then
        assertTrue(symmetry1);
        // when
        final boolean symmetry2 = tunnel2.equals(tunnel1);
        // then
        assertTrue(symmetry2);
    }

    @Test
    @DisplayName("check transitivity")
    void testEqualsTransitivity() {
        // given
        final Tunnel tunnel1 = Tunnel.create(star1, star2);
        final Tunnel tunnel2 = Tunnel.create(tunnel1.getStar1(), tunnel1.getStar2());
        final Tunnel tunnel3 = Tunnel.create(tunnel2.getStar2(), tunnel2.getStar1());
        // when
        final boolean transitivity12 = tunnel1.equals(tunnel2);
        final boolean transitivity23 = tunnel2.equals(tunnel3);
        // then
        assertTrue(transitivity12 && transitivity23);
        // when
        final boolean transitivity13 = tunnel1.equals(tunnel3);
        // then
        assertTrue(transitivity13);
    }

    @Test
    @DisplayName("check consistency")
    void testEqualsConsistency() {
        // given
        final Tunnel tunnel1 = Tunnel.create(star1, star2);
        final Tunnel tunnel2 = Tunnel.create(tunnel1.getStar2(), tunnel1.getStar1());
        // when
        final boolean consistency1 = tunnel1.equals(tunnel2);
        // then
        assertTrue(consistency1);
        // when
        final Planet star3 = Planet.create(a1 + 200, a2 + 200, a3 + 200);
        final Tunnel modifiedTunnel2 = Tunnel.create(star2, star3);
        final boolean consistency2 = tunnel1.equals(modifiedTunnel2);
        // then
        assertFalse(consistency2);
    }
}

어려울 것이 없는 코드지만 생각보다 시간이 많이 걸렸다. 아직 테스트 코드를 어떻게 작성하는 지에 대한 개인적인 기준이 잘 만들어져있지 않기도 하고, 작성하면서 생각해야할 것도 적지 않아서 그런 것 같다. 너무 귀찮아하지 말고, 꾸준히 연습해보자.

아, 물론 `AutoValue`라고 하는, 위의 코드를 안 써도 되도록 도와주는 프레임 워크가 있긴하다. ㅎ


참고 링크