Test 코드 작성

Always·2024년 9월 17일
0

Backend&Devops

목록 보기
1/15

개발자들에게 테스트 코드는 계륵과 같은 존재입니다. 작성을 안 하면, 실제 프로덕션을 할 때 안 돌아가는 경우가 생기고, 하나하나 세세한 부분을 나누어서 단위 테스트로써 테스트 코드를 나누자니, 시간이 오래걸립니다. 오늘은 이 테스트 코드를 활용하여, 개발하는 방식중 하나인 TddMockito에 대해서 공부해보는 시간을 가지겠습니다.


1.Tdd?

Tdd의 유래?

애자일 개발 프로세스는 기존의 모든 계획을 수립후,
개발에 들어가는 폭포수 방법론과는 달리,
개발 즉시 피드백을 받아서 수정해나가는 능동적인 프로세스입니다.
이 중 익스트림 프로그래밍은 이 프로세스를 원할히 할 수 있는 프로그래밍 방법론입니다.

  • tdd(Test-Driven Development)란 테스트 주도개발이라고 불립니다.
  • tdd는 켄트 벡(Kent Beck)의 익스트림 프로그래밍을 실천할 수 있는 방법중 하나로 제시되었습니다.

Tdd는 무엇인가?

  • xp에서 제공하는 tdd는 코드를 작성하기 이전에 '테스트 케이스를' 작성하고 테스트를 통과하기 위한 그에 맞는 기능을 작성합니다.
  • 테스트 중에 실패한 코드에 대해서는 수정해 나아가면서 '반복적인 테스트'를 통한 개발을 하는 작업을 의미합니다.
  • tdd는 기본적으로 아래와 같은 rgr 사이클을 가집니다.
  1. Red:테스트 코드를 작성하는 단계입니다.
  2. Green: Red에서 짠 테스트 코드를 성공시키기 위해, 프로덕션 코드를 수정하는 단계입니다.
  3. Refactor:테스트 코드가 성공한 프로덕션 코드를 보다 나은 품질의 코드로 리팩토링 하는 과정입니다.

Tdd를 그러면 왜 쓰는거야?

  • 리팩토링에 대한 두려움을 줄여준다고 합니다
    • 기존에 잘 동작하던 코드를 바꾸려고 하면 두려워지고 안하고 싶은 마음 다들 아시죠?
    • 그런데, 테스트 코드를 먼저 작성함으로써, Red단계에서 시작을 하므로, 새로운 기능 도입에 따라서 기존의 코드를 리팩토링 할 때의 위의 두려움이 줄어듭니다
  • 디버깅하는 시간을 줄여줍니다.
    • Test코드를 먼저 작성함으로써,디버깅 할 때의 시간이 현저하게 줄어듭니다
  • Test코드가 동작하는 문서 역할을 합니다.
    • 개발을 하기전에 머리속으로 구상하는 설계를 코드상으로 구현하여 문서역할을 합니다.

그렇다면 한 번 실제로 Tdd를 적용해보고, 장단점을 비교해볼까요?


2. Junit5내에서의 Tdd

https://github.com/Munhangyeol/Tdd

테스트 시나리오

  • 단순하게 숫자 두개, +,-,x,/를 입력받았을 때, 해당하는 연산을 하는 계산 기능을 구현할 것입니다.
  • +,-,x,/순으로 tdd의 rgr과정을 따를 것입니다.

더하기 기능 구현

@SpringBootTest
public class CalculateTest {
    @Autowired
    public CalculateService calculateService;
    private double x1;
    private double x2;
    @Test
    public void plusTest(){
        //given
        x1=random();
        x2=random();
        //when,then
       Assertions.assertEquals(calculateService.calculate(x1, x2, "+"),x1+x2);
    }
}
  • 처음에 위와 같이 + 기능을 검증하는 테스트 코드를 짜줍니다.
  • 이 때의 테스트 코드는 맨처음의 red 상태입니다.
package tdd.tdd;

import org.springframework.stereotype.Service;

@Service
public class CalculateService {

    public double calulate(double x1,double x2,String operater){
        return x1+x2;
    }
}
  • 그 후 위처럼 테스트 코드를 돌아가는 상태인 green 상태로 만들게끔 코드를 짜줍니다.
  public double calulate(double x1,double x2,String operater){
        if(operater.equals("+"))
            return x1+x2;
        return 0;
    }
  • 그 후에는 연산자가 +일 때 더한값을 출력하도록 re-factor 상태를 만들어줍니다.

빼기 기능 구현

