본 글은 우아한 테크코스 프리코스 3주 차 미션 중 공부한 내용을 기록한 것이다.
-> 우아한 테크코스 프리코스 3주차 미션 java-lotto
-> 필자가 제출한 코드
-> 3주차 미션 회고
본 글은 우아한 테크코스 프리코스 과정 중 3주 차 미션을 수행하며 만나게 된 문제 사항을 해결하기 위해 찾아본 내용을 정리한 것이다.
내가 만났던 문제 사항은 다음과 같다.
테스트 코드에 불필요하게 반복되는 코드가 너어어어어어무 많다.
말 그대로이다. 각 기능에 대한 테스트 코드를 모두 작성하고 나면 모든 코드들이 다 아래와 같이 되어 있었다.
assertThat().isEqualTo()...
assertThatThrownBy().isInstanceOf()...
2주 차 미션에서도 이렇게 쓰여져 있는걸 보고 너무 불편했는데, 제출까지 시간이 얼마 남지 않아 그냥 넘겼었다.
하지만 3주 차 미션에서는 더 지켜볼 수 없었고, 해결해 보고자 했다.
아마 프로젝트마다 모습이 다 다르겠지만, 내가 만들었던 프로젝트의 경우 예외 처리 기능에 대한 테스트 코드에서 특히 반복이 심했다.
처음 작성했던 예외 처리 기능 테스트 코드는 다음과 같았다.
<처음 작성했던 예외 처리 기능 테스트 코드>
...
@Test
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
void handleEmptyInputExceptionTest() {
String invalidInput = "";
String validInput = "abc";
assertThatThrownBy(() -> {
InputExceptionHandler.handleEmptyInputException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
InputExceptionHandler.handleEmptyInputException(valid);
})
.doesNotThrowAnyException();
}
@Test
@DisplayName("공통예외 - 입력에 숫자와 쉼표 외 다른 문자가 포함되는 경우, 예외가 발생한다.")
void handleNotNumberOrCommaExceptionTest() {
String invalidInput = "1.23dfe";
String validInput = "1,2,";
assertThatThrownBy(() -> {
InputExceptionHandler.handleNotNumberOrCommaException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
InputExceptionHandler.handleNotNumberOrCommaException(valid);
})
.doesNotThrowAnyException();
}
...
위 두 메소드를 보면 많은 코드가 중복되는 것을 볼 수 있는데, 중복되는 코드와 달라지는 코드를 정리해 보면 다음과 같다.
<중복되는 코드>
- assertThatThrownBy() + isInstanceOf() + hasMessageContaining()
- assertThatCode() + doesNotThrowAnyException()
<달라지는 코드>
- 테스트 입력 값
- invalidInput
- validInput
- 테스트할 기능의 메소드
- handleEmptyInputException()
- handleNotNumberOrCommaException()
만약 달라지는 코드가 단순히 변수나 객체였으면 그냥 중복 코드를 모아 메소드를 분리하고, 달라지는 변수나 객체를 파라미터로 전달해 주면 된다.
하지만 메소드가 달라지다 보니, 메소드를 다른 메소드의 파라미터로 전달하는 과정에서 어려움이 있었다.
그래서 이를 해결하기 위한 방법에 대해 이것저것 알아보다 보니, 인터페이스와 익명 클래스를 통해 해결할 수 있었다!
(Method 객체를 전달하는 방법도 있었지만, 내가 원하던 방식이 아니었다ㅠ)
인터페이스와 익명 클래스 개념에 대해 자세히는 다루지 않을 것이다.
(구글링 하면 많이 나온다!)
간단하게만 얘기하면, 인터페이스는 다중 상속이 가능한 일종의 추상 클래스이고, 여러 개의 클래스들을 만들 때, 각 클래스들의 큰 틀을 정해줄 수 있다.
(어떤 기능의 메소드가 필요한지 기능 명세서 마냥 미리 정해서 인터페이스에 정리해 두고, 클래스를 만들 때 인터페이스를 상속받아 해당 메소드들을 정의하는 것!)
인터페이스는 추상 클래스이다 보니, 원래는 객체를 만들 수 없는데, Java에서는 익명 클래스 방식을 통해 인터페이스의 객체를 만들 듯 표현할 수 있다.
(객체를 만드는 그 자리에서 바로 추상 메소드를 정의 해버린다. 이렇게 메소드가 정의된 이름 없는 익명 클래스가 만들어지게 되고, 그 클래스의 객체를 만드는 것이다!)
이러한 개념을 활용해서 반복 코드를 줄일 수 있었는데, 앞서 언급했던 예외 처리 기능 테스트 예시에 적용했던 순서는 다음과 같았다.
- 인터페이스
I를 만들고, 추상 메소드i를 담아둔다.- 반복되는 코드를 메소드
r로 분리하고, 파라미터로I타입의 객체를 받는다. 테스트는i로 수행한다.- 테스트 메소드에서
I의 익명 클래스 객체A를 만들고,i를 테스트하고자 하는 기능의 메소드a로 정의한다.A를r에게 전달하며 테스트를 진행한다.
좀 더 자세히 살펴보자!
테스트하고자 했던 예외 처리 메소드는 다음과 같다.
InputExceptionHandler class
- handleEmptyInputException()
- handleNotNumberOrCommaException()
이제 순서대로 적용해 보자!
< 1. 인터페이스를 만들고, 추상 메소드를 담아 둔다. >
다음과 같이 예외처리 기능 인터페이스를 만든다.
public interface TotalExceptionHandler {
void handleException(String input);
}
< 2. 반복되는 코드를 메소드로 분리하고, 파라미터로 인터페이스 타입의 객체를 받는다. 테스트는 추상 메소드로 수행한다. >
앞서 언급됐던 반복되는 코드를 다시 들고와보자!
...
@Test
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
void handleEmptyInputExceptionTest() {
String invalidInput = "";
String validInput = "abc";
assertThatThrownBy(() -> {
InputExceptionHandler.handleEmptyInputException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
InputExceptionHandler.handleEmptyInputException(valid);
})
.doesNotThrowAnyException();
}
@Test
@DisplayName("공통예외 - 입력에 숫자와 쉼표 외 다른 문자가 포함되는 경우, 예외가 발생한다.")
void handleNotNumberOrCommaExceptionTest() {
String invalidInput = "1.23dfe";
String validInput = "1,2,";
assertThatThrownBy(() -> {
InputExceptionHandler.handleNotNumberOrCommaException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
InputExceptionHandler.handleNotNumberOrCommaException(valid);
})
.doesNotThrowAnyException();
}
...
여기서 인터페이스 추상 메소드를 활용하면 반복되는 코드들을 다음과 같이 분리할 수 있다.
void exceptionTest(TotalExceptionHandler exceptionHandler, String invalid, String valid) {
assertThatThrownBy(() -> {
exceptionHandler.handleException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
exceptionHandler.handleException(valid);
})
.doesNotThrowAnyException();
}
이제 테스트 메소드에서 예외처리 기능으로 추상 메소드를 정의한 익명 클래스 객체를 생성하고, 위 분리된 메소드 exceptionTest()를 활용하면 될 것이다.
< 3. 인터페이스를 상속받는 익명 클래스 객체를 만들고, 테스트하고자 하는 기능의 메소드로 정의한다. 분리된 메소드로 테스트를 진행한다. >
앞서 분리한 메소드 exceptionTest로 테스트를 진행해 보자.
...
@Test
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
void handleEmptyInputExceptionTest() {
String invalidInput = "";
String validInput = "abc";
TotalExceptionHandler emptyInputExceptionHandler = new TotalExceptionHandler() {
@Override
public void handleException(String input) {
InputExceptionHandler.handleEmptyInputException(input);
}
};
exceptionTest(emptyInputExceptionHandler, invalidInput, validInput);
}
@Test
@DisplayName("공통예외 - 입력에 숫자와 쉼표 외 다른 문자가 포함되는 경우, 예외가 발생한다.")
void handleNotNumberOrCommaExceptionTest() {
String invalidInput = "1.23dfe";
String validInput = "1,2,";
TotalExceptionHandler notNumberOrCommaExceptionHandler = new TotalExceptionHandler() {
@Override
public void handleException(String input) {
InputExceptionHandler.handleNotNumberOrCommaException(input);
}
};
exceptionTest(notNumberOrCommaExceptionHandler, invalidInput, validInput);
}
...
이렇게 반복되는 코드를 줄일 수 있게 되었다!
여기서 람다식을 통해 더욱 간단하게 줄일 수 있는데, 방법은 다음과 같다.
본 방법은 위 예시처럼 인터페이스가 하나의 추상 메소드만을 가질 때 활용될 수 있다.
익명 클래스 객체를 만드는 과정을 다시 한번 살펴보자.
@Test
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
void handleEmptyInputExceptionTest() {
String invalidInput = "";
String validInput = "abc";
TotalExceptionHandler handler = new TotalExceptionHandler() {
@Override
public void handleException(String input) {
InputExceptionHandler.handleEmptyInputException(input);
}
};
exceptionTest(handler, invalidInput, validInput);
}
여기서 어차피 재정의 해야하는 추상 메소드는 handleException() 하나밖에 없다.
따라서 다음과 같이 적을 수 있다.
@Test
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
void handleEmptyInputExceptionTest() {
String invalidInput = "";
String validInput = "abc";
TotalExceptionHandler handler = input -> InputExceptionHandler.handleEmptyInputException(input);
exceptionTest(handler, invalidInput, validInput);
}
여기서 또 람다식을 통해 더더더더 간단하게 나타낼 수도 있다.
@Test
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
void handleEmptyInputExceptionTest() {
String invalidInput = "";
String validInput = "abc";
TotalExceptionHandler handler = InputExceptionHandler::handleEmptyInputException;
exceptionTest(handler, invalidInput, validInput);
}
만약 여기서 @ParameterizedTest 까지 넣어주면, 더더더더더더더더더 간단하게 만들 수도 있다.
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
@ParameteirzedTest
@CsvSource("'', 'abc'")
void handleEmptyInputExceptionTest(String invalidInput, validInput) {
TotalExceptionHandler handler = InputExceptionHandler::handleEmptyInputException;
exceptionTest(handler, invalidInput, validInput);
}
이렇게 반복 코드를 줄일 수 있는 방법들을 다 동원했을 때, 전후를 비교해 보자.
<줄이기 전>
...
@Test
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
void handleEmptyInputExceptionTest() {
String invalidInput = "";
String validInput = "abc";
assertThatThrownBy(() -> {
InputExceptionHandler.handleEmptyInputException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
InputExceptionHandler.handleEmptyInputException(valid);
})
.doesNotThrowAnyException();
}
@Test
@DisplayName("공통예외 - 입력에 숫자와 쉼표 외 다른 문자가 포함되는 경우, 예외가 발생한다.")
void handleNotNumberOrCommaExceptionTest() {
String invalidInput = "1.23dfe";
String validInput = "1,2,";
assertThatThrownBy(() -> {
InputExceptionHandler.handleNotNumberOrCommaException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
InputExceptionHandler.handleNotNumberOrCommaException(valid);
})
.doesNotThrowAnyException();
}
...
<줄인 후>
@DisplayName("공통예외 - 입력이 비어있는 경우, 예외가 발생한다.")
@ParameteirzedTest
@CsvSource("'', 'abc'")
void handleEmptyInputExceptionTest(String invalidInput, validInput) {
TotalExceptionHandler handler = InputExceptionHandler::handleEmptyInputException;
exceptionTest(handler, invalidInput, validInput);
}
@DisplayName("공통예외 - 입력에 숫자와 쉼표 외 다른 문자가 포함되는 경우, 예외가 발생한다.")
@ParameteirzedTest
@CsvSource("'1.23dfe', '1,2,'")
void handleEmptyInputExceptionTest(String invalidInput, validInput) {
TotalExceptionHandler handler = InputExceptionHandler::handleEmptyInputException;
exceptionTest(handler, invalidInput, validInput);
}
void exceptionTest(TotalExceptionHandler exceptionHandler, String invalid, String valid) {
assertThatThrownBy(() -> {
exceptionHandler.handleException(invalid);
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
assertThatCode(() -> {
exceptionHandler.handleException(valid);
})
.doesNotThrowAnyException();
}
비록 위에서 다룬 예시는 인터페이스가 하나의 메소드만을 담고 있다는 아주 이상적인 예시였지만,
처음에 assertThatThrownBy()와 assertThatCode()로 반복이 무지막지하게 일어났던 코드를 단 두 줄로 바꿀 수 있게 되었다.
더 좋은 방법들도 분명 있겠지만, 이 방법도 정말 괜찮은 방법이라 생각한다.
반복 코드를 줄이기 위한 방법 후보로 추가해 두어도 될 것 같다!