본문 바로가기
교육 및 책/TDD, 클린 코드 with Java

TDD, 클린 코드 - 문자열 덧셈 계산기

by oneny 2023. 4. 8.

2단계 - 문자열 덧셈 계산기

StringAddCalculator

class StringAddCalculator {
  private static final int ZERO = 0;
  private static final String RUNTIME_EXCEPTION_MESSAGE = "문자열 계산기에 숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 throw한다.";

  private static final String ZERO_TO_NONE_REGEX = "^[0-9]*$";
  private static final String DEFAULT_DELIMITER = "[,:]";
  private static final Pattern CUSTOM_DELIMITER = Pattern.compile("//(.)\n(.*)");

  static int splitAndSum(String text) {
    if (isNullOrEmpty(text)) return 0;

    return sum(toInts(customToStrings(text)));
  }

  static boolean isNullOrEmpty(String text) { // private 접근 제어자를 두자!
    return text == null || text.isEmpty();
  }

  static String[] toStrings(String text) { // toString과 기능은 다르지만 혼동될 여지가 있는 네이밍이다.
    return text.split(DEFAULT_DELIMITER);
  }

  static String[] customToStrings(String text) {
    Matcher m = CUSTOM_DELIMITER.matcher(text); // 축약어는 자제

    if (m.find()) {
      // 값을 하드코딩하기 보다는 상수로 관리하는 것이 유지보수에 좋다.
      // 상수(static final)를 만들고 이름을 부여하면 이 변수의 역할이 무엇인지 의도를 드러내기에도 좋다.
      String customDelimiter = m.group(1);

      return m.group(2).split(customDelimiter);
    }

    return toStrings(text); // 추상화 수준을 맞추자.
  }

  // 유효성 검증과 Integer를 추출하는 두 가지 기능을 하는 메서드로
  // 이럴 경우 이를 검증하는 테스트를 작성하더라도 유효성 검증에서 문제가 생기는지 parseInt에서 문제가 생기는지 디버깅 해야하는 이슈 발생
  // 메서드는 한 가지 일만 하는 것이 좋다.
  static int validatedNumber(String text) {
    if (!text.matches(ZERO_TO_NONE_REGEX)) {
      throw new RuntimeException(RUNTIME_EXCEPTION_MESSAGE);
    }

    return Integer.parseInt(text);
  }

  static int[] toInts(String[] strings) {
    int[] numbers = new int[strings.length];
    for (int i = 0; i < numbers.length; i++) {
      numbers[i] = validatedNumber(strings[i]);
    }

    return numbers;
  }

  static int sum(int[] numbers) {
    int total = ZERO;
    for (int number : numbers) {
      total += number;
    }

    return total;
  }
}

상수 활용과 추상화 수준 맞추기

private static String[] customToStrings(String text) {
  Matcher matcher = CUSTOM_DELIMITER_FORMAT.matcher(text);

  if (matcher.find()) {
    String customDelimiter = matcher.group(CUSTOM_DELIMITER);

    return matcher.group(OPERANDS).split(customDelimiter);
  }

  return text.split(DEFAULT_DELIMITER_FORMAT);
}

마지막 return하는 부분도 text.split(DEFAULT_DELIMITER_FORMAT);를 반환하여 추상화 수준을 맞췄다.
그리고 CUSTOM_DELIMITEROPERANDS를 상수로 만들어 어떤 것을 추출하려고 하는지 그 의도에 따라 이름을 부여했다.

Stream API 활용하여 메서드 분리

private static void validate(String text) {
  if (!text.matches(NUMBER_REGEX)) {
    throw new RuntimeException(RUNTIME_EXCEPTION_MESSAGE);
  }
}

private static List<Integer> toInts(String[] strings) {
  return Arrays.stream(strings)
          .peek(StringAddCalculator::validate)
          .map(Integer::parseInt)
          .collect(Collectors.toList());
}

private static int sum(List<Integer> numbers) {
  return numbers.stream()
          .mapToInt(Integer::intValue)
          .sum();
}

Stream API를 활용하여 한 가지 일만 할 수 있도록 메서드로 분리했다. 확실히 이전보다 가독성이 향상된 것 같다.

StringAddCalculatorTest

