우아한테크코스 레벨 1 미션을 진행하면서 무작위로 숫자를 생성하는 기능이 사용된 메서드를 테스트 하는 것에서 난항을 겪었다.
우여곡절 끝에 1단계 미션의 기능을 구현했지만, 2단계 미션에서 아래와 같이 테스트 가능한 부분과 불가능한 부분을 분리해 테스트 가능한 부분에서 단위 테스트를 진행하라는 요구사항이 나왔다.
그래서 이번 포스팅에서 내 나름대로 이 방법을 고민하고 반영한 과정에 대해서 기록해보려 한다.
메서드 시그니처를 수정하기
public CarStatus move() {
int power = RandomNumberGenerator.generate();
if (power >= POWER_LOWER_BOUND) {
position++;
}
return new CarStatus(carName.getName(), position);
}
다음과 같은 코드는 어떻게 테스트할 수 있을까?
객체를 객체스럽게 사용하기 위해서는 객체의 역할을 분리해야한다. RandomNumberGenerator와 Car의 강한 결합을 분리해서 테스트가 가능한 구조로 만들어야 한다.
이를 다음과 같이 power를 주입받는 코드로 변경해보자.
public CarStatus move(int power) {
if (power >= POWER_LOWER_BOUND) {
position++;
}
return new CarStatus(carName.getName(), position);
}
이렇게 분리하고 나면 매개변수를 임의로 주입하면서 아래와 같이 성공, 실패 테스트를 구현하기가 쉬워진다.
class CarTest {
@Test
void carMoveTest() {
Car car = new Car(new CarName("toby"));
int power = 6;
car.move(power);
Assertions.assertThat(car.getPosition()).isEqualTo(1);
}
@Test
void carDoesNotMoveTest() {
Car car = new Car(new CarName("toby"));
int power = 1;
car.move(power);
Assertions.assertThat(car.getPosition()).isEqualTo(0);
}
}
여기까지가 아래 테코블에서 알게 된 내용이다.
https://tecoble.techcourse.co.kr/post/2020-05-07-appropriate_method_for_test_by_parameter/
고마워요 스티치!
함수형 인터페이스 생성
클래스의 생성자에 인터페이스를 받도록 구현하고, 테스트 클래스에서 구현체를 정의하여 내가 원하는 정수를 반환하도록 하는 것이다.
@FunctionalInterface
public interface NumberGenerator {
int generate();
}
왜 함수형 인터페이스인가요?
함수형 인터페이스는 추상 메서드가 오직 하나인 인터페이스를 말한다. 그냥 인터페이스와 다르게 함수형 인터페이스는 람다식으로 표현할 수 있다라는 것이 큰 장점이다. 테스트 코드를 작성할 때 람다식을 사용해서 간단하게 랜덤 숫자 생성기를 파라미터에 넣을 수 있다.
이 인터페이스를 구현하여 RandomNumberGenerator를 작성하였고, NumberGenerator 인터페이스를 매개변수로 받아 Cars에서 사용하는 것으로 수정하였다.
public class RandomNumberGenerator implements NumberGenerator {
private static final int MAX_RANDOM_UPPER_BOUND = 10;
@Override
public int generate() {
Random random = new Random();
return random.nextInt(MAX_RANDOM_UPPER_BOUND);
}
}
public CarsStatus move(NumberGenerator generator) {
List<CarStatus> carStatuses = new ArrayList<>();
for (Car car : cars) {
int number = generator.generate();
carStatuses.add(car.move(number));
}
return new CarsStatus(carStatuses);
}
왜 NumberGenerator를 매개변수로 받았나요?
전략 패턴을 적용하기 위해서이다.
전략 패턴이란 객체들이 할 수 있는 행위에 대한 전략 클래스를 생성하여 여러 가지 유사한 행위를 캡슐화 하는 인터페이스를 사용하는 것이다.
자동차가 움직이기 위한 파워를 생성하는 여러 가지(가 될 수 있는) 전략을 하나의 인터페이스로 통일하고, 그 전략을 선택하는 역할을 클라이언트에게 부여하는 것이다.
여기서 클라이언트는 Cars의 move() 함수를 호출하는 RacingCarGame이 되겠다.
RacingGame에서 Cars에 구현체를 생성하여 파라미터로 주입해주면, Cars는 그 구현체에서 정의한 숫자 생성 전략에 따라 숫자를 생성하고 Car의 move()로 인자를 넘기게 된다.
results.add(cars.move(new RandomNumberGenerator()));
그럼 테스트 코드를 작성할 때, 이렇게 인터페이스를 사용해 생성한 임의의 클래스를 통해 내가 랜덤 숫자로서 반환하고 싶은 숫자를 지정할 수 있게 된다.
@DisplayName("한 명의 우승자가 결정된다.")
@Test
void oneWinnerJudgeTest() {
// given
List<String> carNames = List.of("toby", "pobi", "anna");
Cars cars = Cars.from(carNames);
// when
cars.move(new TestOneWinnerGenerator());
Winners winners = cars.judge();
// then
Assertions.assertThat(winners.winners().size()).isEqualTo(1);
Assertions.assertThat(winners.winners().get(0)).isEqualTo("anna");
}
static class TestOneWinnerGenerator implements NumberGenerator {
private static List<Integer> numbers = List.of(3, 2, 9);
private static int index = 0;
@Override
public int generate() {
return numbers.get(index++);
}
}
여기까지는 아래 테코블에서 알게 된 내용이다.
https://tecoble.techcourse.co.kr/post/2020-05-17-appropriate_method_for_test_by_interface/
고마워요 스티치!
움직임 전략을 Car의 필드로 두기
더 개선할 수 없을까?
추상화 레벨을 조금 더 올리고 싶었다.
이전 설계는 NumberGenerator가 숫자라는 요소에 국한되어 있다는 특징이 있다. 자동차가 움직이는 전략은 아주 다양하게 될 수 있다. 예를 들어, 숫자가 아니라 영어 대문자 P를 입력해야 움직이게 될 수 있고 동전을 던졌을 때 동전 뒷면이 나온 경우가 될 수 있다.
그러나 전략이 어떻든 변하지 않는 것은 차는 움직이거나, 움직이지 않거나 둘 중 하나라는 것이다. 나는 이러한 불변 요소를 움직임 전략의 결괏값으로 일반화하여 인터페이스로 정의하였다.
그러면 MoveStrategy라는 움직임 전략 인터페이스를 생성해서 자동차마다 서로 다른 전략을 가질 수 있도록 하는 건 어떨까?
@FunctionalInterface
public interface MoveStrategy {
boolean canMove(int minPower);
}
이번에도 마찬가지로 함수형 인터페이스를 구현하였다.
MoveStrategy는 자동차가 움직일 수 있는지, 아닌 지를 클라이언트에게 알려줄 필요가 있다.
따라서 canMove() 를 유일한 메서드로 선언하고 이를 구현한 클래스에서 내부 구현을 작성해주었다.
public class RandomMoveStrategy implements MoveStrategy{
private static final int MAX_UPPER_BOUND = 10;
@Override
public boolean canMove(int threshold) {
Random random = new Random();
int number = random.nextInt(MAX_UPPER_BOUND);
if(number >= threshold) {
return true;
}
return false;
}
}
요구사항에 명시된 것처럼, 무작위로 생성된 숫자가 일정 기준 (threshold) 이상이라면 true, 그렇지 않으면 false를 반환하도록 구현하였다.
public class Car {
private CarName carName;
private int position;
private MoveStrategy moveStrategy;
private Car(CarName carName, MoveStrategy moveStrategy) {
this.carName = carName;
this.position = 0;
this.moveStrategy = moveStrategy;
}
...
public CarStatus move() {
if (moveStrategy.canMove(POWER_LOWER_BOUND)) {
position++;
}
return new CarStatus(carName.getName(), position);
}
그리고 이를 Car가 내부 필드로 가지고 move 함수에서 사용할 수 있도록 했다.
초기 설계와 다르게 객체에 메시지를 보낼 수 있어 훨씬 가독성이 좋고, 전략 패턴을 사용하여 유지보수성에 좋은 코드가 완성이 되었다.
그럼 테스트 코드는 어떻게 작성하면 될까?
public class TestMoveStrategy {
static class AlwaysMoveStrategy implements MoveStrategy {
@Override
public boolean canMove(final int minPower) {
return true;
}
}
static class AlwaysDontMoveStrategy implements MoveStrategy {
@Override
public boolean canMove(final int minPower) {
return false;
}
}
}
우선 이렇게 테스트를 위한 움직임 전략을 만들어 놓는다.
항상 이동하는 전략과 항상 이동하지 않는 전략을.
그럼 자동차 이동 테스트에서는 항상 자동차가 이동하거나 이동하지 않도록 Car를 생성 시 움직임 전략 구현 클래스를 넣어주면 원하는 대로 테스트가 가능하다.
@DisplayName("자동차 이동 테스트")
@Nested
class MoveTest {
@DisplayName("자동차의 파워가 4 이상이어서 자동차가 이동한다.")
@Test
void carMoveTest() {
Car car = Car.of("toby", new AlwaysMoveStrategy());
car.move();
Assertions.assertThat(car.getPosition()).isEqualTo(1);
}
@DisplayName("자동차의 파워가 4 미만이어서 자동차가 이동하지 않는다.")
@Test
void carDoesNotMoveTest() {
Car car = Car.of("toby", new AlwaysDontMoveStrategy());
car.move();
Assertions.assertThat(car.getPosition()).isEqualTo(0);
}
}
그럼 Cars는요 ? 저 모든 차를 움직이는 move() 와 우승자를 결정하는 judge()를 테스트 해야하는데요?
Cars는 Car의 move() 함수를 내부적으로 호출한다.
생성 시점에서 Car의 생성자를 일일이 호출하면서 List<Car>를 만들기 때문에, Cars 테스트 같은 경우 List<Car> 생성자를 이용하여 위에서 정의한 테스트 움직임 전략 클래스를 활용할 수 있다.
그런데 저는 생성자는 private 로 막아놓았는데요?
테스트를 위해 프로덕션 코드의 내용을 바꾸는 건 탐탁치 않은 일이다. 그러나 테스트도 중요한 부분이기 때문에, 이를 해결하기 위해 에너지를 소모하는 것보다 트레이드 오프를 고려해 타협점을 찾는 것 또한 중요하다.
protected Cars(List<Car> cars) {
this.cars = cars;
}
이런 경우, private로 막아놓았던 생성자를 protected 로 바꾸어 같은 패키지에 위치한 클래스들은 모두 이 생성자를 사용할 수 있도록 할 수도 있다. 그러면 외부 패키지는 이 함수를 사용하지 못하기 때문에 무분별한 생성을 막을 수 있다.
우승자를 결정하는 테스트 코드로 글을 마무리하려 한다.
@DisplayName("우승자 결정 테스트")
@Nested
class JudgeTest {
@DisplayName("한 명의 우승자가 결정된다.")
@Test
void oneWinnerJudgeTest() {
// given
List<Car> givenCars = List.of(
Car.of("toby", new AlwaysMoveStrategy()),
Car.of("pobi", new AlwaysDontMoveStrategy()),
Car.of("anna", new AlwaysDontMoveStrategy())
);
Cars cars = new Cars(givenCars);
// when
cars.move();
Winners winners = cars.judge();
// then
Assertions.assertThat(winners.winners().size()).isEqualTo(1);
Assertions.assertThat(winners.winners().get(0)).isEqualTo("toby");
}
@DisplayName("두 명의 우승자가 결정된다.")
@Test
void bothWinnerJudgeTest() {
// given
List<Car> givenCars = List.of(
Car.of("toby", new AlwaysMoveStrategy()),
Car.of("pobi", new AlwaysMoveStrategy()),
Car.of("anna", new AlwaysDontMoveStrategy())
);
Cars cars = new Cars(givenCars);
// when
cars.move();
Winners winners = cars.judge();
// then
Assertions.assertThat(winners.winners().size()).isEqualTo(2);
Assertions.assertThat(winners.winners().get(0)).isEqualTo("toby");
Assertions.assertThat(winners.winners().get(1)).isEqualTo("pobi");
}
}
무작위 생성 기능을 사용하는 메서드를 완벽하게 테스트할 수 없지만, 전략 패턴을 통해 객체 간 느슨한 결합을 구현하여 여러 객체를 테스트 가능하게 만들 수 있다.
끗!
'우아한테크코스 > 레벨1' 카테고리의 다른 글
[회고] 우아한테크코스 6기 백엔드 레벨1 3주차 회고 (0) | 2024.03.03 |
---|---|
[TDD] 1단계 `사다리 생성 미션` 페어 프로그래밍 & 코드 리뷰 회고 (4) | 2024.03.03 |
[회고] 우아한테크코스 6기 백엔드 레벨1 2주차 회고 (0) | 2024.02.25 |
[UnitTest] 🚗자동차 경주🚗 페어 프로그래밍 & 리팩토링 회고 (4) | 2024.02.23 |
[회고] 우아한테크코스 6기 백엔드 레벨1 1주차 회고 (4) | 2024.02.19 |