diff --git a/README.md b/README.md index 1e3356ca..fbc83637 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ -# java-calculator +# 기능 및 프로그래밍 요구 사항 + +# Step4 + +- [x] build.gradle에 AssertJ 의존성을 추가한다. +- [x] 기존 JUnit5로 작성되어 있던 단위 테스트를 AssertJ로 리팩터링한다. +- [x] JUnit5에서 제공하는 기능과 AssertJ에서 제공하는 기능을 사용해보고, 어떠한 차이가 있는지 경험한다. +- [x] 메인 메서드는 만들지 않는다. + +# Step3 +- [x] 쉼표(`,`) 또는 콜론(`:`)을 구분자로 가지는 문자열을 전달하면, 구분자를 기준으로 분리한 숫자들의 합을 반환해야 한다. + - 예시: + - "" → 0 + - "1,2" → 3 + - "1,2,3" → 6 + - "1,2:3" → 6 +- [x] 입력 문자열을 받아 처리해야 한다. +- [x] 입력받은 문자열을 쉼표(`,`) 또는 콜론(`:`)을 기준으로 분리할 수 있어야 한다. +- [x] 분리한 숫자들을 합산하여 반환해야 한다. +- [x] 기본 구분자(쉼표, 콜론) 외에 **커스텀 구분자**를 지정할 수 있어야 한다. + - [x] 쉼표, 콜론은 기본 구분자이다. + - [x] 커스텀 구분자를 기본 구분자에 **추가 등록**할 수 있어야 한다. + - [x] 커스텀 구분자는 문자열 앞부분 `"//"`와 `"\n"` 사이에 위치한 문자를 추출하여 등록할 수 있어야 한다. +- [x] 문자열에 숫자가 아닌 값이나 음수가 포함되면 `RuntimeException`을 발생시켜야 한다. +- [x] 구현한 문자열 계산기가 예상한대로 동작하는지 Junit5를 활용하여 테스트를 자동화한다. +- [x] 조금 더 복잡한 도메인을 대상으로 테스트를 작성하는 경험을 해본다. +- [x] 메인 메서드는 만들지 않는다. + + +# Step2 +- [x] 구현한 초간단 계산기가 예상한대로 동작하는지 Junit5를 활용하여 테스트를 자동화한다. +- [x] 메인 메서드 없이 동작을 검증하는 경험을 해본다. +- [x] 메인 메서드는 만들지 않는다. + +# Step1 +- [x] 인자 2개를 받아 사칙연산을 할 수 있는 계산기를 구현한다. +- [x] 사칙연산과 매칭되는 4개의 메서드를 제공한다. +- [x] 계산된 결과는 정수를 반환한다. +- [x] 메인 메서드는 만들지 않는다.** + -계산기 미션 저장소 diff --git a/build.gradle b/build.gradle index 87254a3a..239f9e78 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,9 @@ repositories { dependencies { testImplementation platform('org.junit:junit-bom:5.9.1') + testImplementation platform('org.assertj:assertj-bom:3.25.1') testImplementation('org.junit.jupiter:junit-jupiter') + testImplementation('org.assertj:assertj-core') } test { diff --git a/src/main/java/.gitkeep b/src/main/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/domain/Calculator.java b/src/main/java/domain/Calculator.java new file mode 100644 index 00000000..5974e9fa --- /dev/null +++ b/src/main/java/domain/Calculator.java @@ -0,0 +1,25 @@ +package domain; + +public class Calculator { + + private static final String DIVIDE_BY_ZERO_ERROR_MESSAGE = "[ERROR] 0으로 나눌 수 없습니다."; + + public int add(int firstNumber, int secondNumber) { + return firstNumber + secondNumber; + } + + public int subtract(int firstNumber, int secondNumber) { + return firstNumber - secondNumber; + } + + public int multiply(int firstNumber, int secondNumber) { + return firstNumber * secondNumber; + } + + public int divide(int firstNumber, int secondNumber) { + if (secondNumber == 0) { + throw new IllegalArgumentException(DIVIDE_BY_ZERO_ERROR_MESSAGE); + } + return firstNumber / secondNumber; + } +} diff --git a/src/main/java/domain/Delimiters.java b/src/main/java/domain/Delimiters.java new file mode 100644 index 00000000..2a098fd1 --- /dev/null +++ b/src/main/java/domain/Delimiters.java @@ -0,0 +1,39 @@ +package domain; + +import java.util.ArrayList; +import java.util.List; + +public class Delimiters { + + private static final String COMMA = ","; + private static final String COLON = ":"; + private static final List DEFAULT_DELIMITERS = List.of(COMMA, COLON); + + private final List delimiters = new ArrayList<>(DEFAULT_DELIMITERS); + + public void registerCustomDelimiter(String customDelimiter) { + validate(customDelimiter); + delimiters.add(customDelimiter); + } + + public List getDelimiters() { + return new ArrayList<>(delimiters); + } + + private void validate(String customDelimiter) { + validateNotBlank(customDelimiter); + validateNotDuplicate(customDelimiter); + } + + private void validateNotBlank(String customDelimiter) { + if (customDelimiter == null || customDelimiter.isBlank()) { + throw new IllegalArgumentException("[ERROR] 커스텀 구분자는 비어 있을 수 없습니다."); + } + } + + private void validateNotDuplicate(String customDelimiter) { + if (delimiters.contains(customDelimiter)) { + throw new IllegalArgumentException("[ERROR] 이미 등록된 구분자입니다."); + } + } +} diff --git a/src/main/java/domain/PositiveNumber.java b/src/main/java/domain/PositiveNumber.java new file mode 100644 index 00000000..de0e6e6e --- /dev/null +++ b/src/main/java/domain/PositiveNumber.java @@ -0,0 +1,43 @@ +package domain; + +import java.util.Objects; + +public class PositiveNumber { + + private static final String ERROR_NEGATIVE_NUMBER = "[ERROR] 숫자는 0 이상이어야 합니다."; + + private final int positiveNumber; + + public PositiveNumber(int positiveNumber) { + validateNonNegative(positiveNumber); + this.positiveNumber = positiveNumber; + } + + private void validateNonNegative(int positiveNumber) { + if (positiveNumber < 0) { + throw new IllegalArgumentException(ERROR_NEGATIVE_NUMBER); + } + } + + public int getPositiveNumber() { + return positiveNumber; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PositiveNumber that = (PositiveNumber) o; + return positiveNumber == that.positiveNumber; + } + + @Override + public int hashCode() { + return Objects.hash(positiveNumber); + } + + @Override + public String toString() { + return String.valueOf(positiveNumber); + } +} diff --git a/src/main/java/domain/PositiveNumbers.java b/src/main/java/domain/PositiveNumbers.java new file mode 100644 index 00000000..0e984b5f --- /dev/null +++ b/src/main/java/domain/PositiveNumbers.java @@ -0,0 +1,23 @@ +package domain; + +import utils.NumberParser; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class PositiveNumbers { + + private final List positiveNumbers; + + public PositiveNumbers(List tokens) { + this.positiveNumbers = tokens.stream() + .map(NumberParser::parse) + .collect(Collectors.toCollection(ArrayList::new)); + } + + public int sum() { + return positiveNumbers.stream() + .mapToInt(PositiveNumber::getPositiveNumber) + .sum(); + } +} diff --git a/src/main/java/domain/StringCalculator.java b/src/main/java/domain/StringCalculator.java new file mode 100644 index 00000000..bf00f852 --- /dev/null +++ b/src/main/java/domain/StringCalculator.java @@ -0,0 +1,47 @@ +package domain; + +import utils.ExpressionSplitter; + +import java.util.List; + +public class StringCalculator { + + private final Delimiters delimiters; + private final ExpressionSplitter splitter; + + public StringCalculator() { + this.delimiters = new Delimiters(); + this.splitter = new ExpressionSplitter(delimiters); + } + + public int calculateSum(String expression) { + if (isEmpty(expression)) { + return 0; + } + + String numbersExpression = extract(expression); + List tokens = splitter.split(numbersExpression); + PositiveNumbers positiveNumbers = new PositiveNumbers(tokens); + + return positiveNumbers.sum(); + } + + private boolean isEmpty(String expression) { + return expression == null || expression.isBlank(); + } + + private String extract(String expression) { + final String prefix = "//"; + final String suffix = "\n"; + if (!expression.startsWith(prefix)) { + return expression; + } + int endIndex = expression.indexOf(suffix); + if (endIndex == -1) { + throw new IllegalArgumentException("[ERROR] 커스텀 구분자 형식이 잘못되었습니다."); + } + String customDelimiter = expression.substring(prefix.length(), endIndex); + delimiters.registerCustomDelimiter(customDelimiter); + return expression.substring(endIndex + 1); + } +} diff --git a/src/main/java/utils/ExpressionSplitter.java b/src/main/java/utils/ExpressionSplitter.java new file mode 100644 index 00000000..669ffce1 --- /dev/null +++ b/src/main/java/utils/ExpressionSplitter.java @@ -0,0 +1,24 @@ +package utils; + +import domain.Delimiters; + +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class ExpressionSplitter { + + private final Delimiters delimiters; + + public ExpressionSplitter(Delimiters delimiters) { + this.delimiters = delimiters; + } + + public List split(String expression) { + String regex = delimiters.getDelimiters().stream() + .map(Pattern::quote) + .collect(Collectors.joining("|")); + return Arrays.asList(expression.split(regex)); + } +} diff --git a/src/main/java/utils/NumberParser.java b/src/main/java/utils/NumberParser.java new file mode 100644 index 00000000..a3018b43 --- /dev/null +++ b/src/main/java/utils/NumberParser.java @@ -0,0 +1,17 @@ +package utils; + +import domain.PositiveNumber; + +public class NumberParser { + + private static final String ERROR_NOT_A_NUMBER = "[ERROR] 입력은 숫자여야 합니다."; + + public static PositiveNumber parse(String input) { + try { + int number = Integer.parseInt(input); + return new PositiveNumber(number); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(ERROR_NOT_A_NUMBER); + } + } +} diff --git a/src/test/java/.gitkeep b/src/test/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/test/java/domain/CalculatorTest.java b/src/test/java/domain/CalculatorTest.java new file mode 100644 index 00000000..1bfd6c0d --- /dev/null +++ b/src/test/java/domain/CalculatorTest.java @@ -0,0 +1,77 @@ +package domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.*; + +class CalculatorTest { + + private final Calculator calculator = new Calculator(); + + @ParameterizedTest + @CsvSource({ + "1, 2, 3", + "3, 5, 8", + "-1, -2, -3", + "-5, 5, 0" + }) + @DisplayName("덧셈: 두 수를 더한 결과를 반환한다.") + void addMethod(int firstNumber, int secondNumber, int expected) { + int result = calculator.add(firstNumber, secondNumber); + assertThat(expected).isEqualTo(result); + } + + @ParameterizedTest + @CsvSource({ + "5, 2, 3", + "10, 5, 5", + "0, 0, 0", + "-5, -5, 0" + }) + @DisplayName("뺄셈: 두 수를 뺀 결과를 반환한다.") + void subtractMethod(int firstNumber, int secondNumber, int expected) { + int result = calculator.subtract(firstNumber, secondNumber); + assertThat(expected).isEqualTo(result); + } + + @ParameterizedTest + @CsvSource({ + "2, 3, 6", + "-2, 3, -6", + "0, 5, 0", + "-3, -3, 9" + }) + @DisplayName("곱셈: 두 수를 곱한 결과를 반환한다.") + void multiplyMethod(int firstNumber, int secondNumber, int expected) { + int result = calculator.multiply(firstNumber, secondNumber); + assertThat(expected).isEqualTo(result); + } + + @ParameterizedTest + @CsvSource({ + "6, 3, 2", + "9, 3, 3", + "-9, 3, -3", + "10, -2, -5" + }) + @DisplayName("나눗셈: 두 수를 나눈 결과를 반환한다.") + void divideMethod(int firstNumber, int secondNumber, int expected) { + int result = calculator.divide(firstNumber, secondNumber); + assertThat(expected).isEqualTo(result); + } + + @ParameterizedTest + @CsvSource({ + "5, 0", + "0, 0", + "-3, 0" + }) + @DisplayName("나눗셈: 0으로 나누면 IllegalArgumentException이 발생한다.") + void divideByZeroException(int firstNumber, int secondNumber) { + assertThatThrownBy(() -> calculator.divide(firstNumber, secondNumber)) + .isInstanceOf(IllegalArgumentException.class); + + } +} diff --git a/src/test/java/domain/DelimitersTest.java b/src/test/java/domain/DelimitersTest.java new file mode 100644 index 00000000..e45f17cb --- /dev/null +++ b/src/test/java/domain/DelimitersTest.java @@ -0,0 +1,59 @@ +package domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class DelimitersTest { + + @Test + @DisplayName("기본 구분자가 등록되어 있어야 한다") + void shouldContainDefaultDelimiters() { + Delimiters delimiters = new Delimiters(); + + List expected = List.of(",", ":"); + assertThat(delimiters.getDelimiters()).isEqualTo(expected); + } + + @Test + @DisplayName("커스텀 구분자를 추가할 수 있다") + void shouldAddCustomDelimiter() { + Delimiters delimiters = new Delimiters(); + delimiters.registerCustomDelimiter(";"); + + List expected = List.of(",", ":", ";"); + assertThat(delimiters.getDelimiters()).isEqualTo(expected); + } + + @Test + @DisplayName("null을 입력하면 예외가 발생한다") + void shouldThrowExceptionWhenCustomDelimiterIsNull() { + Delimiters delimiters = new Delimiters(); + assertThatThrownBy(() -> delimiters.registerCustomDelimiter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 커스텀 구분자는 비어 있을 수 없습니다."); + } + + @Test + @DisplayName("빈 문자열을 입력하면 예외가 발생한다") + void shouldThrowExceptionWhenCustomDelimiterIsBlank() { + Delimiters delimiters = new Delimiters(); + assertThatThrownBy(() -> delimiters.registerCustomDelimiter(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 커스텀 구분자는 비어 있을 수 없습니다."); + } + + @Test + @DisplayName("이미 등록된 구분자를 추가하면 예외가 발생한다") + void shouldThrowExceptionWhenCustomDelimiterIsDuplicate() { + Delimiters delimiters = new Delimiters(); + delimiters.registerCustomDelimiter(";"); + + assertThatThrownBy(() -> delimiters.registerCustomDelimiter(";")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 이미 등록된 구분자입니다."); + } +} diff --git a/src/test/java/domain/PositiveNumberTest.java b/src/test/java/domain/PositiveNumberTest.java new file mode 100644 index 00000000..e007aabe --- /dev/null +++ b/src/test/java/domain/PositiveNumberTest.java @@ -0,0 +1,40 @@ +package domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class PositiveNumberTest { + + @Test + @DisplayName("getPositiveNumber 메서드는 저장된 값을 반환한다.") + void getPositiveNumberReturnsStoredValue() { + PositiveNumber positiveNumber = new PositiveNumber(42); + assertThat(positiveNumber.getPositiveNumber()).isEqualTo(42); + } + + @Test + @DisplayName("동일한 값을 가진 PositiveNumber 객체는 equals로 비교 시 같다.") + void positiveNumbersWithSameValueAreEqual() { + PositiveNumber a = new PositiveNumber(42); + PositiveNumber b = new PositiveNumber(42); + assertThat(a).isEqualTo(b); + } + + @Test + @DisplayName("서로 다른 값을 가진 PositiveNumber 객체는 equals로 비교 시 다르다.") + void positiveNumbersWithDifferentValuesAreNotEqual() { + PositiveNumber a = new PositiveNumber(42); + PositiveNumber b = new PositiveNumber(43); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("음수 값으로 생성 시 예외가 발생한다.") + void throwsExceptionWhenNegativeValueProvided() { + assertThatThrownBy(() -> new PositiveNumber(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 숫자는 0 이상이어야 합니다."); + } +} diff --git a/src/test/java/domain/PositiveNumbersTest.java b/src/test/java/domain/PositiveNumbersTest.java new file mode 100644 index 00000000..d951a5c6 --- /dev/null +++ b/src/test/java/domain/PositiveNumbersTest.java @@ -0,0 +1,48 @@ +package domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class PositiveNumbersTest { + + @Test + @DisplayName("숫자 문자열 리스트를 합산한다") + void sumNumbers_success() { + // given + List tokens = List.of("1", "2", "3"); + + // when + PositiveNumbers positiveNumbers = new PositiveNumbers(tokens); + + // then + assertThat(6).isEqualTo(positiveNumbers.sum()); + } + + @Test + @DisplayName("음수가 포함된 경우 예외를 던진다") + void throwException_whenNegativeNumberExists() { + // given + List tokens = List.of("1", "-2", "3"); + + // when & then + assertThatThrownBy(() -> new PositiveNumbers(tokens)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("빈 리스트가 주어지면 합은 0이다") + void sumZero_whenEmptyList() { + // given + List tokens = List.of(); + + // when + PositiveNumbers positiveNumbers = new PositiveNumbers(tokens); + + // then + assertThat(0).isEqualTo(positiveNumbers.sum()); + } +} diff --git a/src/test/java/domain/StringCalculatorTest.java b/src/test/java/domain/StringCalculatorTest.java new file mode 100644 index 00000000..1e543146 --- /dev/null +++ b/src/test/java/domain/StringCalculatorTest.java @@ -0,0 +1,37 @@ +package domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class StringCalculatorTest { + + private final StringCalculator calculator = new StringCalculator(); + + @Test + @DisplayName("빈 문자열 또는 null 입력 시 결과는 0이다") + void returnZero_whenInputIsEmptyOrNull() { + assertThat(calculator.calculateSum("")).isZero(); + assertThat(calculator.calculateSum(null)).isZero(); + } + + @Test + @DisplayName("쉼표 또는 콜론 구분자로 구분된 숫자를 합산한다") + void sumNumbers_whenDefaultDelimiters() { + assertThat(calculator.calculateSum("1,2:3")).isEqualTo(6); + } + + @Test + @DisplayName("커스텀 구분자가 지정된 경우 해당 구분자로 숫자를 합산한다") + void sumNumbers_whenCustomDelimiter() { + assertThat(calculator.calculateSum("//;\n1;2;3")).isEqualTo(6); + } + + @Test + @DisplayName("음수가 포함된 경우 예외를 던진다") + void throwException_whenNegativeNumberExists() { + assertThatThrownBy(() -> calculator.calculateSum("1,-2,3")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/utils/ExpressionSplitterTest.java b/src/test/java/utils/ExpressionSplitterTest.java new file mode 100644 index 00000000..f4b1c6ae --- /dev/null +++ b/src/test/java/utils/ExpressionSplitterTest.java @@ -0,0 +1,58 @@ +package utils; + +import static org.assertj.core.api.Assertions.*; + +import domain.Delimiters; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class ExpressionSplitterTest { + + @Test + @DisplayName("기본 구분자(쉼표, 콜론)로 문자열을 분리한다") + void splitByDefaultDelimiters() { + // given + Delimiters delimiters = new Delimiters(); + ExpressionSplitter splitter = new ExpressionSplitter(delimiters); + String expression = "1,2:3"; + + // when + List result = splitter.split(expression); + + // then + assertThat(result).isEqualTo(List.of("1", "2", "3")); + } + + @Test + @DisplayName("커스텀 구분자가 추가된 경우 문자열을 분리한다") + void splitByCustomDelimiter() { + // given + Delimiters delimiters = new Delimiters(); + delimiters.registerCustomDelimiter(";"); + ExpressionSplitter splitter = new ExpressionSplitter(delimiters); + String expression = "1;2,3:4"; + + // when + List result = splitter.split(expression); + + // then + assertThat(result).isEqualTo(List.of("1", "2", "3", "4")); + } + + @Test + @DisplayName("빈 문자열을 분리하면 빈 리스트를 반환한다") + void splitEmptyString() { + // given + Delimiters delimiters = new Delimiters(); + ExpressionSplitter splitter = new ExpressionSplitter(delimiters); + String expression = ""; + + // when + List result = splitter.split(expression); + + // then + assertThat(result).isEqualTo(List.of("")); + } +} diff --git a/src/test/java/utils/PositiveNumberParserTest.java b/src/test/java/utils/PositiveNumberParserTest.java new file mode 100644 index 00000000..353cf55d --- /dev/null +++ b/src/test/java/utils/PositiveNumberParserTest.java @@ -0,0 +1,25 @@ +package utils; + +import domain.PositiveNumber; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +@DisplayName("parser.NumberParser Test") +class PositiveNumberParserTest { + + @Test + @DisplayName("정상 입력: 문자열을 정수로 변환하여 domain.Number 객체를 반환한다.") + void parseValidInput() { + PositiveNumber positiveNumber = NumberParser.parse("42"); + assertThat(positiveNumber).isEqualTo(new PositiveNumber(42)); + } + + @Test + @DisplayName("비정상 입력: 숫자가 아닌 문자열 입력 시 예외를 발생시킨다.") + void parseInvalidInput() { + assertThatThrownBy(() -> NumberParser.parse("abc")) + .isInstanceOf(IllegalArgumentException.class); + } +}