[자바 웹 프로그래밍] 2장. 문자열 계산기 구현을 통한 테스트와 리팩토링
이 글은 박재성님의 [자바 웹 프로그래밍] 책의 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, 리팩토링의 의의, 간단한 정규표현식 사용법 등을 알 수 있어서 유익한 시간이었다.