본문 바로가기
개발 일지/개발하면서 했던 고민들

개발 고민 | 테스트 가능한 코드와 테스트의 범위에 대하여

by Hoya324 2025. 2. 17.

들어가기 전...

최근 테스트 코드를 작성하면서 "어떤 코드가 테스트하기 좋은 코드일까?" 라는 고민을 하게 되었습니다.

 

테스트 코드의 범위를 어떻게 설정해야 하는지, 그리고 테스트하기 좋은 코드의 특징이 무엇인지에 대한 고민이 많았습니다.

  • 테스트 가능한 코드란 무엇일까?
  • 테스트의 범위는 어디까지 설정해야 하지?
  • 테스트 코드에서 관리할 수 없는(라이브러리와 같은) 코드를 어떻게 다뤄야 할까?

이런 고민을 하다 보니, 테스트의 목적코드의 구조적 설계가 밀접한 관계가 있음을 깨달았습니다.

 

특히, 프로덕트 코드에서는 의존성을 줄이고, 테스트 코드에서는 동작을 제어할 수 있도록 설계하는 것이 테스트 코드와 프로덕트 코드를 모두 고려하는 방법이라고 생각이 들었습니다.

 

그래서 이번 글에서는 테스트하기 좋은 코드란 무엇인지, 그리고 어떻게 하면 테스트하기 좋은 코드를 작성할 수 있는지 고민한 내용을 정리해보겠습니다.


테스트 가능한 코드란 무엇일까?

테스트 가능한 코드를 정의하는 여러 가지 기준이 있지만, 기본적으로 다음과 같은 특징을 만족해야 합니다.

 

1. 결과가 예측 가능해야 한다.

  • 같은 입력값에 대해 항상 같은 출력을 반환해야 합니다.

2. 독립적으로 실행될 수 있어야 한다.

  • 네트워크, 데이터베이스, 외부 API 같은 외부 요소에 의존하지 않아야 합니다.

3. 제어할 수 있어야 한다.

  • 테스트 코드에서 원하는 값을 설정하고, 이를 검증할 수 있어야 합니다.

이런 기준을 만족하려면 테스트 코드에서 제어할 수 없는 요소(외부 시스템, 랜덤 값 등)를 분리해야 합니다.

 

사실 이 부분은 이미 우리에게 꽤나 익숙한 방법이 있습니다. 바로 인터페이스를 활용한 의존성 주입(Dependency Injection) 입니다.

 

외부의 제어해야하는 부분을 인터페이스로 추상화하고 비니지스 코드와 분리하되, 테스트가 가능하도록 구현하는 것이 목표였습니다.

말이 어려우니 코드로 한번 알아보겠습니다.


테스트 범위와 테스트하기 좋은 코드는 뭘까?

매 프로젝트마다 테스트를 무의식 중에 짜게 되었던 것 같습니다. 반성하는 마음을 가지며 테스트 범위에 대해 고민해보았습니다.

 

트랜잭션이 복잡하거나, 흐름이 중요한 경우 어떤 메서드가 잘 수행되었는지는 중요한 부분일 수 있지만, 보다 중요한 것은 비즈니스 로직을 검증하는 것이지, 프레임워크나 외부 시스템의 동작을 검증하는 것이 아니라고 생각했습니다.

 

그렇다면 테스트를 어떻게 쉽게 할 수 있을까요? 개인적인 경험에서는 테스트를 짜다보면 조금더 작은 단위까지 테스트하기 위해 프로덕트 코드의 구조를 변경하는 경우가 허다했던 것 같습니다.

 

이렇게 내린 결론은, 테스트를 쉽게 만들기 위해서는 테스트 코드에서 외부 시스템을 통제할 수 있어야 한다는 것입니다.

 

즉, 외부에 의존하는 부분을 인터페이스로 추상화하여, 테스트 시에는 가짜 객체(Fake Object)나 Mock을 사용할 수 있도록 설계하는 것이 중요합니다.

 

한번 메일 발송 시스템으로 실제 예를 들어보겠습니다.


프로덕트 코드: 의존성 최소화

메일 발송 기능을 인터페이스를 활용하여 구현하면, 실제 메일 발송 방식에 관계없이 코드가 동작하도록 설계할 수 있습니다.

인터페이스 정의

public interface MailSender {
    void send(String to, String message);
}
  • MailSender 인터페이스를 정의하면 메일 발송 방식에 상관없이 이 인터페이스를 구현한 객체라면 어디서든 사용할 수 있을 것입니다.