public class StringAddCalculatorTest {
  @ParameterizedTest
  @NullAndEmptySource
  @DisplayName("빈 문자열 또는 null 값 입력할 경우 0 반환 테스트")
  public void splitAndSum_null_또는_빈문자(String text) {
    int result = StringAddCalculator.splitAndSum(text);
    assertThat(result).isEqualTo(0);
  }

  @Test
  @DisplayName("숫자 하나만 입력하는 경우 해당 숫자 반환 테스트")
  public void splitAndSum_숫자하나() {
    int result = StringAddCalculator.splitAndSum("1");
    assertThat(result).isEqualTo(1);
  }

  @Test
  @DisplayName("숫자 두 개를 컴마(,) 구분자로 입력할 경우 두 숫자의 합 반환 테스트")
  public void splitAndSum_쉼표구분자() {
    int result = StringAddCalculator.splitAndSum("1,2");
    assertThat(result).isEqualTo(3);
  }

  @ParameterizedTest // @ParameterizedTest(name = "")를 사용
  @CsvSource(value = {"1,2=3", "1,2:3=6", "12:15,12=39"}, delimiter = '=')
  @DisplayName("숫자들을 컴마(,)와 콜론(:) 구분자로 입력할 경우 해당 숫자들의 합 반환 테스트")
  public void splitAndSum_쉼표_또는_콜론_구분자(String input, int expected) {
    int result = StringAddCalculator.splitAndSum(input);
    assertThat(result).isEqualTo(expected);
  }

  // 커스텀 구분자에 대한 단위테스트의 경우에도 ParameterizedTest를 활용하면 좋다.
  // 이 경우에는 MethodSource를 사용하면 수월하게 표현할 수 있다.
  @Test
  @DisplayName("//와 \n 문자 사이에 커스텀 구분자 지정 후 해당 숫자들의 합 반환 테스트")
  public void splitAndSum_custom_구분자() {
    int result = StringAddCalculator.splitAndSum("//;\n1;2;3");
    assertThat(result).isEqualTo(6);
  }

  @ParameterizedTest
  @ValueSource(strings = {"-1,2,3", "a:2,3", "1:k:3"}) //  ValueSource 사용한 것에 대해 따봉 받았다ㅎㅎ
  @DisplayName("숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외 throw 테스트")
  public void splitAndSum_negative(String input) throws Exception { // 불필요한 throws Exception 제거
    assertThatThrownBy(() -> StringAddCalculator.splitAndSum(input))
            .isInstanceOf(RuntimeException.class)
            .hasMessage("문자열 계산기에 숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 throw한다.");
  }
}

MethodSource 활용

@ParameterizedTest(name = "//와 \n 문자 사이에 커스텀 구분자 지정 후 해당 숫자들의 합 반환 테스트")\
@MethodSource("provideStrings_custom_구분자")
public void splitAndSum_custom_구분자(String input, int expected) {
  int result = StringAddCalculator.splitAndSum(input);
  assertThat(result).isEqualTo(expected);
}

private static Stream<Arguments> provideStrings_custom_구분자() {
  return Stream.of(
          Arguments.of("//;\n1;2;3", 6),
          Arguments.of("//=\n2=9=5", 16),
          Arguments.of("//!\n1!32!13", 46)
  );
}

MethodSource를 활용하여 다양한 값들을 테스트하도록 작성했다.

4월 6일 (목) 강의 후기

웹 애플리케이션의 백엔드 로직을 작성하는 데에 있어서 사용자에게 보여주기 위한 데이터는 크게 잡아야 1000개이고, 따라서 객체 지향과 가독성을 위해 메서드를 추가하여 for 문 한 번 더 도는 데는 큰 문제가 없다는 얘기를 들었다. 이러한 성능에는 로직보다는 쿼리 등의 다른 요소들이 많기 때문에 그쪽을 신경 쓰는 것이 좋다. 결론은 너무 성능에 초점을 맞춘 나머지 코드의 가독성을 무너뜨리지 않도록 조심하자.
단순히 TDD 수업인 줄 알았는데 그 이상으로 객체 지향적으로 설계하기 위한 사고를 만들어 주려는 커리큘럼과 수업이다. 벌써 만족 중!