에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
아카이브/test

Spring Camp - 테스트 코드에 대하여

반응형

테스트 코드의 중요성은 진작에 알고 있었으나, 언제 적용하나? 적용하려면 어떻게 공부하지 막막하던 참에 직장 동료분께서 추천해주시어 영상을 보고 내용이 너무 좋아 정리하게 되었습니다. 

나를 닮았다고 한다...

 

www.youtube.com/watch?v=YdtknE_yPk4&list=PLdHtZnJh1KdaM0AfxPA7qGK1UuvhpvffL&index=12

왜 작성해야하는가?

가장 큰 이유는 안정감과 자신감입니다. 테스트 코드는 코드에 대한 가장 빠른 피드백을 줄 수 있는 도구입니다. 내가 작성한 코드 대해서 버그를 미리 대비하고 수정을 하였을 때 버그가 없는지 피드백을 해줍니다. 이러한 이유로 안정감과 자심감이 생겨 배포를 자신있게 할 수 있게됩니다.

 

모든 케이스에 대해 작성

지금은 발생하지 않겠지만 코드 수정으로 발생할 수 있는 모든 케이스에 대해 작성합니다. 로또 코드로 예시를 들어보겠습니다.

package com.example.practice.camp;

import java.util.*;

public class LottoGenerator {
    private static final int LOTTO_TICKET_LIMIT_NUM = 6;
    
    private final LottoNumberCollection collection;  // 일급 컬렉션
    
    public LottoGenerator(LottoNumberCollection collection) {
        this.collection = collection;
    }
    
    public List<Integer> generateLotto() {
        Set<Integer> ticket = new HashSet<>();
        List<Integer> lottoNumbers = collection.createNumber();
        shuffleNum(lottoNumbers);
        for (int i = 0; ticket.size() < LOTTO_TICKET_LIMIT_NUM; i++) {
             ticket.add(lottoNumbers.get(i));            
        }
        return new ArrayList<>(ticket);
    }
    
    private void shuffleNum(List<Integer> lottoNumbers) {
        Collections.shuffle(lottoNumbers);
    }
}

로또의 번호를 생성해주는 클래스가 있습니다. 여기서 일급 컬렉션으로 로또 번호를 생성하고 무작위로 6개를 선정하여 리턴처리를 합니다.

 

여기에 테스트 코드를 작성해보겠습니다.

class LottoGeneratorTest {

    @Test
    @DisplayName("6개의 숫자를 반환")
    void generateTicket() {
        LottoGenerator generator = new LottoGenerator(new LottoNumberCollection());

        List<Integer> ticket = generator.generateLotto();

        assertThat(ticket.size()).isEqualTo(6);
    }
}

이 테스트 코드만 있으면 될까? 아니다. 

중복 된 숫자에 대한 테스트 케이스가 없습니다. 하지만..? 코드에서는 Set으로 작성되어있어서 중복될 가능성이 없습니다. 라고 하지만 추 후에 다른 개발자들이 코드를 확장하고 수정한다면 중복이 발생할 수 있습니다. 그럼 테스트는 깨지게 됩니다. 

여기서 중요한 건 코드에 대해서만 테스트 코드를 작성하는 게 아니라 설계적으로 모든 케이스에 대해서 테스트 코드를 작성해야 추 후에도 안정성있는 코드를 작성할 수 있습니다.

 

private 메서드에 대한 테스트

public 메서드는 테스트 코드를 짜기 유용합니다. 하지만 private 메서드가 생겼다면 어떻게 해야할까?

public class Action {

    public String action1() {
        return "action1";
    }

    public String action2(boolean flag) {
        if (flag) {
            return action3();    
        } else {
            return "action2"; 
        }
    }

    private String action3() {
        return "action3";
    }
}
class ActionTest {

    Action action;

    @BeforeEach
    void setUp() {
        action = new Action();
    }

    @Test
    public void action1_test() {
        String result = action.action1();

        assertThat(result).isEqualTo("action1");
    }

    @Test
    public void action2_test() {
        String result = action.action2(false);

        assertThat(result).isEqualTo("action2");
    }

