미션1. 자동차 경주 - 단위테스트 회고

2023. 11. 15. 01:53·📝 끄적끄적/TDD, 클린 코드 with Java 17기

포비님이 첫 번째 미션은 쉽다고 말하셔서 할만하겠지 했는데 완전 오산이었다.

첫 번째 미션부터 정말 어려웠고, 단기간에 이렇게 많은 블로그를 보고 학습한 적이 있었나 싶을 정도였다.

몇 시간 동안 고민하다 손도 못 댈 때도 있고, 진지하게 내 수준이 아닌 것 같아서 그만둬야 되는 거 아닌가 생각했다 😥

매 단계마다 엄청나게 많은 리뷰가 쏟아졌고, 계속 리팩토링 하면서 '재밌다!'라고 느끼는 게 신기했다!

 

1단계 - 학습 테스트 실습

1단계는 본 미션에 들어가기 전 워밍업 단계로 String과 Set Collection에 대한 테스트를 구현하는 미션이었다.

 

@DisplayName()에는 테스트 대상의 input과 output에 대한 시나리오를 설명하자

처음에는 아래처럼 테스트 이름에 코드 로직을 설명했었다.

@Test
@DisplayName("\"(1,2)\" 값이 주어졌을 때 String의 substring() 메소드를 활용해 ()을 제거하고 \"1,2\"를 반환하도록 구현한다.")
void substring() {
    //given
    String data = "(1,2)";

    //when
    String substring = data.substring(1, 4);

    //then
    assertThat(substring).isEqualTo("1,2");
}

 

테스트 코드의 이름은 이 코드를 테스트할 때 예상되는 시나리오를 작성하는 게 좋다는 멘토님의 리뷰가 있었다.

이에 아래와 같이 변경할 수 있다.

 

`@DisplayName("substring을 사용하면 1번째 글자부터 3개를 추출해 괄호를 제거한 문자열이 반환된다.")`

 

@ParameterizedTest로 테스트 코드 중복을 제거하자

경곗값을 테스트하는 경우에 모든 케이스를 테스트하면 코드가 길어질 뿐만 아니라, 케이스가 추가될 때마다 코드도 추가해주어야 한다. 반복문을 사용하는 거처럼 파라미터를 주입해 여러 테스트케이스를 한 번에 수행할 수 있다.

 

before

@Test
void notUseParameterizedTest() {
    assertThat(numbers).contains(1);
    assertThat(numbers).contains(2);
    assertThat(numbers).contains(3);
}

 

after

@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
@DisplayName("JUnit의 ParameterizedTest를 활용해 중복 코드를 제거해 본다.")
void useParameterizedTest(int number) {
    assertThat(numbers).contains(number);
}

 

`@CsvSource`로 key, value를 직접 주입할 수도 있다.

@ParameterizedTest
@CsvSource(value = {"1:true", "2:true", "3:true", "4:false", "5:false"}, delimiter = ':')
@DisplayName("contains 메소드 시 1, 2, 3값은 true / 4,5 값은 false가 반환되는 테스트를 하나의 Test Case로 구현한다.")
void useCsvSource(int number, boolean expected){
    assertThat(numbers.contains(number)).isEqualTo(expected);
}

 

2단계 - 문자열 덧셈 계산기

두 숫자를 더하는 덧셈 계산기의 기능에 대해 테스트 코드를 구현하는 미션이었다.

요구사항은 메서드가 너무 많은 일을 하지 않도록 분리하는 것이었다.

기능 요구사항 분리를 잘해야 테스트 코드 작성이 쉬울 것 같다고 생각했다.

상수를 사용하자

1. 인스턴스 상수화

아래 코드는 pattern을 사용할 때마다 새로운 인스턴스가 생성된다.

이런 경우 인스턴스 그 자체를 상수로 만든다면 매번 인스턴스를 생성하지도 않고 full GC의 발생 빈도도 낮출 수 있다.

 

before

Matcher m = Pattern.compile("//(.)\n(.*)").matcher(text);

 

after

private static final Pattern CUSTOM_DELIMITER_PATTERN = Pattern.compile("//(.)\n(.*)");

Matcher m = CUSTOM_DELIMITER_PATTERN.matcher(text);

 

2. 매직넘버 상수화

클린 코드의 궁극적인 목표는 모르는 사람이 코드를 봤을 때 잘 읽히는 코드이다.

매직넘버에 이름을 부여해 가독성과 유지보수성을 높일 수 있다.

 

before

Matcher m = CUSTOM_DELIMITER_PATTERN.matcher(text);

if (m.find()) {
    return m.group(0).split(m.group(1));
}

 

after

private static final int CUSTOM_DELIMITER_INDEX = 1;
private static final int TEXT_INDEX = 2;

