[TDD] 문자열 계산기 구현

0_0_yoon·2021년 11월 16일
0

TDD

목록 보기
1/3
post-thumbnail

📌 기능

사칙연산자와 숫자로 이루어진 입력값을 받아 연산자의 우선순위와 관계없이 순차적으로 연산하는 문자열 계산기

📌 To do list

계산기 작동 순서에 따라 필요한 기능들을 작성했다.

  • 입력값 받기
  • 입력값 공백제거 하기
  • 유효성 검사
    • 첫글자가 숫자가 아니면 예외발생
    • 마지막 글자가 숫자가 아니면 예외발생
    • 사칙연산자 이외의 연산자가 있을 경우 예외발생
  • 입력값을 숫자 배열과 연산자 배열로 나누기
  • 두 배열의 반복을 통해서 순차적으로 연산하기
    • 연산 문자와 연산 가능한 식을 가지고있는 연산자 클래스 구현
    • 연산 문자와 객체 연산자를 맵핑해줄 수 있는 메서드 구현
  • 결과값 출력하기

📌 enum Operator

여러번의 리팩토링을 거쳐서 구현하는데 가장 많은 시간을 쏟았다.

처음에는 StringCalc 클래스 안에서 Map으로 연산자와 연산식을 맵핑해서 구현했다. 하지만 StringCalc 클래스의 책임이 늘어났고 코드량이 많아져서 가독성이 떨어졌다. 결론적으로 단일책임의 원칙에 위배된다고 생각해서 리팩토링을 진행했다. 연산문자와 연산식을 멤버필드로 가지고 있는 Operator 클래스를 만들고 정적필드로 Operator 객체들을 가지고있는 CalcOperator 클래스 까지 만들었다. 그 결과 StringCalc 클래스의 책임이 줄었지만 CalcOperator 클래스와 Operator 클래스의 결합도가 높아졌고 CalcOperator 클래스의 역할이 불분명해서 한번 더 리팩토링을 했다. 그래서 이렇게 열거타입으로 Operator를 구현하게됐다.

@DisplayName("열거타입 연산기능수행 테스트")
@ParameterizedTest
@EnumSource(Operator.class) 
void calc(Operator operator){
     if(operator == Operator.PLUS)
         assertThat(operator.op.calc(1,2)).isEqualTo(3);
     if(operator == Operator.MINUS)
         assertThat(operator.op.calc(1,2)).isEqualTo(-1);
     if(operator == Operator.MULTIPLY)
         assertThat(operator.op.calc(1,2)).isEqualTo(2);
     if(operator == Operator.DIVIDE)
         assertThat(operator.op.calc(10,2)).isEqualTo(5);
}

public enum Operator{

  PLUS("+",(a,b)->a+b),
  MINUS("-",(a,b)->a-b),
  MULTIPLY("*",(a,b)->a*b),
  DIVIDE("/",(a,b)->a/b);

  String symbol;
  Calculable op;

  Operator(String symbol, Calculable op){
      this.symbol = symbol;
      this.op = op;
  }

  @FunctionalInterface
  interface Calculable{
      public int calc(int a, int b);
  }
  
}

생성자를 통해서 사칙연산자들을 초기화 시켜줬다. 연산자의 가장 중요한 연산기능은 함수적 인터페이스의 오버라이딩을 통해서 구현했다.

📍 Operator-연산 문자와 연산자 객체 맵핑

@DisplayName("문자와 Operator맵핑 메소드 테스트")
@Test
void of(){
    assertThat(Operator.of("+")).isEqualTo(Operator.PLUS);
    assertThat(Operator.of("-")).isEqualTo(Operator.MINUS);
    assertThat(Operator.of("/")).isEqualTo(Operator.DIVIDE);
    assertThat(Operator.of("*")).isEqualTo(Operator.MULTIPLY);
}

public static Operator of(String symbol){
    return Arrays.stream(values())
                .filter(op -> op.symbol.equals(symbol))
                .findFirst()
                .get();
}

📌 class StringCalc

단위테스트를 먼저 진행을 하며 구현 하다보니 테스트가 용이하도록 자연스럽게 기능을 하나하나 나누게되었고 이 클래스를 가장 마지막에 구현하게 되었다.

📍 StringCalc-싱글톤 패턴