    // private ..?
}

private은 외부에서 호출할 수 없으니 어떻게 테스트 코드를 작성해야할까? 

저도 이 부분에서 고민을 많이했습니다.

결론은 private 메서드를 호출하는 메서드에서 테스트 케이스를 한 개 더 추가하면 됩니다.

class ActionTest {

    Action action;

    @BeforeEach
    void setUp() {
        action = new Action();
    }

    // private ..?
    @Test
    @DisplayName("action3도 포함하는 테스트 케이스 작성")
    public void action2_1_test() {
        String result = action.action2(true);

        assertThat(result).isEqualTo("action3");
    }
}

하지만 이와 같은 경우가 아닐 수도 있습니다. 저 메서드에 종속되지 않고 독립적인 기능같으면 private 메서드가 아닌 public으로 변경해야할지 고민해야합니다.

 

testable 과 non-testable

testable은 우리가 테스트 코드를 작성할 수 있는 영역을 말하고 non-testable은 테스트 코드를 작성할 수 없는 영역을 말합니다. 

non-testable 오염

만약에 아래와 같은 구조의 로직을 테스트 코드로 작성하려고 합니다. 하지만 가장 하위에서 non-testable의 영억을 호출하는 부분이 있다고 가정합니다. 그럼 이 테스트 코드는 전체가 non-testable이 됩니다.

non-testable의 영역의 간단한 예제로 외부 API와 통신하는 구간이 있다고 가정합니다. 

public class ActionService {
    
    private External external;
    
    public ActionService() {
        external = new External();
    }
    
    public String action() {
        String result = external.call();  // 외부 통신
        
        if (result.equals("1")) {
            return "Success";
        }
        
        return "Fail";
    }
}
class ActionServiceTest {

    @Test
    @DisplayName("non-testable 테스트 케이스")
    public void actionServiceTest() {
        ActionService actionService = new ActionService();

        String result = actionService.action();

        assertThat(result).isEqualTo("Success");
    }

}

테스트 코드를 작성하면 외부 API가 정상이라면 이 테스트 코드는 정상 결과가 나타납니다. 하지만 외부 API가 죽어있다면 테스트가 깨질 것 입니다. 이것을 non-testable이라고 합니다.

이런 non-testable 은 boundary layer영역까지 끌어올려서 테스트할 메서드와 동등한 레벨로 두어서 테스트를 진행합니다.

public class ActionService {

    private final External external;

    public ActionService(External external) {  // 생성자에서 주입
        this.external = external;
    }

    public String action() {
        String result = external.call();

        if (result.equals("1")) {
            return "Success";
        }

        return "Fail";
    }
}

생성자에서 non-testable 코드를 주입할 수 있도록 변경하여 mock처리된 클래스를 넘겨서 테스트 코드를 안정화시킬 수 있습니다.

 

테스트 코드 작성 방법

일반적으로 서비스는 @Component, @Bean으로 구현되있을 것입니다. 그럼 테스트 할 때 spring context를 올려주는 @SpringBootTest를 선언해야할까요? 결론은 아니다입니다. 이유를 알아보겠습니다.

속도

우리는 TDD 기법으로 코드를 작성하거나 이미 생성한 코드에 테스트 코드를 생성합니다. 즉 비즈니스 코드를 작성할 때마다 그에 따른 테스트 코드가 존재합니다. 그리고 실행합니다. 그 과정에서 속도가 느리다면 당연히 서비스 개발 속도는 늦어질테고 퍼포먼스도 낮아질 것입니다.

 

spring framework에 종속되지 않도록 작성해야합니다. 의존성을 주입할 때 @Autowired보단 생성자 인젝션을 이용하는 것을 추천드립니다.  

번외로 세미나 중 권용근님이 하신 말씀

언어의 본질을 망각할 수 있게 된다라고 말씀하셨습니다. 나는 자바 개발자인가? 스프링 프레임워크 개발자인가? @Autowired를 떼는 순간 필드 인젝션은 레거시 코드가 됩니다. 