Matcher m = CUSTOM_DELIMITER_PATTERN.matcher(text);

if (m.find()) {
    return m.group(TEXT_INDEX).split(m.group(CUSTOM_DELIMITER_INDEX));
}

 

@assertThatThrownBy

예외 발생 여부를 확인한다.

특정 예외를 확인할 수 있고, 메시지의 내용도 검증할 수 있다.

@Test
@DisplayName("음수를 전달할 경우 RunTimeException 예외가 발생한다.")
void splitAndSum_negative() {
    assertThatThrownBy(() -> StringAddCalculator.splitAndSum("-1,2,3"))
            .isInstanceOf(RuntimeException.class)
            .hasMessageContaining("숫자 이외의 값 또는 음수가 입력됨");
}

 

 

3단계 - 자동차 경주

본격적으로 자동차 경주 게임을 구현하는 미션이다.. 기능 요구사항을 분리하는 게 가장 어려웠다😫

요구사항은 아래와 같다.

1. 모든 로직에 단위 테스트를 구현한다.

2. else 예약어를 쓰지 않는다.

3. 자바 코드 컨벤션을 지킨다.

4. 깃 커밋 메시지 컨벤션을 지킨다.

 

멱등성을 보장하지 않을 때 테스트 코드 작성

💡 멱등성이란?
: 여러번 실행시켜도 그 결과가 동일한 것을 의미한다.

 

자동차가 전진하는 조건은 0에서 9 사이의 random 값을 구한 후 그 값이 4 이상이면 전진한다.

위 요구사항을 다음과 같이 구현하였다.

public class Car {
    public static final int CAN_MOVE_VALUE = 4;
    private int position;

    public Car() {
        this.position = 0;
    }

    public void move() {
        Random random = new Random();
        int randomValue = random.nextInt(10);

        if(randomValue >= CAN_MOVE_VALUE) {
            position += 1;
        }
    }
}

 

위와 같이 move()안에서 랜덤 값을 생성해 이 값으로 4 이상인지 체크하니 테스트 코드 작성이 불가능했다.

그래서 테스트를 위해 move 메소드를 오버로딩했었다..😞

`LocalDateTime.now()`, 난수 생성 등은 멱등성이 지켜지지 않는 대표적인 사례다.

//테스트를 위한 오버로딩ㅠㅠ
public void move(int randomValue) {
    if(randomValue >= CAN_MOVE_VALUE) {
        position += 1;
    }
}

테스트를 위해 별개의 메소드를 생성하는 건 바람직하지 못하다. 

또 리뷰어님이 테스트 코드 작성이 어려울 때는 역할이 잘 분리되지 않았는지 의심할 필요가 있다고 하셨다.

ex) '랜덤값생성'이라는 행위가 `Car`의 책임이 맞을까?

 

이에 `Car`객체에서 `Random`을 직접 의존하지 말고 숫자 생성기라는 인터페이스를 만들어 랜덤값을 생성하는 전략 패턴을 사용했다.

public interface NumberGenerator {
    int nextInt(int bound);
}
public class RandomGeneratorImpl implements NumberGenerator {

    private static final Random RANDOM = new Random();

    @Override
    public int nextInt(int bound) {
        return RANDOM.nextInt();
    }
}

 

위 숫자 생성기를 통해 `Car`는 주입받은 숫자 생성기에게 값을 전달받고, 이 값이 4 이상인지 확인하면 된다.

public class Car {

    private static final int MIN_MOVE_VALUE = 4;
    private static final int INITIAL_POSITION = 1;

    private int position;
    private NumberGenerator numberGenerator;

    public Car(NumberGenerator numberGenerator) {
        this.position = INITIAL_POSITION;
        this.randomGenerator = randomGenerator;
    }

    public void move() {
        go(numberGenerator.nextInt(10));
    }

    public void go(int randomValue) {
        if (randomValue >= MIN_MOVE_VALUE) {
            position += 1;
        }
    }

    public int getPosition() {
        return position;
    }
}

 

테스트 코드 작성이 가능해졌다!

@ParameterizedTest
@ValueSource(ints = {4, 5, 6, 7, 8, 9})
@DisplayName("random 값이 4이상일 경우 자동차가 1칸 전진한다.")
void moveCar(int randomValue) {
    Car car = new Car();

    car.move(randomValue);

    int position = car.getPosition();
    assertThat(position).isEqualTo(2);
}

 

변수명에 컬렉션의 이름을 사용하지 말자

before

public class Cars {
    private List<Car> carList = new ArrayList<>();
}

 

자동차 경주에 참가한 자동차들이라는 일급 컬렉션 클래스 `Cars`에 변수명까지 `cars`를 사용하면 헷갈릴 것 같아서 