public class StringCalcTest {
    StringCalc calc;

    @BeforeEach
    void setUp(){ calc = StringCalc.getInstance(); }

    @DisplayName("싱글톤패턴 테스트")
    @Test
    void isSingleton(){
        assertThat(calc).isEqualTo(StringCalc.getInstance());
    }
}

public class StringCalc {

    private static final StringCalc stringCalc = new StringCalc();

    private StringCalc(){}

    public static StringCalc getInstance(){ return stringCalc; }
}

처음에는 StringCalc클래스가 상태필드값을 따로 가지고있지 않고 util클래스와 역할이 비슷하다고 판단해서 생성자를 private으로 구현하고 메서드를 static으로 구현했다. 리팩토링 단계에서 계산기의 기능은 상속을 통해서 확장할 수 있는 여지가 있다는 생각이 들었다. 그래서 객체마다 따로 저장해야할 상태필드값이 없으니 하나의 객체만을 가지고 사용하려고 싱글톤 패턴으로 구현하게 되었다.

📍 StringCalc-입력값 계산하기

@DisplayName("계산기능 테스트")
@ParameterizedTest
@CsvSource(value={"0  +0:0","1+ 2  - 3/2*2:0","100+1 0 0 -100*5/2:250","10-10+1000/2*5:2500"},delimiter=':')
void calc(String s, int expected){
    assertThat(calc.calc(s)).isEqualTo(expected);
}

public int calc(String str){
    int result = 0;
    str = CalcUtil.removeSpace(str);
    ValidationUtil.checkFirstIdx(str);
    ValidationUtil.checkLastIdx(str);
    ValidationUtil.checkOp("[\\+|\\*|/|-]",str);
    List<String> nos = CalcUtil.removeOps("[\\+|\\*|/|-]",str);
    List<String> ops = CalcUtil.removeNos(str);
    result = Integer.parseInt(nos.get(0));
    for(int i = 1; i < nos.size(); i++){
        result = Operator.of(ops.get(i-1)).op.calc(result,Integer.parseInt(nos.get(i)));
    }
    return result;
}

계산기 구현에 필요한 util 메서드와 Operator클래스의 연산, 맵핑 메서드를 사용했다.

📌 class CalcUtil

util클래스를 객체화해서 사용할지 아니면 static 메서드로 사용할지 고민하다가 static 메서드를 사용하는 쪽으로 결정했다. 그 이유는 현재 사용할 util 기능들은 상속을 통한 기능 확장의 여지가 없다고 생각했기 때문이다.

📍 CalcUtil-생성자

public class CalcUtil {
	private CalcUtil() throws InstantiationException{ 
    	   throw new InstantiationException("CalcUtil객체를 생성할 수 없습니다.");
        }
}

CalcUtil 클래스의 생성자를 private으로 설정했다. 거기에 예외처리를 통해서 CalcUtil 클래스 내부에서도 생성자를 사용하지 못하도록 했다.

📍 CalcUtil-입력값을 연산자 및 숫자 List로 만들기

@DisplayName("입력값중 숫자만 뽑아서 배열생성")
@Test
void removeOp(){
    String regex = "[\\+|\\*|/|-]";
    assertThat(CalcUtil.removeOps(regex,"1+1")).isEqualTo(Arrays.asList(new String[]{"1","1"}));
    assertThat(CalcUtil.removeOps(regex,"11+111")).isEqualTo(Arrays.asList(new String[]{"11","111"}));
    assertThat(CalcUtil.removeOps(regex,"1+1-1/1*1")).isEqualTo(Arrays.asList(new String[]{"1","1","1","1","1"}));
}

@DisplayName("입력값중 연산자만 뽑아서 배열생성")
@Test
void removeNos(){
    assertThat(CalcUtil.removeNos("1+1")).isEqualTo(Arrays.asList(new String[]{"+"}));
    assertThat(CalcUtil.removeNos("1-1+1")).isEqualTo(Arrays.asList(new String[]{"-","+"}));
    assertThat(CalcUtil.removeNos("1+1-1/1*1")).isEqualTo(Arrays.asList(new String[]{"+","-","/","*"}));
}

public static List<String> removeNos(String s) {
    return Arrays.stream(s.split("[0-9]"))
                .filter(op->!op.equals(""))
                .collect(Collectors.toList());
}

