포비님이 첫 번째 미션은 쉽다고 말하셔서 할만하겠지 했는데 완전 오산이었다.
첫 번째 미션부터 정말 어려웠고, 단기간에 이렇게 많은 블로그를 보고 학습한 적이 있었나 싶을 정도였다.
몇 시간 동안 고민하다 손도 못 댈 때도 있고, 진지하게 내 수준이 아닌 것 같아서 그만둬야 되는 거 아닌가 생각했다 😥
매 단계마다 엄청나게 많은 리뷰가 쏟아졌고, 계속 리팩토링 하면서 '재밌다!'라고 느끼는 게 신기했다!
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
테스트 코드
- [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
전략패턴
'📝 끄적끄적 > TDD, 클린 코드 with Java 17기' 카테고리의 다른 글
미션2. 로또 - TDD 회고 (0) | 2023.11.29 |
---|---|
TDD, 클린 코드 with Java 17기를 시작하며 (0) | 2023.11.05 |