`carList`라고 지었는데, 변수 이름에 자료형이 들어가면 다른 자료형(Set..)으로 변경될 경우 변수명도 수정해줘야 한다.

 

after

public class RacingCars {
    private final List<Car> cars;
}

List, Collection 등의 자료형은 복수형으로 표현하자.

 

 

4단계 - 자동차 경주(우승자)

각 자동차에 이름을 부여하고, 게임이 끝나면 우승자를 출력하는 기능을 추가했다.

요구사항

1. 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.

2. 메서드의 코드 라인이 15가 넘지 않도록 구현한다.

 

원시값 포장

자동차에 이름을 부여하는 기능을 아래와 같이 구현했다.

public class Car {

    private int position = 1;
    private final String name;

    public Car(String name) {
    	validateNameBlank(name.trim());
        validateNameLength(name.trim());
        this.name = name;
    }
    
    private void validateNameBlank(String name) {
        if (name == null || name.isEmpty()) {
            throw new InvalidNameException("자동차의 이름이 입력되지 않았습니다.");
        }
    }

    private void validateNameLength(String name) {
        if (name.length() > MAX_NAME_LENGTH) {
            throw new InvalidNameException("자동차의 이름은 5자를 초과할 수 없습니다." + name);
        }
    }
}

 

만약 자동차의 속성 즉 변수가 추가된다면 해당 속성에 대한 기능(메서드)이 Car 클래스에 계속 추가될 것이다. 

원시값 포장을 통해 '이름'이라는 객체로 포장할 수 있다.

public class Car {

    private final int position;
    private final Name name;

    public Car(Name name) {
        this(new Position(), name);
    }
}
public class Name {

    private static final int MAX_NAME_LENGTH = 5;

    private final String name;

    public Name(String name) {
        validateNameBlank(name.trim());
        validateNameLength(name.trim());
        this.name = name;
    }

    private void validateNameBlank(String name) {
        if (name == null || name.isEmpty()) {
            throw new InvalidNameException("자동차의 이름이 입력되지 않았습니다.");
        }
    }

    private void validateNameLength(String name) {
        if (name.length() > MAX_NAME_LENGTH) {
            throw new InvalidNameException("자동차의 이름은 5자를 초과할 수 없습니다." + name);
        }
    }

    public String getName() {
        return name;
    }
}

 

컬렉션을 반환할 때 불변으로 리턴하자

public class Racing {
    public List<Car> getRacingCars() {
        return racingCars.getCars();
    }
}

getter 사용 시 불변으로 내보내지 않으면 외부에서 컬렉션에 침투해 값을 변경할 수 있다.

 

`Collection.toUnmodifiableList`을 사용해 불변으로 바꾸어서 반환하도록 수정했다.

public class Racing {
	public List<Car> getRacingCars() {
        return racingCars.getCars().stream()
        		.collect(Collectors.toUnmodifiableList());
    }
}

 

 

5단계 - 자동차 경주(리팩토링)

5단계는 MVC 패턴 기반으로 Controller, Domain, View의 역할로 구분하고,

테스트하기 힘든 부분을 분리해 단위 테스트하는 과정이었다.

난 이미 전 과정에서 리팩토링 해왔어서 개인적으로 리팩토링 하고 싶은 부분을 수정했다!

 

전략패턴 사용

💡 전략패턴이란?
: 유사한 행위들을 캡슐화하는 인터페이스를 정의하여, 객체의 행위를 동적으로 바꾸고 싶을 때
직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 방법을 말한다.

 

3단계에서 자동차 전진에 대해 '숫자 생성기'라는 인터페이스를 통해 값을 전달받는 식으로 수정했었다.

이것도 전략패턴이긴 하지만 또 다른 전략패턴으로 리팩토링 해보았다.

왜 필요했나?

현재 자동차 전진의 요구사항은 '랜덤값이 4이상이면 움직인다'이다.

하지만 전진 요구사항이 바뀐다면 Car.move()내의 코드를 계속 수정해주어야 하기 때문이다.

 

'전진한다'는 행위에 대한 전략을 인터페이스로 정의하고,  '랜덤값이 4이상이면 움직인다'에 대한 구현체를 만들었다.

(지금 보니 `RANDOM.nextInt(BOUND)`도 외부에서 주입받는 게 좋아 보인다.)

public interface MoveStrategy {
    boolean isMovable();
}

public class RandomMoveStrategy implements MoveStrategy {
    private static final Random RANDOM = new Random();
    private static final int BOUND = 10;
    private static final int MOVE_CONDITION_NUMBER = 4;

    @Override
    public boolean isMovable() {
        return RANDOM.nextInt(BOUND) >= MOVE_CONDITION_NUMBER;
    }
}

 

이 전략패턴을 아래와 같이 사용하고, 만약 조건이 바뀐다면 새로운 전략으로 갈아 끼우면 된다.