@Test
    public void minusTest(){
       //given
        x1=random();
        x2=random();
        //when,then
        Assertions.assertEquals(calculateService.calculate(x1, x2, "-"),x1-x2);
    }
  • 위처럼 -상황에서 돌아가는 테스트 코드를 작성한다(red)
package tdd.tdd;

import org.springframework.stereotype.Service;

@Service
public class CalculateService {

    public double calculate(double x1,double x2,String operater){
        if(operater.equals("+"))
            return x1+x2;
        if(operater.equals("-"))
            return x1 - x2;
        return 0;
    }
}
  • 빼기 기능의 테스트 코드가 돌아가게끔 코드를 수정함(green)
package tdd.tdd;

import org.springframework.stereotype.Service;

@Service
public class CalculateService {

    public double calculate(double x1,double x2,String operater){
        switch (operater) {
            case "+":
                return x1+x2;
            case "-":
                return x1-x2;
            default:
                return 0;
        }
      
    }
}
  • 위처럼 switch-case문으로 코드를 리팩토링 할 수 있습니다. refactor

곱하기 기능 구현

@Test
    public void multipeTest(){
        //given
        x1=random();
        x2=random();
        //when,then
        Assertions.assertEquals(calculateService.calculate(x1, x2, "x"),x1*x2);
    }
  • 곱하기 red
@Service
public class CalculateService {

    public double calculate(double x1,double x2,String operater){
        switch (operater) {
            case "+":
                return x1+x2;
            case "-":
                return x1-x2;
            case "x":
                return x1*x2;
            default:
                return 0;
        }

    }
}
  • 곱하기 green
   public double calculate(double x1,double x2,String operater){
        return switch (operater) {
            case "+" -> x1 + x2;
            case "-" -> x1 - x2;
            case "x" -> x1 * x2;
            default -> 0;
        };
    }
  • 곱하기 re-factor

나누기 기능 구현

 @Test
    public void divideTest(){
        //given
        x1=random();
        x2=random();
        //when,then
        Assertions.assertEquals(calculateService.calculate(x1, x2, "/"),x1/x2);
    }
  • 나누기 기능 red
 public double calculate(double x1,double x2,String operater){
        return switch (operater) {
            case "+" -> x1 + x2;
            case "-" -> x1 - x2;
            case "x" -> x1 * x2;
            case "/"->x1/x2;
            default -> 0;
        };
    }
  • 나누기 기능 green
 package tdd.tdd;

import org.springframework.stereotype.Service;

@Service
public class CalculateService {

    public double calculate(double x1,double x2,String operater){
        return switch (operater) {
            case "+" -> plus(x1, x2);
            case "-" -> minus(x1,x2);
            case "x" -> multiple(x1,x2);
            case "/"->divide(x1,x2);
            default -> throw new IllegalStateException("Unexpected value: " + operater);
        };
    }
    public double plus(double x1,double x2){
        return x1 + x2;
    }
    public double minus(double x1,double x2){
        return x1 - x2;
    }
    public double multiple(double x1,double x2){
        return x1 * x2;
    }
    public double divide(double x1,double x2){
        return x1 / x2;
    }
}
  • 나누기 refactor

3. 테스트 시나리오 적용후 tdd에 대한 간단한 생각

지루함

  • 한 기능을 도입 할 때마다 red-green-refactor패턴을 적용하는건 위와같은 상황에서는 꽤나 지루한 일이었습니다.
  • 켄트 백은 TDD는 불안함을 지루함으로 바꾸는 마법의 돌이라고 했듯이 반대로 말하면 이 지루함이란것은 코드가 돌아간다는 확신이 생긴다는 말과 같다고 느껴졌습니다.
  • 이 확신은 다음 기능을 구현 할 때, 심리적으로 편안함을 느끼게 합니다

코드 최적화

  • 즉, tdd는 코드가 지닌 불안정성을 줄여주는 일종의 디버그로부터 방파제 역할을 해줍니다.
  • 또한 한 기능이 도입 될 때마다 리펙토링을 하므로, 항상 클린 코드를 유지 할 가능성을 높입니다.
  • tdd는 개발을 할 때 스텝을 잘게 나누어, 항상 최적화된 코드를 유지하게 해줍니다

4. Tdd의 한계 및 Tdd를 적용하면 좋을 것 같은 코드

Tdd의 한계

  • 위처럼 너무 명확한 답을 내는 코드 내에서는 굳이 필요할까라는 생각이 들었습니다.
  • 기본적으로 시간이 오래 걸리기 때문입니다.
  • 그래서 제가 생각하기에 Tdd를 실행하면 좋을 것 같은 코드는 해당 기능이 될지 안 될지 불명확,불안정 할 때, 즉 디버깅이 많이 필요할 때라고 생각합니다.

