[우아한테크코스] 숫자 야구 게임 (최종 코테 준비)

YoonJuHo·2023년 12월 4일
1

우아한테크코스

목록 보기
5/5
post-thumbnail

숫자 야구 게임 회고기록

고민 1 : Test Code Format
고민 2 : 방어적 복사
고민 3 : 라이브러리에 대한 Test
<고민 아닌 고민>
- input 예외 검증 Test
- 정규식 검사
- 단일책임원칙?

GitHub Code

Convention

AngularJS commit conventions

docs(README): 기능목록 재정리

Google Java Style Guide

기존의 Google Java Style Guide와 비교했을 때 
크게 달라진 점은 블럭 들여쓰기 2 -> 4로 변경된 것 뿐이다.

Enable google-java-format을 설정 시 Google Java Style Guide로 설정.

DefaultWootecoStyle로 설정되어 있다.


Docs

1. 기능 요구 사항을 읽어가면서 아래 사항들을 정리

  • 전체적인 게임 규칙
  • Domain(컴퓨터, 플레이어)
  • System 유의사항
  • Exception Handling
  • Input
  • Output
  • 사용해야 할 라이브러리

2. 도메인과 입력이 중복되는 부분이 반드시 존재한다.

플레이어 : 서로 다른 3개의 숫자를 입력 | 입력 : 서로 다른 3자리의 수
이 경우 도메인의 비즈니스 로직 예외 검증 부분과, 입력의 단순 입력 예외 검증 부분을 구분하자

3. 중복되는 기능 요구사항들을 합치자.

현재의 경우에는 출력 부분에 입력한 수에 대한 결과를 볼, 스트라이크 개수로 표시는 게임 결과를 출력한다는 의미로, 하나도 없는 경우와 동일한 의미이므로 하나로 합칠 수 있다.
컴퓨터의 플레이어가 입력한 숫자에 대한 결과를 출력한다.와 출력에서 다루는 게임결과 출력은 같은 의미를 내포해 중복을 제거할 수 있지만,
도메인이 어떤 역할을 가지고 있는지 특정하기 위해 합치지 않는다.


Feat

구현 순서

Docs를 살아있는 문서로 만들면서 진행

  • Domain
  • Controller <-> View

<고민 2>

객체(일급 컬렉션 등)를 생성할 때 무심코 아래와 같이 구성했다.

public class GameNumber {
    private final List<Integer> numbers;

    public GameNumber(List<Integer> numbers) {
        this.numbers = numbers;
    }
}

이렇게 구성하게 되면 같은 주소값을 전달받는 것으로 외부에서 내부 변경이 가능해진다.

따라서, 외부에서 내부의 값을 변경할 수 없게끔 방어적 복사를 수행하자!

public class GameNumber {
    private final List<Integer> numbers;
    public GameNumber(List<Integer> numbers) {
        this.numbers = new ArrayList<>(numbers);
    }
}


<고민 3>

라이브러리로 주어지는 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)
            );
        }
    }
}

<고민 아닌 고민> - 단일책임원칙?

Computer가 어떠한 책임을 가지고 있는지 생각해보면,
컴퓨터는 자신의 숫자와 플레이어가 입력한 숫자에 대한 비교 결과를 알 수 있다.는 것이다.
아래와 같이 두가지의 방식을 고민했었는데,

단일책임 원칙에 대해 깊게 고민하기 보다는 해당하는
객체(도메인)이 어떠한 책임을 가지고있는지에 더 집중하는 게 좋을 것 같다.

Computer 아래와 같이 2가지의 책임을 가져도 무방하다.

  • 컴퓨터는 자신의 숫자와 플레이어가 입력한 숫자에 대한 비교 결과를 알 수 있다.
    - <비교 기준> 같은 수가 같은 자리에 있으면 스트라이크
    - <비교 기준> 다른 자리에 있으면 볼
  • 플레이어가 컴퓨터의 수를 모두 맞추면 플레이어가 승리한다.
// 1번째 방식
public Map<GameResult, Long> compare(Numbers userNumbers) {
        long totalCount = numbers.countContains(userNumbers);
        long strikeCount = numbers.countMatching(userNumbers);
        long ballCount = Math.abs(strikeCount - totalCount);
        if (strikeCount == 0 && ballCount == 0) {
            return Map.of(GameResult.NOTHING, totalCount);
        }
        return Map.of(GameResult.STRIKE, strikeCount, GameResult.BALL, ballCount);

    }
// 2번째 방식 
public class Computer {
    private final BaseballNumber baseballNumber;

    public Computer(BaseballNumber baseballNumber) {
        this.baseballNumber = baseballNumber;
    }

    public Map<GameHint, Integer> compare(BaseballNumber userBaseballNumber){
        int strikeCount = baseballNumber.matchCount(userBaseballNumber);
        int ballCount = baseballNumber.containsCount(userBaseballNumber) - strikeCount;
        return GameHint.of(strikeCount, ballCount);
    }
}

public enum GameHint {
    STRIKE("스트라이크"),
    BALL("볼"),
    NOTHING("낫싱");

    private String korean;


    GameHint(String korean) {
        this.korean = korean;
    }

    public static Map<GameHint, Integer> of(int strikeCount, int ballCount) {
        if (strikeCount == 0 && ballCount == 0) {
            return new EnumMap<>(Map.of(NOTHING, 0));
        }
        return new EnumMap<>(Map.of(BALL, ballCount, STRIKE, strikeCount));
    }
}