public class car {
    public void move(MoveStrategy moveStrategy) {
        if (moveStrategy.isMovable()) {
            position.plusPosition();
        }
    }
}

 

 


 

자동차 경주 미션을 수행하면서 참고했던 링크를 보려면 더보기를 클릭!

⬇

더보기

Java

  • [JAVA] 상수, 매직 넘버(Magic Number)
  • 좋은 코드를 위한 자바 변수명 네이밍

테스트 코드

  • [Test Code] 테스트 코드 작성의 기본기 (Introduction to AsertJ)
  • [TDD] JUnit5 단위테스팅 총 정리(정의, 어노테이션)
  • Introduction to AssertJ | Baeldung
  • AssertJ 주요 기능 공부
  • 테스트를 작성하는 방법
  • JUnit - @ParameterizedTest, @ValueSource, @CsvSource, @MethodSource 어노테이션
  • AssertJ - Exception
  • JUnit 5 User Guide

Git

  • [Git/Github] Commit Convention이란? | Jumy
  • [Git] Git Rebase란? (feat. git-flow 히스토리를 더 이쁘게 만들기) — SH's Devlog

OOP

  • 일급 컬렉션 (First Class Collection)의 소개와 써야할 이유
  • 일급 컬렉션을 사용하는 이유
  • 왜 Constructor Injection을 사용해야 하는가?
  • 디미터 법칙(Law of Demeter)
  • [객체지향 생활체조 원칙] 규칙 9. getter/setter/property를 쓰지 않는다
  • [객체지향 생활체조 원칙] 규칙 4. 한 줄에 점을 하나만 찍는다
  • static method만을 가지는 utility class는 private 생성자를 가지도록 구현한다. :: SLiPP

전략패턴

  • 💠 전략(Strategy) 패턴 - 완벽 마스터하기
  • [Spring] 예제로 알아보는 [ 전략 패턴 ]
  • [디자인패턴] 전략 패턴 ( Strategy Pattern ) :: victolee
  • [10분 테코톡] 📣 완태의 전략패턴 - YouTube

 

저작자표시 비영리 (새창열림)

'📝 끄적끄적 > TDD, 클린 코드 with Java 17기' 카테고리의 다른 글

미션2. 로또 - TDD 회고  (0) 2023.11.29
TDD, 클린 코드 with Java 17기를 시작하며  (0) 2023.11.05
'📝 끄적끄적/TDD, 클린 코드 with Java 17기' 카테고리의 다른 글
  • 미션2. 로또 - TDD 회고
  • TDD, 클린 코드 with Java 17기를 시작하며
현주먹
현주먹
대구 불주먹 출신 현주먹의 개발.log
  • 현주먹
    현주먹의 개발로그
    현주먹
  • 전체
    오늘
    어제
    • 전체글 (179)
      • 👶🏻 CS (15)
        • Operating System (7)
        • DB (5)
        • Data Structure (2)
        • Software Engineering (1)
      • 💻 Dev (54)
        • Java & OOP (24)
        • Spring (4)
        • DB&JPA (6)
        • Test Code (1)
        • JSP & Servlet (13)
        • Etc (6)
      • 💡 Algorithm (25)
        • 인프런 (9)
        • 백준 (16)
      • 🛠 DevOps & Tool (11)
        • Linux (4)
        • AWS (1)
        • Git (2)
        • Etc (4)
      • 📝 끄적끄적 (74)
        • 후기 및 회고 (11)
        • TDD, 클린 코드 with Java 17기 (3)
        • F-Lab (23)
        • 🖥️ 자바의 정석 (11)
        • 📖 Clean Code (3)
        • 항해99 코테 스터디 (11)
        • 📖 가상 면접 사례로 배우는 대규모 시스템 설계 .. (11)
  • 블로그 메뉴

    • 🐈‍⬛ GitHub
    • TIL repository
  • 인기 글

  • 최근 글

  • 최근 댓글

  • 태그

    til
    코딩테스트준비
    jsp 2.3 웹 프로그래밍: 기초부터 중급까지
    NextSTEP
    f-lab 후기
    에프랩 후기
    개발자멘토링
    JPA
    자바의신절판
    코테스터디
    jsp
    항해99
    자바의정석
    C
    백준
    2025스프링캠프
    개발자취업
    객체지향
    TDD 클린 코드 with Java
    로또 미션
    데브클럽
    오블완
    에프랩
    99클럽
    인프런 특정문자뒤집기
    티스토리챌린지
    오라클
    F-Lab
    ==와 equals()
    개구리책
  • hELLO· Designed By정상우.v4.10.2
현주먹
미션1. 자동차 경주 - 단위테스트 회고
상단으로

티스토리툴바