Tdd를 적용하면 좋을 거 같은 코드

  • 예를 들어 아래와 같은 코드가 있습니다.
@Slf4j
@Component
public class InfreanVideoCrawling {

    public ArrayList<InfreanVideoDTO> crawlingInfreanVideo(String teck_stack){
        WebDriver driver = getWebDriver(teck_stack);
        ArrayList result = new ArrayList<InfreanVideoDTO>();
        try {
            //WebElement 추출
            List<WebElement> lectures = getLectures(driver);
            // 각 강의 요소를 순회하며 데이터 추출
            for (WebElement lecture : lectures) {
                if (result.size() >= 12) {
                    break;
                }
                try {
                    String thumbnailUrl = getUrl(lecture, "div.mantine-AspectRatio-root img", "src", "No image");
                    // 강의 URL 추출
                    String lectureUrl = getUrl(lecture, "a", "href", "No URL");
                    // 강의 제목 추출
                    String title = getTitle(lecture.getText());
                    // 가격 추출
                    String price=getPrice(lecture.getText());
                    // 결과 출력
                    log.info("InfreanCrawling result: " + "thumnail: " + thumbnailUrl + " url: " + lectureUrl +
                            " title: " + title + " price: " + price);
                    InfreanVideoDTO infreanVideoDTO = new InfreanVideoDTO(lectureUrl,title,thumbnailUrl,Long.valueOf(price.replaceAll("[^\\d]", "")));
                    result.add(infreanVideoDTO);
                } catch (org.openqa.selenium.NoSuchElementException e) {
                    log.info("Element not found in this lecture element: " + e.getMessage());
                }
            }
        } catch (Exception e) {
            log.error("An unexpected error occurred: " + e.getMessage(), e);
        }
        driver.quit();
        return result;
    }

    private String getUrl(WebElement lecture, String cssSelector, String src, String x) {
        // 썸네일 이미지 URL 추출
        WebElement thumbnailElement = lecture.findElement(
                By.cssSelector(cssSelector));
        String thumbnailUrl = thumbnailElement != null ? thumbnailElement.getAttribute(src) : x;
        return thumbnailUrl;
    }
    private String getPrice(String lectureText) {
        // 가격은 "원"이 마지막에 있고, 숫자로 변환했을 때, 숫자 인것만 반환함
        String[] lines = lectureText.split("\n");
        for (String line : lines) {
            if (line.trim().endsWith("원")&&line.replaceAll("[^\\d]", "")!="") {
                return line.replaceAll("[^\\d]", "");
            }
        }
        return "0";
    }
    private String getTitle(String lectureText){
        String[] lines = lectureText.split("\n");
        for (String line : lines) {
            if (line.charAt(0) == '할' && line.charAt(1) == '인') {
                return lines[1];
            } else {
                return line;
            }
        }
        return "0";
    }

