우아한테크코스 6기 최종 코딩테스트를 준비하면서 작성된 글입니다.
아래의 우아한테크코스의 프리코스 과제를 수행해 오면서 정리한 내용들로 이루어져 있습니다.
oncall - 최종 코딩 테스트!subway-pathpairmatching-precoursebridgebaseballmenuchristmaslottoracingcarvendingmachineonboarding구현 전략
Docs,Feat,Test모두 한번에Commit하는 방식으로 진행하였다.
(= 기능단위로 커밋)
도메인의 비즈니스로직을 단위테스트하는 것을 중심으로 작성되었다.
우선 AssertJ를 import한다.
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
@Test
@DisplayName("1에서 9까지의 숫자가 아니라면 예외가 발생한다.")
void validateRange() { // 사용되어 지는 메서드 명
assertThatThrownBy(() -> new GameNumber(List.of(0, 2, 3)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("숫자는 1에서 9까지의 수로 이루어져야 합니다.");
}
@ParameterizedTest 을 @Test 애노테이션 대신 사용하면 여러 개의 파라미터 값에 대해 각각 테스트를 수행하는 코드를 간편하게 작성할 수 있다.
@ValueSource(strings = {"", " ", " "})@CsvSource(value = {"1:2", "2:4", "3:6"}, delimiter = ':')@NullAndEmptySource@EnumSource(Week.class)@MethodSource("paramsForIsBlank") @ParameterizedTest
@MethodSource("matchData")
@DisplayName("다른 숫자와 비교해 같은 자리에 같은 수가 몇개 있는지 알 수 있다.")
void matchCount(BaseballNumber computerNumber, BaseballNumber userNumber, long expected){
assertThat(computerNumber.matchCount(userNumber)).isEqualTo(expected);
}
static Stream<Arguments> matchData() {
BaseballNumber computerNumber = new BaseballNumber(List.of(4, 2, 3));
return Stream.of(
Arguments.of(computerNumber, new BaseballNumber(List.of(4, 2, 3)), 3L),
Arguments.of(computerNumber, new BaseballNumber(List.of(1, 2, 3)), 2L),
Arguments.of(computerNumber, new BaseballNumber(List.of(4, 3, 2)), 1L),
Arguments.of(computerNumber, new BaseballNumber(List.of(3, 4, 5)), 0L)
);
}
assertThatIllegalArgumentException()와 같이 예외를 특정해서 테스트할 수 있는 장점이 있지만, 아래와 같이 예외 처리하는 것이 더 간편한 것 같다.assertThatThrownBy(() -> new OrderSheets(orderDuplicate))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR] 유효하지 않은 주문입니다. 다시 입력해 주세요.");
@DisplayName("특정 메뉴 그룹에 속하는 메뉴가 몇 개 있는지 알 수 있다.")
@Test
void getNumberOf() {
OrderSheets orderSheets = new OrderSheets(List.of("초코케이크-2", "제로콜라-1", "시저샐러드-1"));
Assertions.assertAll(
() -> assertThat(orderSheets.getNumberOf(MenuGroup.DESSERT)).isEqualTo(2),
() -> assertThat(orderSheets.getNumberOf(MenuGroup.APPETIZER)).isEqualTo(1),
() -> assertThat(orderSheets.getNumberOf(MenuGroup.BEVERAGE)).isEqualTo(1)
);
}
"스트라이크 테스트" 보다는 "같은 자리에 같은 숫자가 존재하면, 스트라이크이다."처럼 명사 나열보다는 문장 형식이 낫다.**@DisplayNameGeneration** 라는 애너테이션을 쓰면, 매번 메서드마다 @DisplayName 을 사용하지 않아도 된다@DisplayNameGeneration을 사용하지 않기로 한다.
// 테스트하기 어려운 메서드
public void move() {
final int number = random.nextInt(RANDOM_NUMBER_UPPER_BOUND);
if (number >= MOVABLE_LOWER_BOUND) {
position++;
}
}
// 테스트하기 좋은 메서드
public void move(int number) {
if (number >= MOVABLE_LOWER_BOUND) {
position++;
}
}
public class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Test
public void testGetName() {
// Given
Person person = new Person("John");
// When
String name = person.getName();
// Then
assertEquals("John", name);
}
// 의미가 없다!
하지만, 계산된 값이 있는 경우나 로직이 내장된 경우와 같이 Getter가 내부적으로 어떤 로직을 수행하거나 특별한 경우를 처리하는 등의 추가적인 로직이 있는 경우에는 해당 로직에 대한 테스트를 추가하는 것이 좋다.
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
if (age < 18) {
return "Child " + name;
} else {
return name;
}
}
@Test
public void testGetName() {
// Given
Person childPerson = new Person("Alice", 15);
Person adultPerson = new Person("Bob", 25);
// When
String childName = childPerson.getName();
String adultName = adultPerson.getName();
// Then
assertEquals("Child Alice", childName);
assertEquals("Bob", adultName);
}
이경우에는 솔직히 Getter라는 이름보다는 다른 이름을 쓰는 것이 나은 판단 같다.
Q. 어떻게 캡슐화를 코드에 스며들게 할까?
A. 객체를 만들고 객체 안에서 메서드( 기능 )을 구현할 때 이름을 추상적이게 명명해야 한다.
Q. 그럼 추상적이게 명명한다는 것이 무엇일까?
A1. 먼저 내가 이 메서드를 왜 사용해야 하는지를 먼저 생각해보자
Ex) 성인 콘텐츠와 미성년자 콘텐츠를 구분하여야 한다.
A2. 은닉화 되어있는 age의 getter를 내가 가져와야 하는 이유로 명명하자.
Ex) getAge → checkAdult
A3. 위와 같이 작성했을 경우 외부에서 이 메서드를 사용하는 사용자는 '성인 여부를 확인하는 메서드이구나'라고 생각하고 사용하게 될 것이다.
그렇게 된다면,
이 안에서 어떤 속성을 썼는지 내부 로직을 예측할 수 없게 된다. ->추상적
이렇게 사용자가 기능만 알고 사용하게 된다면 캡슐화는 잘 적용된 것일 것이다.
메서드의 이름이 추상적이다 라는 것은 약간 오해의 소지가 있지만,
”추상적이다 = 구체적이지 않게 작성하라는 뜻”이 아니다
애매하게 쓰이는 이름보다는 구체적인게 오히려 더 낫다
getAge를 통해 나이를 알아오는 메서드이름을 추상화해서 표현하여
사용자가 내부의 특정 속성, 특정 로직을 예측하지 못하게 구성하고,
사용자가 어떤 기능인지만 알고 수행하게끔checkAdult와 같이 추상화해서 표현하라는 뜻이다.
내부적으로 검증된 메서드들을 사용하더라도 메서드 시그니처가 검증된 메서드들의 시그니처와 다를 시 테스트 하자.
아래의 경우 finish()에 대한 테스트는 굳이 진행하지 않아도 되겠다.
// BridgeGame
public boolean finish() {
return bridge.end();
}
// Bridge
public boolean end() {
return unit.size() == index;
}
/**
* @param size 다리의 길이
* @return 입력받은 길이에 해당하는 다리 모양. 위 칸이면 "U", 아래 칸이면 "D"로 표현해야 한다.
*/
public List<String> makeBridge(int size) {
validateSize(size);
List<String> bridge = new ArrayList<>();
for(int i = 0; i < size; i++){
bridge.add(BridgeUnit.of(bridgeNumberGenerator.generate()).getSignatureLetter());
}
return bridge;
}
위와 같이 이루어져 있는 경우에 makeBridge(int size)로 생성되는 List<String>형의 문자들은 정확히 어떤 문자들이 들어가는지 예측할 수가 없다.
(내부적으로 랜덤한 값을 생성해 문자로 변환하기 때문에)
하지만, 랜덤한 값을 생성하는 bridgeNumberGenerator가 정상적으로 동작하는지에 대해 검증했고, BridgeUnit.of() 또한 정상작동하는 것을 검증했다면,
정확히 List<String>에 어떤 순서로 값이 들어가는 지는 별로 중요하지 않고,
특정 문자 U와 D만 정상적으로 들어갔는지 검사하는 것으로 테스트를 할 수 있겠다.
@Test
@DisplayName("무작위 값을 이용해 다리를 생성할 수 있다.")
void makeBridge() {
BridgeMaker bridgeMaker = new BridgeMaker(new BridgeRandomNumberGenerator());
assertAll(
() -> assertThat(bridgeMaker.makeBridge(3).size()).isEqualTo(3),
() -> assertThat(bridgeMaker.makeBridge(3)).containsAnyElementsOf(List.of(CrossingDirection.TOP.getSignatureLetter(), CrossingDirection.BOTTOM.getSignatureLetter()))
);
}
아래와 같이 입력에 대한 예외를 검증하는 부분에 대해서는 테스트 코드를 작성하지 않는다.
private void validateNullAndEmpty(String input) {
if (Objects.isNull(input) || input.isEmpty()) {
throw new IllegalArgumentException("null 이거나 길이가 없는 문자열 입니다.");
}
}
private void validateNumeric(String input) {
if (!NUMERIC_PATTERN.matcher(input).matches()) {
throw new IllegalArgumentException("문자열이 숫자 1부터 9까지로 이루어져 있지 않습니다.");
}
}
private void validateSingleLetter(String input) {
if (input.length() != 1) {
throw new IllegalArgumentException("문자열의 크기는 한개로 이루어져야 합니다.");
}
}
InputView에서 아래 부분의 throw~ 로 예외처리되는 부분이 검증이 되지않아 테스트를 진행하지 않아 테스트 코드 커버리지가 떨어지는 걸 볼 수 있다.

