서적

[자바 웹 프로그래밍] 2장. 문자열 계산기 구현을 통한 테스트와 리팩토링

조명인 2023. 8. 7. 18:30

이 글은 박재성님의 [자바 웹 프로그래밍] 책의 2장 예제를 정리한 글입니다.

 

1. main() 메소드를 활용한 테스트의 문제점

프로덕션 코드와 테스트 코드(main() 메소드)가 같은 클래스에 위치하는 경우 서비스하는 시점에 같이 배포된다.

이것은 테스트 코드와 프로덕션 코드를 분리함으로써 해결할 수 있다.

 

2. main() 메소드 하나에서 여러 테스트를 실행하는 경우의 문제점

프로덕션 코드의 복잡도가 증가할수록 main() 메소드의 복잡도가 증가하며, main() 메소드를 유지하는 데에 힘이 들어간다.

이 경우 각 메소드별로 테스트 코드를 분리하는 방법으로 해결할 수 있다.

 

3. + 출력을 통해 테스트를 하는 경우의 문제점

로직의 복잡도가 높은 경우 일일이 출력값을 확인하기 힘들다.

JUnit 라이브러리를 통해 위 문제점들을 해결할 수 있다.

 

JUnit을 활용해 main() 메소드 문제점 극복

package calculator;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class StringCalculatorTest {

    private StringCalculator calculator;
    
    //각 테스트 전에 StringCalculator 객체 초기화
    @BeforeEach
    void runCalculator() {
        calculator = new StringCalculator();
    }

    @DisplayName("빈 문자열 또는 null 입력시 0 반환")
    @Test
    void test1() {
        assertEquals(0, calculator.add(""));
        assertEquals(0, calculator.add(null));
    }

    @DisplayName("숫자 하나를 입력받을 경우 그 숫자 반환")
    @Test
    void test2() {
        assertEquals(1, calculator.add("1"));
        assertEquals(13, calculator.add("13"));
    }

    @DisplayName("구분자로 ,이나 :일 경우 더한 값을 반환")
    @Test
    void test3() {
        assertEquals(3, calculator.add("1,2"));
        assertEquals(3, calculator.add("1:2"));
        assertEquals(6, calculator.add("1,2,3"));
        assertEquals(6, calculator.add("1:2:3"));
        assertEquals(6, calculator.add("1,2:3"));
    }

    @DisplayName("//와 /n 사이의 문자를 구분자로 사용할 수 있다. ,나 :를 포함한다")
    @Test
    void test4() {
        assertEquals(6, calculator.add("//m\n1m2m3"));
        assertEquals(3, calculator.add("//;\n1;2"));
        assertEquals(6, calculator.add("//;\n1;2;3"));
        assertEquals(6, calculator.add("//;\n1;2:3"));
    }

    @DisplayName("숫자가 음수일 경우 RuntimeException을 던진다")
    @Test
    void test5() {
        Assertions.assertThrows(RuntimeException.class, () -> {
            calculator.add("-1,2,3");
        });
    }
}

위와 같이 JUnit 라이브러리를 활용하면 전체 또는 각각의 테스트 메서드를 독립적으로 실행할 수 있으며, 실행 결과를 눈으로 확인하지 않아도 테스트할 수 있다.

 

문자열 계산기 요구사항

  • 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환한다.
  • 커스텀 구분자를 지정할 수 있다. 문자열 앞부분의 "//"와 "\n" 사이에 위치하는 문자를 커스텀 구분자로 사용할 수 있다.
  • 음수를 전달하는 경우 RuntimeException으로 예외처리한다.

추가 요구사항

  • 메소드가 한가지 책임만 가지도록 구현한다.
  • 인덴트 깊이를 1단계로 유지한다.
package calculator;

import java.util.ArrayList;
import java.util.List;

public class StringCalculator {
    int add(String text) {
        if (isBlank(text)) {
            return 0;
        }
        return getSum(toInts(split(text)));
    }

    private String[] split(String text) {
        String match = getMatch(text);
        text = checkCustomDelimAndParse(text);
        return text.split(match);
    }

    private String checkCustomDelimAndParse(String text) {
        String[] textSplits = text.split("\n");
        if (textSplits.length == 2) {
            return textSplits[1];
        }
        return text;
    }

    private boolean isBlank(String text) {
        return text == null || text.equals("");
    }

    private String getMatch(String text) {
        List<String> delimiters = new ArrayList<>();
        delimiters.add(",");
        delimiters.add(":");
        if (text.matches("//.\n.*")) {
            delimiters.add(String.valueOf(text.charAt(2)));
        }
        return makeRegex(delimiters);
    }

    private String makeRegex(List<String> delimiters) {
        String regex = "";
        for (String delim : delimiters) {
            regex += delim;
        }
        return String.format("[%s]", regex);
    }

    private List<Integer> toInts(String[] numberStrings) {
        List<Integer> numbers = new ArrayList<>();
        for (String numStr : numberStrings) {
            numbers.add(positiveOrZero(Integer.valueOf(numStr)));
        }
        return numbers;
    }

    private Integer positiveOrZero(Integer number) {
        if (number < 0) {
            throw new RuntimeException("numbers should not be negative");
        }
        return number;
    }

    private Integer getSum(List<Integer> numbers) {
        return numbers.stream()
                .reduce(Integer::sum)
                .get();
    }
}

구현 과정

  • 먼저 요구사항에 대한 테스트 코드들을 작성하였다.
  • 책의 힌트를 최대한 보지 않고 메서드들을 정의하고 구현하였다.
  • 책에서 진행하는 리팩토링의 방식대로 add 메소드를 리팩토링하였다.

느낀점

테스트 주도 개발과 리팩토링이 어떤 느낌인지 대략 알 수 있었다. 테스트를 정해놓고 구현을 시작하니 원래처럼 우다다 개발하는 일이 없이 단계적으로 살이 붙어가는 메서드 구현을 할 수 있었다. 마치 블럭을 쌓는 느낌이랄까.. 구현을 하는 과정에서 현재 어떤 기능을 개발하였고, 어떤 것을 더 해야 하는지 명확하게 인지한 상태에서 코딩을 할 수 있었다. 여기서 리팩토링을 통해 15라인 정도 되는 add() 메서드를 짧게 줄이는 과정을 맛볼 수 있었는데, 이 과정을 통해 제3자 또는 한달 후의 내가 읽고 금방 이해할 수 있는 코드를 만드는 것이 중요하다는 가르침을 얻을 수 있었다. 실제로 완성된 add 메서드를 읽을 때

  • 빈 문자면 0을 반환하는구나(isBlank)
  • 문자를 나누는구나(split)
  • 나눈 문자들을 숫자로 바꾸는구나(toInts)
  • 숫자들을 더하는구나(getSum)

이렇게 바로 메서드가 하는 일을 알아챌 수 있었다(물론 지금 내가 add 메서드가 무슨 일을 하는지 99% 알고 있는 상태라 그럴지도 모른다). 앞으로 어떤 기능을 구현할 때 코드를 처음 본 사람도 잘 이해할 수 있게 짜도록 노력해야겠다. 이 장을 진행하면서 JUnit, 리팩토링의 의의, 간단한 정규표현식 사용법 등을 알 수 있어서 유익한 시간이었다.