효율적인 단위 테스트: private 메서드와 랜덤 함수

서론

우테코 7기 프리코스를 수강하면서 테스트 코드의 중요성과 TDD(테스트 주도 개발) 방법론을 배웠다. 이 과정에서 몇 가지 어려움이 있었고, 이 문제들을 해결하고 정리하는 시간을 가지려고 한다.

특히 private 메서드는 테스트 코드에서 접근할 수 없기 때문에, 접근 제어자를 private에서 public으로 변경하거나 상위 메서드를 통해 접근하는 방법을 선택해야 했다.

또한, 랜덤 값이나 사용자 입력과 같이 예측할 수 없는 값을 테스트할 때 어려움을 겪었다. 이러한 문제 해결 방안을 함께 고민해보려 한다.

private 메서드 사용의 필요성

프라이빗 메서드(private method)는 클래스 내부에서만 호출되고 외부에서는 접근할 수 없는 메서드이다. 보통 캡슐화를 위해 프라이빗 메서드를 사용한다.

내부 구현 은닉

클래스의 동작을 외부에 숨겨 외부 코드가 내부 구조에 의존하지 않도록 한다.

예를 들어, BankAccount 클래스의 경우 사용자는 계좌의 잔액을 직접 수정할 수 없다. 잔액을 업데이트하는 메서드는 프라이빗으로 설정되어 외부에서 호출할 수 없다. 이렇게 하면 내부의 잔액 계산 로직이 변경되더라도 외부 코드에는 영향을 주지 않는다.

public class BankAccount {
    private double balance;

    private void updateBalance(double amount) {
        balance += amount; // 외부에서 접근 불가
    }
}

데이터 보호

클래스의 상태를 외부에서 직접 변경하지 못하게 하여 데이터의 무결성을 유지한다.

User 클래스에서는 사용자의 비밀번호를 외부에서 직접 변경할 수 없다. 비밀번호를 설정하는 메서드는 프라이빗으로 설정되어 있어, 외부에서는 임의로 비밀번호를 수정할 수 없다. 사용자는 비밀번호 변경을 위해 별도의 메서드를 통해서만 작업할 수 있다.

public class User {
    private String password;

    private void setPassword(String newPassword) {
        password = newPassword; // 외부에서 접근 불가
    }
}

테스트 시도

이와 같은 프라이빗 메서드는 코드의 가독성과 안정성을 높이는 데 기여한다.

하지만 이를 테스트하려면 외부에서 접근해야 하므로, 프라이빗 메서드에 접근할 수 없다는 경고가 발생한다.

java: updateBalance(int) has private access in BankAccount

private 메서드 테스트 주의 사항

프라이빗 메서드(private method)의 테스트를 진행할 때 어떤 방법을 사용할 수 있을까? 먼저, 쉽게 생각할 수 있지만 절대 해서는 안 되는 행동부터 살펴보겠다.

퍼블릭으로 접근 제어자를 변경하는 것은 피해야 한다.

퍼블릭으로 변경하면 테스트에서 접근할 수 있게 되지만, 앞서 이야기한 프라이빗 메서드의 장점은 모두 사라지게 된다.

내부 구현이 노출되고, 데이터 무결성이 위협받으며, 캡슐화 원칙을 위반하게 된다.

테스트 코드를 위한 메서드는 구현하면 안 된다.

테스트 전용 메서드를 만들면 테스트 코드의 의도를 파악하기 어려워지고, 이는 테스트의 신뢰성을 저하시킨다.

테스트의 주 목적은 기능이 제대로 작동하는지 확인하는 것이지만, 테스트 전용 메서드는 실제로 그 메서드가 어떻게 작동하는지를 제대로 반영하지 않을 수 있다.

이처럼 두 가지 사항은 테스트를 진행할 때 꼭 주의해야 할 중요한 포인트이다.

private 메서드 테스트의 해결책

프라이빗 메서드가 단순히 가독성을 위한 분리일 뿐만 아니라, 중요한 역할을 수행하는 경우에는 클래스를 분리하는 것이 필요하다.

예를 들어, Bank 클래스는 입출금, 계좌 잔액 표시 등의 역할을 수행한다고 가정하자. 하지만 withdrawal 메서드는 금액을 출금하는 중요한 역할을 하면서도 프라이빗으로 선언되어 있어 테스트할 수 없다.

public class Bank {
    private int withdrawal(Account account, int amount) {
        return account.getBalance() - amount;
    }

    public void displayBalance(Account account) {
        int newBalance = withdrawal(account, 10000);
        System.out.println("거래 후 잔액: " + newBalance);
    }
}