util로 따로 생성해 테스트를 해줄까도 생각해 보았지만 그렇게 하지 않았다.
테스트 코드를 꼼꼼히 작성해 테스트 코드 커버리지가 높아 지는 것은 좋은 일이지만,
코드 커버리지를 높이기 위한 테스트코드 작성은 주객이 전도된 것이다.
테스트는 결함 검출용으로 사용하고, Code Coverage에는 집착하지 마라
결함 검출을 어느 수준까지 할것인지는 본인의 판단이며,
위와 같은 상황에서 아래와 같다면
input 예외처리 테스트 코드 작성의 비용 > 입력 검증 Test 결함으로 생기는 SideEffect
테스트 코드를 굳이 작성하지 않고, input 검증에 관한 부분은 결함이 없다고 생각하고 진행하는 것이 맞다
입력 검증
Test결함으로 생기는SideEffect는 거의 없다고 생각되며, 비즈니스 로직과 연관되는 예외처리도 아니므로 그렇게Critical한 예외 상황도 없을 것이다.
라이브러리로 주어지는 import camp.nextstep.edu.missionutils.Randoms;과 같은 것을
테스트해야 하는지, 또 테스트를 해야한다면 어떻게 구성해야하는지에 대해 알아보자
테스트를 해야 하는가? -> ⭕️
숫자를 몇개를 만드는지, 범위는 어디서부터 어디까지인지 외부에서 모르기 때문에 generate메서드에 대한 테스트를 만들어야 한다.
또한 Util성을 가진 클래스라도 테스트는 해야하는 것이 맞다.
public class RandomNumGenerator {
public static List<Integer> generate(){
List<Integer> numbers = new ArrayList<>();
while (numbers.size() < 3) {
int randomNumber = Randoms.pickNumberInRange(1, 9);
if (!numbers.contains(randomNumber)) {
numbers.add(randomNumber);
}
}
return numbers;
}
}
그렇다면 어떻게 테스트를 해야하는가?
Randoms.picikNumberInRange의 경우 내부적으로 어떤 구현이 이루어졌는지 메서드 명만으로는 알 수 없으며, 구현내부를 알더라도 관련된 메서드는 private으로 접근제한을 걸어놓아 항상 1부터 9사이의 수를 반환하는지 검증하기 어렵고, 테스트하기 힘들다.
덧붙이자면, 만약 구현 내부(pickNumberInRange() 내부)의 메서드들을 모두 검증할 수 있다면 항상 1부터 9사이의 수를 반환하는 것을 입증할 수 있지만,
현재는 그렇게 하지 못하므로 항상 1부터 9사이의 수를 반환하는지 검증하기 어렵다는 것이다.
따라서, 충분히 큰 수만큼 테스트를 돌렸을 때 정상적으로 동작한다는 것은 메서드가 정상적으로 동작한다는 것으로 생각하자!
(라이브러리가 정상적으로 동작하는 지에 대한 검증이 필요없을 수 있지만,
해당 라이브러리는 내가 직접 구현한 것이 아니므로 추가적인 검증을 진행한 것이다)
테스트를 10000번 돌려도 되고(라이브러리에 대한 추가 검증),
1번만 돌려도("라이브러리에 대한 검증"이 됐다라고 판단한 후 진행하는 것) 된다.
class RandomNumGeneratorTest {
public static final int ENOUGH_BIG_NUMBER = 10000;
@Test
@DisplayName("1에서 9까지 서로 다른 임의의 수 3개를 생성한다.")
void generate() {
for (int i = 0; i < ENOUGH_BIG_NUMBER; i++) {
List<Integer> randomNums = RandomNumGenerator.generate();
assertAll(
() -> assertThat(randomNums.stream().allMatch(num -> num >= 1 && num <= 9)).isTrue(),
() -> assertThat(randomNums.stream().distinct().toList().size()).isEqualTo(3),
() -> assertThat(randomNums.size()).isEqualTo(3)
);
}
}
}