Study/test

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

에디개발자 2021. 3. 9. 07:00
반응형

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

나를 닮았다고 한다...

 

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개가 나와야한다.
    }
}
반응형