포비님이 첫 번째 미션은 쉽다고 말하셔서 할만하겠지 했는데 완전 오산이었다.
첫 번째 미션부터 정말 어려웠고, 단기간에 이렇게 많은 블로그를 보고 학습한 적이 있었나 싶을 정도였다.
몇 시간 동안 고민하다 손도 못 댈 때도 있고, 진지하게 내 수준이 아닌 것 같아서 그만둬야 되는 거 아닌가 생각했다 😥
매 단계마다 엄청나게 많은 리뷰가 쏟아졌고, 계속 리팩토링 하면서 '재밌다!'라고 느끼는 게 신기했다!
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 |