기능 요구사항
- 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환 (예: “” => 0, "1,2" => 3, "1,2,3" => 6, “1,2:3” => 6)
- 앞의 기본 구분자(쉼표, 콜론)외에 커스텀 구분자를 지정할 수 있다. 커스텀 구분자는 문자열 앞부분의 “//”와 “\n” 사이에 위치하는 문자를 커스텀 구분자로 사용한다. 예를 들어 “//;\n1;2;3”과 같이 값을 입력할 경우 커스텀 구분자는 세미콜론(;)이며, 결과 값은 6이 반환되어야 한다.
- 문자열 계산기에 숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 throw한다.
📌 Step 1 : Todo 리스트 작성
Todo
- 문자열 입력 값에 대한 유효성 검증
- 숫자 이외의 문자열이 있을 경우 예외가 발생한다.
- 음수가 있는 경우 예외가 발생한다
- 파싱된 숫자들을 더한다.
- 문자열을 파싱한다.
- 쉼표, 콜론은 기본 구분자로 문자열을 나눌 수 있다.
- 앞의 기본 구분자를 제외하고선 커스텀 구분자를 정할 수 있다.
Root 클래스인 CalculatedString 클래스를 먼저 생각합니다. CalculatedString 클래스는 계산된 값을 가지고 있는 클래스로, 계산하는 역할을 담당한다. 하지만 루트 클래스이므로 아직 테스트를 하거나 구현하지 않습니다. 왜냐하면 바텀-업으로 가기 위함입니다.
어떤 테스트를 먼저 할 것이냐에 대한 질문의 답은 가장 테스트하기 쉬워 보이는 것을 고르면 됩니다. 제가 볼 땐 문자열 유효성 검증이 가장 쉬워 보입니다. 하지만 Todo 목록에 보면 "문자열을 파싱한다." 라는 말이 있습니다. 파싱을 할 때 유효성 검증도 같이하는게 어떨까합니다. 그러니 todo 리스트를 수정하겠습니다.
Todo (수정 전)
문자열 입력 값에 대한 유효성 검증
숫자 이외의 문자열이 있을 경우 예외가 발생한다.음수가 있는 경우 예외가 발생한다
- 파싱된 숫자들을 더한다.
- 문자열을 파싱한다.
- 쉼표, 콜론은 기본 구분자로 문자열을 나눌 수 있다.
- 앞의 기본 구분자를 제외하고선 커스텀 구분자를 정할 수 있다.
Todo (수정 후)
- 파싱된 숫자들을 더한다.
- 문자열을 파싱한다.
- 쉼표, 콜론은 기본 구분자로 문자열을 나눌 수 있다.
- 앞의 기본 구분자를 제외하고선 커스텀 구분자를 정할 수 있다.
- 문자열 입력 값에 대한 유효성 검증
- 숫자 이외의 문자열이 있을 경우 예외가 발생한다.
- 음수가 있는 경우 예외가 발생한다
그래서 "문자열 유효성 검증"을 "문자열을 파싱한다."의 하위 기능 목록으로 수정합니다.
그러면 큰 기능은 "파싱된 숫자를 더한다.", "문자열을 파싱한다" 이렇게 2가지 입니다. 그 중에서 더 쉬워보이는건 "문자열을 파싱한다" 입니다. 그렇기 때문에 문자열을 파싱의 책임을 가지고 있는 객체를 테스트하겠습니다.
📌 Step 2 : 음수에 대한 유효성 검증
문자열 파싱의 책임을 가지고 있는 객체 이름은 뭐라고 하면 좋을까요? 표현식이라는 의미로 "Expression"은 어떨까요? 근데 문자열 파싱의 책임을 가지고 있기 때문에 "ParsedString"가 더 좋아보입니다.
@Test
@DisplayName("문자열에 음수가 있는 경우 예외가 발생한다")
void a() {
String inputValue;
Parsed sut = new ParsedString(inputValue);
Assertions.assertThatThrownBy(() -> sut.parsedValue())
.isInstanceOf(RuntimeException.class)
.hasMessage("음수를 입력할 수 없습니다.");
}
해당 테스틑 문자열 입력값에 대한 음수 검증 테스트입니다. 유효성 검증을 생성자에서 하지 않고 메소드를 호출할 때 유효성 검증을 합니다. 저는 생성자에 유효성 검증을 하지 않는 이유는 생성자에 로직이 들어가면 유지 보수가 어렵기 때문입니다.
이제 해당 테스트에 대한 구현을 하도록 하겠습니다. 먼저. 인터페이스를 정의합니다.
public interface Parsed {
List<String> parsedValue();
}
그리고 인터페이스에 대한 구현체를 만듭니다.
public class ParsedString implements Parsed {
private static final Pattern NEGATIVE_JUDGMENT = Pattern.compile(".*-[0-9].*");
private final String stringToBeParsed;
public ParsedString(String stringToBeParsed) {
this.stringToBeParsed = stringToBeParsed;
}
@Override
public List<String> parsedValue() {
verifyNegative();
return null;
}
private void verifyNegative() {
if (NEGATIVE_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("음수를 입력할 수 없습니다.");
}
}
}
📌 Step 3 : 숫자 이외의 문자에 대한 대한 유효성 검증
이제 음수에 대한 유효성 검증을 끝냈으니 숫자 이외의 문자에 대한 유효성 검증을 하면 됩니다.
@Test
@DisplayName("숫자 이외의 문자에 대한 유효성 검증")
void b() {
String stringToBeParsed = "*";
Parsed sut = new ParsedString(stringToBeParsed);
Assertions.assertThatThrownBy(() -> sut.parsedValue())
.isInstanceOf(RuntimeException.class)
.hasMessage("숫자 이외의 문자를 입력할 수 없습니다.");
}
테스트에 대한 구현을 합니다.
public class ParsedString implements Parsed {
private static final Pattern NEGATIVE_JUDGMENT = Pattern.compile(".*-[0-9].*");
private static final Pattern NUMBER_JUDGMENT = Pattern.compile(".*[0-9].*");
private final String stringToBeParsed;
public ParsedString(String stringToBeParsed) {
this.stringToBeParsed = stringToBeParsed;
}
@Override
public List<String> parsedValue() {
verifyNegative();
verifyNumber();
return null;
}
private void verifyNegative() {
if (NEGATIVE_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("음수를 입력할 수 없습니다.");
}
}
private void verifyNumber() {
if (!NUMBER_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("숫자 이외의 문자를 입력할 수 없습니다.");
}
}
}
📌 Step 4 : 쉼표, 콜론은 기본 구분자로 문자열을 나눌 수 있다.
문자열에 대한 유효성 검증은 다 했습니다. 이제 todo 리스트의 "문자열을 파싱한다."의 세부 기능 중 2개가 남아있습니다.
"기본 구문자로 문자열 나누기", "커스텀 구문자로 문자열 나누기" 이 2개 중에서 더 쉬워보이는 "기본 문자열로 문자열 나누기"를 테스트하도록 하겠습니다.
@ValueSource(strings = {
"1,2",
"1:2"
})
@ParameterizedTest
@DisplayName("쉼표(,)와 콜론(:)을 구분자를 가지는 문자열 일 경우 구분자로 기준으로 분리된 숫자들을 반환한다")
void c(String stringToBeParsed) {
Parsed sut = new ParsedString(stringToBeParsed);
List<String> parsedValue = sut.parsedValue();
Assertions.assertThat(parsedValue).containsExactly("1", "2");
}
해당 테스트를 구현합니다.
public class ParsedString implements Parsed {
private static final Pattern NEGATIVE_JUDGMENT = Pattern.compile(".*-[0-9].*");
private static final Pattern NUMBER_JUDGMENT = Pattern.compile(".*[0-9].*");
private static final String DEFAULT_DELIMITER_REGEX = "[,:]";
private final String stringToBeParsed;
public ParsedString(String stringToBeParsed) {
this.stringToBeParsed = stringToBeParsed;
}
@Override
public List<String> parsedValue() {
verifyNegative();
verifyNumber();
return Arrays.stream(stringToBeParsed.split(DEFAULT_DELIMITER_REGEX))
.collect(toList());
}
private void verifyNegative() {
if (NEGATIVE_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("음수를 입력할 수 없습니다.");
}
}
private void verifyNumber() {
if (!NUMBER_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("숫자 이외의 문자를 입력할 수 없습니다.");
}
}
}
📌 Step 5 : 커스텀 기본 구분자로 문자열을 나눌 수 있다.
현재 유효성 검사과 기본 구분자로 문자열을 나누는 부분까지 마쳤습니다. 여기서 커스텀 문자열을 나누는 부분까지 하면 ParsedString 클래스가 너무 커지는 것 같아서 기본 구분자로 문자열을 나누는 ParsedStringAsDefaultDelimeter 클래스와 ParsedStringAsCustomDelimeter 클래스로 나눠 전략패턴을 사용할까 생각했습니다. 일단은 귀찮으니 염두만 해두도록 하겠습니다.
@Test
@DisplayName("문자열 첫 부분에 // 와 \n 사이에 문자가 있을 경우 해당 문자를 구분자로 문자열을 나눌 수 있다.")
void d() {
String stringToBeParsed = "//;\n1;2";
Parsed sut = new ParsedString(stringToBeParsed);
List<String> parsedValue = sut.parsedValue();
Assertions.assertThat(parsedValue).containsExactly("1", "2");
}
public class ParsedString implements Parsed {
private static final Pattern NEGATIVE_JUDGMENT = Pattern.compile(".*-[0-9].*");
private static final Pattern NUMBER_JUDGMENT = Pattern.compile(".*[0-9].*");
private static final Pattern CUSTOM_DELIMITER_JUDGMENT = Pattern.compile("^(//.\\n)");
private final String stringToBeParsed;
public ParsedString(String stringToBeParsed) {
this.stringToBeParsed = stringToBeParsed;
}
@Override
public List<String> parsedValue() {
verifyNegative();
verifyNumber();
if (CUSTOM_DELIMITER_JUDGMENT.matcher(stringToBeParsed).find()) {
final String customDelimiter = customDelimiter();
final String stringParsedIntro = stringParsedIntro();
return Arrays.stream(stringParsedIntro.split(customDelimiter))
.collect(toList());
}
final String defaultDelimiterRegex = "[,:]";
return Arrays.stream(stringToBeParsed.split(defaultDelimiterRegex))
.collect(toList());
}
private void verifyNegative() {
if (NEGATIVE_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("음수를 입력할 수 없습니다.");
}
}
private void verifyNumber() {
if (!NUMBER_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("숫자 이외의 문자를 입력할 수 없습니다.");
}
}
private String customDelimiter() {
final int customDelimiterIndex = 2;
return String.valueOf(stringToBeParsed.charAt(customDelimiterIndex));
}
private String stringParsedIntro() {
final String introToBeParsed = "//.\n";
final int introLength = introToBeParsed.length();
return stringToBeParsed.substring(introLength);
}
}
parsedValue() 메소드 내 로직을 보면 구분자를 기본 구분자로 쓸 때와 커스텀 구분자로 쓸 때 이렇게 분기 처리를 해야합니다. 이부분은 전략 패턴으로 극복할 수 있을 것으로 보입니다.
ParedStringByCustomDelimiterTest 클래스를 만들고 기존 ParsedString 클래스의 테스트들을 옮겨왔습니다.
class ParedStringByCustomDelimiterTest {
@Test
@DisplayName("문자열에 음수가 있는 경우 예외가 발생한다")
void a() {
String stringToBeParsed = "//-1\n";
Parsed sut = new ParsedStringByDefaultDelimiter(stringToBeParsed);
Assertions.assertThatThrownBy(() -> sut.parsedValue())
.isInstanceOf(RuntimeException.class)
.hasMessage("음수를 입력할 수 없습니다.");
}
@Test
@DisplayName("문자열 첫 부분에 // 와 \n 사이에 문자가 있을 경우 해당 문자를 구분자로 문자열을 나눌 수 있다.")
void b() {
String stringToBeParsed = "//;\n1;2";
Parsed sut = new ParedStringByCustomDelimiter(stringToBeParsed);
List<String> parsedValue = sut.parsedValue();
Assertions.assertThat(parsedValue).containsExactly("1", "2");
}
}
테스트에 대한 구현입니다.
public class ParedStringByCustomDelimiter implements Parsed {
private static final Pattern NEGATIVE_JUDGMENT = Pattern.compile("(//.*\n)?.*-[0-9].*");
private static final int CUSTOM_DELIMITER_INDEX = 2;
private static final String INTRO_TO_BE_PARSED = "//.\n";
private final String stringToBeParsed;
public ParedStringByCustomDelimiter(String stringToBeParsed) {
this.stringToBeParsed = stringToBeParsed;
}
@Override
public List<String> parsedValue() {
verifyNegative();
final String customDelimiter = customDelimiter();
final String stringParsedIntro = stringParsedIntro();
return Arrays.stream(stringParsedIntro.split(customDelimiter))
.collect(toList());
}
private void verifyNegative() {
if (NEGATIVE_JUDGMENT.matcher(stringToBeParsed).find()) {
throw new RuntimeException("음수를 입력할 수 없습니다.");
}
}
private String customDelimiter() {
return String.valueOf(stringToBeParsed.charAt(CUSTOM_DELIMITER_INDEX));
}
private String stringParsedIntro() {
return stringToBeParsed.substring(INTRO_TO_BE_PARSED.length());
}
}
그리고 ParsedString 클래스 명칭을 기본 구분자에 대해서 파싱하는 책임을 담당하도록 ParsedStringByDefaultDelimiter 으로 변경 하였습니다.
📌 Step 6 : 중간 점검
현재 패키지 구조는 이렇게 되어 있습니다.
전략 패턴을 사용하려고 커스텀 구분자와 기본 구분자로 클래스를 2개 나눴습니다.
ParsedStringByCustomDelimiter : 기본 구분자로 문자열 파싱
ParsedStringByDefaultDelimiter : 커스텀 구분자로 문자열 파싱
📌 Step 7 : 문자열 리스트를 숫자 리스트로 변환
문자열 파싱을 담당하고있는 인터페이스의 메서드의 반환 타입을 보면 List<String> 으로 반환하고 있습니다.
@FunctionalInterface
public interface Parsed {
List<String> parsedValue();
}
그러니 List<Integer> 로 타입 변환이 필요해보입니다.
타입 변환의 책임을 담당하고 있는 객체 명을 어떻게 지어야할까요? 일급 컬렉션을 만드는게 좋아보입니다. 클래스 명은 Numbers 가 적당해보입니다.
@FunctionalInterface
public interface Numbers {
List<Integer> numbers();
}
class StringsToNumbersTest {
@Test
@DisplayName("파싱된 문자열 리스트를 숫자 리스트로 변환할 수 있다.")
void a() {
String stringToBeParsed = "1,2,3";
Parsed parsed = new ParsedStringByDefaultDelimiter(stringToBeParsed);
Numbers sut = new StringsToNumbers(parsed);
List<Integer> numbers = sut.numbers();
Assertions.assertThat(numbers).containsExactly(1, 2, 3);
}
}
public class StringsToNumbers implements Numbers {
private final Parsed parsed;
public StringsToNumbers(Parsed parsed) {
this.parsed = parsed;
}
@Override
public List<Integer> numbers() {
List<String> parsedValues = parsed.parsedValue();
return parsedValues.stream()
.map(Integer::parseInt)
.collect(toList());
}
}
📌 Step 8 : 계산된 문자열
문자열 계산을 담당하는 클래스인 CalulcatedString을 테스트해보도록 하겠습니다.
class CalculatedStringTest {
@Test
@DisplayName("문자열을 계산할 수 있다.")
void a() {
String inputValue = "1,2,3";
Parsed parsed = new ParsedStringByDefaultDelimiter(inputValue);
Calculated sut = new CalculatedString(parsed);
int calculatedResult = sut.calculatedResult();
Assertions.assertThat(calculatedResult).isEqualTo(6);
}
}
@FunctionalInterface
public interface Calculated {
int calculatedResult();
}
public class CalculatedString implements Calculated {
private final Numbers numbers;
public CalculatedString(Parsed parsed) {
this(new StringsToNumbers(parsed));
}
public CalculatedString(Numbers numbers) {
this.numbers = numbers;
}
@Override
public int calculatedResult() {
List<Integer> numbersToCalculated = numbers.numbers();
return numbersToCalculated.stream()
.mapToInt(numbers -> numbers)
.sum();
}
}
📌 Step 9 : 클라이언트
public class Client {
private static final Pattern CUSTOM_DELIMITER_JUDGMENT = Pattern.compile("(//.*\n)?.*[0-9].*");
public static void main(String[] args) {
int calculated = calculated();
System.out.println("calculated = " + calculated);
}
private static int calculated() {
String inputValue = "";
try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
inputValue = br.readLine();
} catch (IOException e) {
e.printStackTrace();
}
if (CUSTOM_DELIMITER_JUDGMENT.matcher(inputValue).find()) {
Calculated calculated = new CalculatedString(new ParsedStringByCustomDelimiter(inputValue));
return calculated.calculatedResult();
}
Calculated calculated = new CalculatedString(new ParsedStringByDefaultDelimiter(inputValue));
return calculated.calculatedResult();
}
}
📌 Step 10 : 총 정리
로직 흐름 : Clinet → Calculated → Numbers → Parsed
Calclated : 숫자 리스트들을 계산한다.
Numbers : 문자열 리스트를 숫자 리스트로 변환한다
Parsed : 문자열 파싱을 한다.
'[TDD]' 카테고리의 다른 글
[TDD 연습] 문자열 계산기 (0) | 2022.05.07 |
---|