즉 두가지 방식 모두 사용될 수 있는 구조이고,
굳이 꼽자면 2번째 구조로 구성 할 수 있겠다.


<고민 아닌 고민> - 정규식 검사

아래와 같은 정규식으로 숫자 1부터 9까지 해당하는지 검사할 수 있다.

private static final Pattern NUMERIC_PATTERN = Pattern.compile("^[1-9]*$");

하지만, 위와 같은 정규식도 빈 문자열에 대해서는 맞다고 판단하니 해당 부분에 대해서는

(추가적인 validate가 필요하다)

Option + Enter를 누르면 해당 사진처럼 검사가 가능하다.

isEmpty() 메서드:

  • isEmpty() 메서드는 문자열이 길이가 0인지 확인합니다.
  • 즉, 문자열이 아무 문자도 포함하지 않으면 true를 반환하고, 그렇지 않으면 false를 반환합니다.
  • 예를 들어, "" 또는 new String()과 같은 빈 문자열일 때 true를 반환합니다.
String emptyString = "";
boolean isEmpty = emptyString.isEmpty(); // true

isBlank() 메서드:

  • isBlank() 메서드는 Java 11부터 제공되는 메서드로, 문자열이 비어 있거나(길이가 0) 공백 문자만 포함되어 있는지를 확인합니다.
  • 공백 문자는 일반 공백(whitespace) 문자뿐만 아니라 탭(\t)이나 줄바꿈(\n)과 같은 공백 문자들도 포함합니다.
  • 예를 들어, "" 또는 " "과 같이 공백 문자만 포함되어 있을 때 true를 반환합니다.
String blankString = "   ";
boolean isBlank = blankString.isBlank(); // true

따라서, isEmpty()는 정확히 길이가 0일 때만 참이 되고, isBlank()는 길이가 0이거나 공백 문자만 포함되어 있을 때 참이 됩니다. 선택은 사용하고자 하는 상황과 요구사항에 따라 달라집니다. Java 11 이상을 사용하는 경우, 보다 유연하게 문자열이 비어있거나 공백 문자만 포함되어 있는지를 확인하려면 isBlank()를 사용하는 것이 좋습니다.

결국 둘다 null 체크는 못해주므로 아래와 같이 구성해야 한다.

public class InputView {
    private static final Pattern NUMERIC_PATTERN = Pattern.compile("^[1-9]*$");
    private static final String SEPARATOR = "";

    public List<Integer> inputNumbers() {
        System.out.println("숫자를 입력해주세요 : ");
        String numbers = Console.readLine();
        validateNullAndEmpty(numbers);
        validateNumeric(numbers);
        return Arrays.stream(numbers.split(SEPARATOR))
                .map(Integer::valueOf)
                .collect(Collectors.toUnmodifiableList());
    }

    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까지로 이루어져 있지 않습니다.");
        }
    }
}

Test

<고민 1>

Test코드의 가독성을 향상시키기 위해 @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
를 사용하려 했으나, 더 가독성이 떨어지는 느낌을 받았다.

따라서, 아래와 같이 구성하기로 결정!

@Test
@DisplayName("1에서 9까지의 숫자가 아니라면 예외가 발생한다.")
void validateRange() {
	assertThatThrownBy(() -> new GameNumber(List.of(0, 2, 3)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("숫자는 1에서 9까지의 수로 이루어져야 합니다.");
}

<고민 아닌 고민> - input 예외 검증 Test

InputView에서 아래의 부분의 throw ~로 예외처리되는 부분이 검증이 되지않아 테스트를 진행하지 않아 테스트 코드 커버리지가 떨어지는 걸 볼 수 있다.
생각을 해보니 해당 예외에 대해 어떤식으로 처리되는지를 검사해야 한다고 생각해 해당 단순 입력 검증에 관한 예외 검증 메서드 들은 util로 따로 빼서 활용할 예정이다.

util로 따로 생성해 테스트를 해줄까도 생각해 보았지만 그렇게 하지 않았다.
테스트 코드를 꼼꼼히 작성해 테스트 코드 커버리지가 높아 지는 것은 좋은 일이지만,
코드 커버리지를 높이기 위한 테스트코드 작성은 주객이 전도된 것이다.

테스트는 결함 검출용으로 사용하고, Code Coverage에는 집착하지 마라
결함 검출을 어느 수준까지 할것인지는 본인의 판단이며,
위와 같은 상황에서 아래와 같다면
input 예외처리 테스트 코드 작성의 비용 > 입력 검증 Test 결함으로 생기는 SideEffect
테스트 코드를 굳이 작성하지 않고, input 검증에 관한 부분은 결함이 없다고 생각하고 진행하는 것이 맞다

+입력 검증 Test 결함으로 생기는 SideEffect는 거의 없다고 생각되며, 비즈니스 로직과 연관되는 예외처리도 아니므로 그렇게 Critical한 예외 상황도 없을 것이다.


Refactor

  • 값을 하드코딩하지 않고 상수화 하였는가
    (0을 ZERO로 표현하는 것은 안하느니 못하다)
  • 네이밍 규칙을 잘 따랐는가
  • 패키지 구분을 가독성있게 했는가
  • 접근제한자를 적절하게 사용했는가
  • 출력결과의 순서와 동일한가
  • 처리하지 않은 예외 검사가 있는가
  • 객체지향 생활 체조 원칙을 준수했는가

Code Coverage


참고 블로그

0개의 댓글