MailSender 구현체

@RequiredArgsConstructor
public class SmtpMailSender implements MailSender {

		private final RestClient restclient;
    
    @Override
    public void send(String to, String message) {
        restclient.post()
		        .uri("<https://email/>..")
		        ...
    }
}
  • SmtpMailSender실제 SMTP 서버와 연결되어 메일을 전송하는 구현체입니다.

비즈니스 로직에서 인터페이스를 활용

@RequiredArgsConstructor
public class NotificationService {
    
    private final MailSender mailSender;

    public void notifyUser(String userEmail, String content) {
        mailSender.send(userEmail, content);
    }
}
  • NotificationService메일 발송 방식(SMTP, AWS SES, SendGrid 등)에 대해 알 필요 없이 MailSender 인터페이스만 사용합니다.
  • 즉, 의존성이 인터페이스를 통해 추상화되었기 때문에, 실제 구현 방식이 바뀌어도 NotificationService의 코드는 변경되지 않습니다.

테스트 코드

이제 위 코드를 테스트한다고 가정해보겠습니다.

 

✨ 문제점:

  • 실제 메일이 발송되면, 테스트를 돌릴 때마다 메일이 쌓이는 문제가 발생할 수 있습니다.
  • 네트워크 문제로 인해 테스트가 실패할 수도 있습니다.
  • 메일 발송 여부를 쉽게 검증하기 어렵습니다.

우리는 지금 이메일이 잘 보내지는지 궁금한 것이 아닙니다.

따라서 테스트에서는 가짜 구현체(Fake Object) 를 사용하여 실제 프로덕트 코드와 분리된, 테스트에서 필요한 부분만 구현합니다.

public class FakeMailSender implements MailSender {

    private final List<String> sentMessages = new ArrayList<>();

    @Override
    public void send(String to, String message) {
        sentMessages.add(to + ": " + message);
    }

    public List<String> getSentMessages() {
        return sentMessages;
    }
}
  • FakeMailSender 는 실제 메일을 발송하지 않고, 발송된 내역을 저장하는 역할을 합니다.
  • 이렇게 하면 테스트에서 메일 발송 내역을 확인할 수 있습니다.

 

이제 FakeMailSender 를 사용하여 테스트를 작성해보겠습니다.

class NotificationServiceTest {

    @Test
    void 알림이_올바르게_발송되는지_테스트() {
        // given
        FakeMailSender mailSender = new FakeMailSender();
        NotificationService service = new NotificationService(mailSender);

        // when
        service.notifyUser("test@example.com", "Hello Test!");

        // then
        Assertions.assertThat(mailSender.getSentMessages().size()).isEqualTo(1);
        Assertions.assertThat(
            mailSender.getSentMessages().get(0).contains("Hello Test!")
        ).isEqualTo(true);
    }
}

제가 생각하는 이 테스트 코드의 장점

  1. 실제 메일을 발송하지 않고, 가짜 구현체(FakeMailSender)를 사용하여 동작을 제어합니다.
  2. 발송 내역을 저장하고 검증할 수 있어, 테스트의 목적성을 유지할 수 있습니다.
  3. 테스트의 신뢰도를 높이고, 네트워크 오류 같은 외부 변수의 영향을 받지 않습니다.

결론

테스트하기 좋은 코드를 만들기 위해서는 테스트 코드에서 컨트롤할 수 없는 요소를 분리해야 한다고 생각했습니다.

인터페이스를 활용한 의존성 주입(Dependency Injection)이 왜 필요한가?에 대해 피부에 와닿게 이해할 수 있었던 시간이었습니다.

제가 이번에 고민하면서 정리해본 테스트하기 좋은 코드의 원칙은 다음과 같습니다.

  • 외부 의존성을 직접 사용하지 않고, 인터페이스를 통해 추상화한다.
  • 비즈니스 로직이 외부의 변화에 쉽게 변하지 않고, 유지되도록 설계한다.

이를 통해 독립적인 테스트가 가능하고, 테스트 코드에서 원하는 동작을 직접 통제할 수 있으며, 비즈니스 로직을 안정적으로 검증할 수 있는 코드를 작성할 수 있습니다.

앞으로도 테스트 가능한 코드를 고민하면서, 더 좋은 방법을 찾아보고자 합니다.


Reference