    private List<WebElement> getLectures(WebDriver driver) {
        // 페이지가 로드될 때까지 잠시 대기 (필요에 따라 조정)
        WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
            wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("ul.css-y21pja li.mantine-1avyp1d")));
            // 강의 목록 요소를 선택
            List<WebElement> lectures = driver.findElements(By.cssSelector("ul.css-y21pja li.mantine-1avyp1d"));

            return lectures;
    }

    public WebDriver getWebDriver(String teck_stack) {
       WebDriverManager.chromedriver().setup();
//        System.setProperty("chrome.driver", "/chromedriver.exe");
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless");
        options.addArguments("--no-sandbox");
        options.addArguments("--disable-dev-shm-usage");
        WebDriver driver = new ChromeDriver(options);

        /*
        중략......
        /*
        driver.get(INFREAN_CRAWRLING_URL_SEARCH+teck_stack+"&types=ONLINE");
        return driver;
    }

}
  • 해당 코드는 크롤링을 해서 정보를 db에 저장하는 코드로 크게 webdriver연동→WebElement 추출→각 강의 요소를 순회하며 데이터 추출→ webdriver close로 나눌 수 있습니다.
  • 하지만 각 강의 요소에는 url,thumnail,title,price를 가지고 오며, 이를 크롤링 해올 때 못가져오는 값도 존재하므로, 전체 단계로 보면 디버깅을 해야하는 상황이 많이 발생할 가능성이 있습니다.
  • 또한 크롤링하는 프로세스는 크롤링 하는 사이트의 변화에 따라 달라 지므로, 이에 대한 테스트 코드를 세세하게 작성을 해두는 것이 유지,보수관점에서 좋습니다.
  • 즉 이럴 때는 각 파트에 대한 디버깅보다는, 각 단계마다 step을 작게 가져가면서, tdd를 적용해보는 것이 클린코드 관점에서도 그렇고 시간적인 측면에서도 옳다는 것이 제 생각입니다.

5. Mockito를 활용한 test코드 작성

Mockito란?

  • mock을 한국어로 번역하면 가짜의,모의의라는 뜻입니다.
  • 즉, 테스트시에 실제의 객체가 아닌 가짜객체를 넣어서, 테스트의 효용성을 높인 것입니다.

가짜 객체?

  • 기본적으로 실제 객체를 흉내낸 객체로, 안의 모든 값들은 null로 구성되어있습니다.
  • 실제 객체가 아니기에 Springboot에서 제공하는 Dependency Injection이 일어나지 않습니다.
  • 이 덕분에 Controller,Service,Repository단에서의 독립적인 테스트 코드 작성이 가능해집니다.
  • 또한, 실제 api연동이 아닌, 가짜 객체를 통하여, 테스트가 가능하여 훨씬 빠른 테스트 코드 작성이 가능합니다
  • 아래처럼 controller의 테스트를 할 때 반드시 service객체를 넣어주는 것이 아닌 가짜 객체를 넣어줌으로써 훨씬 빠르게 작동이 가능하게 할 수 있습니다


6.예시를 통한 Mockito

  • 아래는 videoService클래스 입니다.
 private final VideoRepository videoRepository;
    private final YoutubeVideoFetcher youtubeVideoFetcher;
    private final UdemyVideoFetcher udemyVideoFetcher;
    private final InfreanVideoFetcher infreanVideoFetcher;
    private final TechnologyStackRepository technologyStackRepository;

    public VideoService(VideoRepository videoRepository, YoutubeVideoFetcher youtubeVideoFetcher,
                        UdemyVideoFetcher udemyVideoFetcher, InfreanVideoFetcher infreanVideoFetcher, TechnologyStackRepository technologyStackRepository) {
        this.videoRepository = videoRepository;
        this.youtubeVideoFetcher = youtubeVideoFetcher;
        this.udemyVideoFetcher = udemyVideoFetcher;
        this.infreanVideoFetcher = infreanVideoFetcher;
        this.technologyStackRepository = technologyStackRepository;
    }

@ExtendWith(MockitoExtension.class)
public class VideoServiceTest {
    @InjectMocks
    private VideoService videoService;
    @Mock
    private VideoRepository videoRepository;
    @Mock
    private RestTemplate restTemplate;
    @Mock
    private YoutubeVideoFetcher youtubeVideoFetcher;
    @Mock
    private UdemyVideoFetcher udemyVideoFetcher;

    @BeforeEach
    public void setUp() {
    }

    @DisplayName("가짜 유튜브 비디오 객체를 생성하여,Fetch하고 Save하는 과정이 13번 진행되나 테스트한다.")
    @Test
    public void testFetchAndSaveYoutubeVideos() {
        // Given
        YouTubeApiResponse response = getMockYouTubeApiResponse();
        when(youtubeVideoFetcher.fetchYoutubeVideos(anyString())).thenReturn(response);
     //When
        videoService.fetchAndSaveYoutubeVideos();
        // Then
        //Tehcnology stack name이 총 13개임으로 13번 호출 되는 것이 맞음.
        verify(videoRepository, times(13)).save(any());
    }

    //가짜 YoububeApiResponse를 가져온다.
    private YouTubeApiResponse getMockYouTubeApiResponse() {
        Item item = new Item();
        setYoutubeItem(item);
        YouTubeApiResponse response = new YouTubeApiResponse();
        response.setItems(Collections.singletonList(item));
        return response;
    }
    private YouTubeApiResponse getMockYouTubeApiResponseWithVideoIdIsNull() {
//        ReflectionTestUtils.setField(videoService, "youtubeApiKey", "fakeApiKey");
        Item item = new Item();
        setYoutubeItemWithVideoIdIsNull(item);
        YouTubeApiResponse response = new YouTubeApiResponse();
        response.setItems(Collections.singletonList(item));
        return response;
    }
    

    //가짜 YoububeItem을 생성한다.
    private Item setYoutubeItem(Item item) {
        Id id = new Id();
        id.setVideoId("video123");
        item.setId(id);
        Snippet snippet = new Snippet();
        snippet.setTitle("Sample Video");
        Thumbnails thumbnails = new Thumbnails();
        Thumbnail thumbnail = new Thumbnail();
        thumbnail.setUrl("Sample Thumnail");
        thumbnails.setDefault(thumbnail);
        snippet.setThumbnails(thumbnails);
        item.setSnippet(snippet);
        return item;
    }

    //videoId가 없는 가짜 YoububeItem을 생성한다.
    private Item setYoutubeItemWithVideoIdIsNull(Item item) {

        Snippet snippet = new Snippet();
        snippet.setTitle("Sample Video");
        Thumbnails thumbnails = new Thumbnails();
        Thumbnail thumbnail = new Thumbnail();
        thumbnail.setUrl("Sample Thumnail");
        thumbnails.setDefault(thumbnail);
        snippet.setThumbnails(thumbnails);
        item.setSnippet(snippet);
        return item;
    }
  
}
  • 기본적으로 위처럼 @Mock을 통해서 Mock 객체를 생성할 수 있습니다.
  • @InjectMocks은 기본적으로 가짜 객체가 아닌, 실제 객체를 생성하고, 그 후 의존성을 mock객체를 이용해서 받는 것을 의미합니다.
  • 위의 코드에서는 videoRepository,restTemplate,youtubeVideoFetcher,udemyVideoFetcher가 mock으로 정의 되어 있고, 이 가짜 객체들이 videoservice내에 mock으로써 주입 됩니다.
  • @InjectMocks를 통해서 주입 할 때 mock 객체가 만약 없으면 null이 주입되고, 그 이후에 사용시에 오류가 납니다.(생성시에는 오류안남)
  • when(youtubeVideoFetcher.fetchYoutubeVideos(anyString())).thenReturn(response)when문을 통해서, 해당 mock객체의 return값을 위와같이 제어할 수 있습니다.(given도 있으나, 비슷한 기능을 합니다.
  • verify(videoRepository, times(13)).save(any())verify를 통해서 실제test가 잘 되었는지를 확인(then)

MockBean?

  • Mocked가 가짜 객체를 생성해주는 것이라면, @MockedBean가짜 빈 객체가 진짜 빈 객체를 교체해주는 것으로, 이 가짜 빈 객체를 생성함으로써 실제 DI가 될 때 이 가짜 빈객체가 대신 들어 갑니다.

위의 InjectMock과 비슷한 역할을 할것 같은데 무슨차이일까요?

InjectedMock vs MockBean

  • MockBean은 Spring Boot에서 제공하는 어노테이션으로 spring bean의 라이프사이클을 고려한 통합 테스트 코드를 작성 할 수 있습니다.
  • InjectedMock은 Mockito에서 제공하고, mock 빈이 아닌 객체를 주입하기에, bean의 라이프 사이클을 고려하지않은 unit test작성에 용이하다고 합니다.

7. Mockito는 언제 써야 할까

  • 기본적으로 test코드를 작성 할 때, 훨씬 빠르게 동작할 때 사용하는 것이 용이합니다.
  • 독립적으로 동작하는 테스트 코드를 작성하고 싶을 때 쓰는 것이 좋습니다.
  • Api호출과 같은 cost가 많은 동작을 다룰 때, 사용하는 것이 좋습니다.

결론

TDD코드의 품질을 향상시키고 리팩토링에 대한 두려움을 줄여주는 강력한 개발 방식입니다. 반복적인 테스트와 리팩토링 과정을 통해 신뢰성 있는 코드를 유지할 수 있으며, 테스트 코드 자체가 문서 역할을 하여 유지보수에도 도움이 됩니다. 하지만 TDD는 단순하거나 명확한 로직에서는 다소 지루하고 시간이 오래 걸릴 수 있습니다. 따라서 TDD는 불확실하거나 디버깅이 많이 필요한 복잡한 기능 구현에 특히 유용합니다.

Mockito는 이러한 TDD와 결합하여 독립적인 테스트 환경을 제공해, 의존성이나 외부 시스템과 무관하게 특정 로직만을 검증할 수 있도록 해줍니다. 이를 통해 빠르고 효율적인 테스트가 가능하며, 특히 API 호출 등 외부 연동이 포함된 부분에서 큰 이점을 제공합니다.

따라서 TDD와 Mockito를 적절히 활용하면 안정적이고 확장 가능한 코드를 지속적으로 유지할 수 있습니다.

참고 자료

https://www.youtube.com/watch?v=n01foM9tsRo&t=1053s
https://tech.kakaopay.com/post/implementing-tdd-in-practical-applications/
https://www.youtube.com/watch?v=3LMmPXoGI9Q&t=362s

profile
🐶개발 블로그

0개의 댓글