public static List<String> removeOps(String regex, String s) {
    return Arrays.asList(s.split(regex));
}

입력값의 첫자는 숫자이기때문에 removeNos 내부에서 split시 배열 맨앞에 빈 문자열이 반환되므로 그것을 제외하도록 구현했다.

📍 CalcUtil-입출력 및 공백제거

@DisplayName("공백제거 테스트")
@Test
void removeSpace(){
    assertThat(CalcUtil.removeSpace(" 1 1 1+   2")).isEqualTo("111+2");
}

public static String input(Scanner sc){
    System.out.println("다음줄에 값을 입력하세요.");
    return sc.nextLine();
}

public static void output(int result){
    System.out.println("결과값: " + result);
}

public static String removeSpace(String s) {
    return s.replaceAll(" ","");
}

📌 class ValidationUtil

@DisplayName("입력값 첫번째가 숫자일 경우")
@Test
void checkFirstIdx(){
    ValidationUtil.checkFirstIdx("1+1");
}
    
@DisplayName("입력값 첫번째가 숫자가 아닐 경우")
@ParameterizedTest
@ValueSource(strings = {"+11","-1","-","*9"})
void checkFirstIdx(String s){
    assertThatIllegalArgumentException().isThrownBy(()->{
            ValidationUtil.checkFirstIdx(s);
    }).withMessageMatching("첫입력자는 숫자여야합니다.");
}

@DisplayName("입력값 마지막이 숫자일 경우")
@Test
void checkLastIdx(){
    ValidationUtil.checkLastIdx("1+1");
}

@DisplayName("입력값 마지막이 숫자가 아닐 경우")
@ParameterizedTest
@ValueSource(strings={"11+","1-","1-1-"})
void checkLastIdx(String s){
    assertThatIllegalArgumentException().isThrownBy(()->{
            ValidationUtil.checkLastIdx(s);
    }).withMessageMatching("마지막 입력자는 숫자여야합니다.");  
}

@DisplayName("입력값중 사칙연산자이외의 연산자가 없는 경우")
@Test
void checkOp(){
    ValidationUtil.checkOp("[\\+|\\*|/|-]","1+1");
}

@DisplayName("사칙연산자이외의 연산자가 있을 경우 테스트")
@ParameterizedTest
@ValueSource(strings = {"1%1","2!2","2+3=5"})
void checkOp(String s){
    assertThatIllegalArgumentException().isThrownBy(()->{
            ValidationUtil.checkOp("[\\+|\\*|/|-]",s);
    }).withMessageMatching("연산자가 올바르지 않습니다.");
}

public class ValidationUtil {

    private ValidationUtil() throws InstantiationException { throw new InstantiationException("ValidationUtil");}

    public static void checkFirstIdx(String s) {
        if(!(s.charAt(0) >= '0' && s.charAt(0) <='9'))
            throw new IllegalArgumentException("첫입력자는 숫자여야합니다.");
    }

    public static void checkLastIdx(String s) {
        int lastIdx = s.length() - 1;
        if(!(s.charAt(lastIdx) >= '0' && s.charAt(lastIdx) <= '9'))
            throw new IllegalArgumentException("마지막 입력자는 숫자여야합니다.");
    }

    public static void checkOp(String regex, String s) {
        s = s.replaceAll("[0-9]","");
        s = s.replaceAll(regex,"");
        if(!s.equals(""))
            throw new IllegalArgumentException("연산자가 올바르지 않습니다.");
    }
}

📌 StringCalc 실행하기

public class Application {
    private static final Scanner sc = new Scanner(System.in);
    public static void main(String[] args) {
        CalcUtil.output(StringCalc.getInstance().calc(CalcUtil.input(sc)));
}

📌 느낀점

TDD 기반으로 코드를 작성하다 보니 테스트에 쉽도록 자연스럽게 작은 단위로 기능을 분리하게 됐다. 하지만 테스트 코드를 너무 중점적으로 생각하다 보니 실제로 사용되는 메서드가 아닌 테스트를 위한 메서드를 구현하기도 했다. 아직은 시간도 너무 오래 걸리고 테스트하기 쉽고 동시에 비즈니스 로직에 사용될 수 있는 코드를 짜는 게 어렵다.

profile
꾸준하게 쌓아가자

0개의 댓글