이러한 경우, 책임을 명확히 하고 withdrawal 메서드를 다른 클래스로 분리하는 것이 바람직하다.

public class WithdrawalProcessor {
    public int processWithdrawal(Account account, int amount) {
        return account.getBalance() - amount;
    }
}

public class Bank {
    private WithdrawalProcessor withdrawalProcessor = new WithdrawalProcessor();

    public void displayBalance(Account account) {
        int newBalance = withdrawalProcessor.processWithdrawal(account, 10000);
        System.out.println("거래 후 잔액: " + newBalance);
    }
}

이렇게 WithdrawalProcessor 클래스를 통해 withdrawal 메서드를 간접적으로 테스트할 수 있게 된다.

물론 클래스 내부에 있는 메서드를 이용해 간접적으로 테스트가 가능하지만, 클래스를 분리함으로써 책임을 명확히 하고, 테스트 가능성을 높이며, 코드의 응집도를 향상시킬 수 있다.

테스트 하기 어려운 메서드

private 메서드 말고도 테스트하기 어려운 메서드들이 있다. 예를 들어 랜덤 함수와 같은 예상할 수 없는 값을 포함하는 메서드를 테스트하는 것은 매우 어려운 일이다.

Racing 미션을 예로 들어보겠다.
Racing의 경우, 랜덤 값이 4 이상이면 전진하고, 3 이하면 정지하는 규칙을 가지고 있다고 가정한다.

만약 이 랜덤 값을 사용하는 move 함수를 테스트한다고 한다면, 테스트가 원활하지 않을 것이다. 개발자는 랜덤 값이 예측할 수 없기 때문이다.

public void move() {
    final int number = random.nextInt(RANDOM_NUMBER_UPPER_BOUND);

    if (number >= MOVABLE_LOWER_BOUND) {
        position++;
    }
}

테스트 하기 어려운 메서드 해결 방법

랜덤 함수에 의존하는 코드를 테스트하기 쉽게 만들기 위해서는 의존성을 외부에서 주입받는 방식으로 리팩터링하는 것이 필요하다. 이를 통해 테스트할 수 있는 상태로 만들 수 있다.

예를 들어, move 메서드에서 랜덤 값을 직접 생성하는 대신, 외부에서 랜덤 값을 주입받도록 변경할 수 있다.

리팩터링 전

현재 move 메서드는 내부에서 랜덤 값을 생성하고 있기 때문에 테스트가 어렵다.

랜덤 값을 주입받는 방식으로 수정해보자.

  • Application(테스트하기 어려움)

⬇️

  • GameController(테스트하기 어려움)

⬇️

  • move(테스트하기 어려움) ➡️ Randoms(테스트하기 어려움)

리팩터링 후

리팩터링 후, move 메서드는 외부에서 랜덤 값을 주입받도록 수정된다.

public void move(int number) {
    if (number >= MOVABLE_LOWER_BOUND) {
        position++;
    }
}
  • Application(테스트하기 어려움)

⬇️

  • GameController(테스트하기 어려움) ➡️ Randoms(테스트하기 어려움)

⬇️

  • move(테스트하기 쉬움)

이러한 구조의 변화는 랜덤 값의 생성 및 사용을 move 메서드에서 분리하여 테스트 가능한 상태로 만들어준다. 이제 move 메서드는 외부에서 주입받은 값을 바탕으로 동작하므로, 다양한 시나리오에 대한 테스트가 가능해진다.

물론, 이렇게 수정했다고 모든 문제가 해결된 것은 아니다. 단지 랜덤 한 값에 의존하는 클래스의 위치가 변경된 것이기 때문이다. 실제로 move 메서드를 사용하는 다른 클래스에서 랜덤 한 값을 주입해 주어야 하기 때문에, 해당 클래스에서는 다시 랜덤 한 값에 의존하게 된다.

우리는 이러한 의존을 어디에 두고, 어떻게 관리해야 할 것인지 고민해야 한다. 그렇게 고민한 시간만큼 더 테스트하기 좋은 구조로 코드를 작성할 수 있을 것이다.

결론 및 요약

이번 글에서는 private 메서드와 랜덤 함수로 인한 테스트의 어려움과 해결 방안을 다루었다.
private 메서드는 내부 구현을 숨기지만 테스트 접근성을 제한하므로, 클래스를 분리해 테스트 가능성을 높이는 것이 중요하다.
랜덤 함수는 외부에서 값을 주입받도록 리팩토링하여 다양한 테스트를 쉽게 수행할 수 있다.
이러한 접근은 테스트의 신뢰성을 높이고 소프트웨어 품질을 개선하는 데 기여한다.

참고자료

메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기

위로 스크롤