주관적으로 이 부분은 이해가 되나 테스트 코드에서 어떻게 이해하고 받아들여야할 지 좀 더 고민해야할 부분이라고 생각됩니다.

설계엉망

설계적으로 엉망이되더라도 spring context를 사용하여 @MockBean, @Autowired를 남용해도 테스트 코드는 어찌어찌 돌아갈 것입니다. 과연 맞는 것 일까??

제 생각은 클래스 간 의존성이 엉키고 엉망이되어도 돌아가는 테스트 코드는 바람직하지 않다고 생각합니다. 객체 설계가 바르지 않기 때문에 수정해야한다고 생각합니다.

@MockBean과 @Autowired를 제외하고도 테스트 코드를 작성하기 힘들다면 비즈니스 로직이 너무 엉켜있는지 다시 확인해봐야하고 리팩토링 해야한다고 생각합니다.

 

TestDouble

non-testable한 대상에 대하여  테스트 할 수 있도록 도와줍니다. 

제가 가장 많이 쓰는 것은 Mockito입니다.

@MockBean, @SpyBean 같은 어노테이션들이 있어서 간단히 선언만 하고 사용할 수 있어 간단합니다. 하지만 위에서 언급한 설계가 엉망으로 갈 염려가 있어 양날에 검입니다. 사용할 때는 반드시 생각해보고 사용합니다!

 

그럼 어디에 사용하는 게 적합할까? 

순수하게 java 언어로만은 구현하기 힘든 영역들이 있습니다.

  • Controller end point
  • Repository Query
  • 외부 API

이러한 영역들을 테스트할 때 사용하면 좋습니다.

 

Embedded

Mokito처럼 가상적으로 사용할 수 있는 것을 말합니다.

 

먼저 서비스에서 다른 서비스와 통신하는 로직이 있다고 가정합니다. 이 로직에 대한 테스트 코드를 작성한다고 했을 때 가장 확실한 방법은 Local에 두 서비스를 띄워놓고 테스트하는 것입니다. 하지만 환경 구축, 시간 등 리소스 낭비가 너무 심합니다. 그리하여 Embedded로 작성하는 것을 선호합니다.

 

Controller End Point 

Controller Service Repository 로 대부분 설계가 되어있을 것입니다. 그럼 Controller의 End-Point를 테스트하려면 Service, Repository의 비즈니스 흐름을 전부 이해하고 테스트 코드를 작성하려면 난해한 경우가 많습니다. 

이 부분은 Controller의 요청 스팩과 응답 스팩만 검증하는 방법을 추천드립니다. 다시 말해서 Service 이후 로직은 목처리하여 신경쓰지 않고 Controller end point에 대한 스팩만 테스트합니다.

 

Spring Cloud Context

외부 Api를 사용할 때 유용합니다. 이 부분은 이 글에서 함께 다루기엔 너무 내용이 방대하여 추 후에 글을 작성하여 다시 올리도록 하겠습니다. 참고 영상 www.youtube.com/watch?v=7F27S81enVo&list=PLdHtZnJh1KdZnswQEyrn5VKxAXluSlWJc&index=6

 

 

Tip

테스트에서 공유되는 자원은 하나의 테스트 케이스가 끝날때마다 초기화 시켜서 독립적으로 실행될 수 있도록 해야합니다. 예를 들어 2개의 테스트에서 하나의 Repository에 save를 한다고 다른 테스트 케이스에서 조회했을 때 2개가 나오면 안됩니다.

@SpringBootTest
class CampTest {
    @Autowired
    static LottoRepository lottoRepository;

    @AfterEach
    static void afterEach() {
        lottoRepository.deleteAll();
    }
    
    @Test
    void test1() {
        Lotto lotto = Lotto.builder().number(1).name("대박 로또").build();

        lottoRepository.save(lotto);
    }

    @Test
    void test2() {
        Lotto lotto = Lotto.builder().number(2).name("대박 로또").build();

        lottoRepository.save(lotto);
    }

    @Test
    void test3() {
        List<Lotto> allCount = lottoRepository.findAll();
        assertThat(allCount).hasSize(0);  // 2개가 아닌 0개가 나와야한다.
    }
}
반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."