TDD로 개발하기 3탄

카일·2020년 2월 17일
2
post-thumbnail

모든 코드는 여기를 클릭 하시면 확인 하실 수 있습니다.

안녕하세요. 2탄에서는 사용자와 함께 플레이 할 이름이 입력되었을 때 다양한 상황에 대해서 구현해보았습니다. 이번 포스팅에서는 아래와 같은 부분을 진행하겠습니다.

  • 2탄에서 구현한 코드를 리팩토링하여 경주에 필요한 Car와 Cars를 생성
  • 추가 리팩토링 : 레이싱에 참여한 자동차를 관리하는 RacingCar 클래스 생성 (Names의 기능 이전)
  • 패키지 분류
  • 플레이하고자 하는 라운드 수를 입력 받고 검증

리팩토링 : Name클래스를 Car 클래스로 변환

  • 사용자에게 입력받은 이름을 바탕으로 플레이어는 본인의 차를 선택하는 게임이기에 플레이어 각각에게 이름이 달린 차를 제공하는 방식으로 리팩토링을 해보겠습니다. 사실 자동차와 이름은 별개의 개념입니다. 자동차는 이름을 가지고 있지만 추가적으로 다른 요소들 또한 가지고 있을 수 있습니다. 자동차를 이루는 구성요소는 각각을 클래스의 형태로 검증과 필요한 기능을 가진 즉, 상태와 행동을 모두 가진 클래스의 형태로 분리하는 것이 원론적으로는 맞는 방법이라 생각합니다. 하지만 현재는 자동차에서 이름이 자동차를 식별하는 유일한 식별자로서 사용되기에 이름에 대한 검증 부분은 자동차에게 조금 더 적합하다고 생각하여 Name이라는 클래스 및 테스트를 자동차로 옮기는 리팩토링입니다. 물론 자동차에 여러 요소가 들어오고 각각의 요소가 별개의 클래스로서의 기능을 하게 되면서 식별자의 역할을 할 수 있는 다른 값이 생긴다면 Name 또한 클래스로 분리하는 것이 좋을 것 같습니다.
  • Name을 검증하는 테스트 코드를 자동차 테스트 코드로 변경하였습니다. 내부 로직 및 테스트는 완벽하게 일치합니다.
    package kail.study.java.racing;
    
    import static org.assertj.core.api.Assertions.*;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.ValueSource;
    
    public class CarTest {
    	@ParameterizedTest
    	@ValueSource(strings = {"kyle", "hodle", "pobi"})
    	@DisplayName("이름 규칙에 문제가 없어서 정상적으로 생성되는 경우")
    	void 정상적인_경우(String name) {
    		assertThat(new Car(name));
    	}
    
    	@ParameterizedTest
    	@ValueSource(strings = {"overLength", "helloWorld", "ImTheKing"})
    	void 길이_초과(String name) {
    		assertThatThrownBy(() -> {
    			new Car(name);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("길이가 초과");
    	}
    
    	@ParameterizedTest
    	@ValueSource(strings = {"", "   ", "            "})
    	void 공백_예외(String name) {
    		assertThatThrownBy(() -> {
    			new Car(name);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("공백은 입력할 수 없습니다.");
    	}
    
    	@Test
    	void 널이라서_예외() {
    		assertThatThrownBy(() -> {
    			new Car(null);
    		}).isInstanceOf(NullPointerException.class)
    			.hasMessageContaining("널값은 입력할 수 없습니다.");
    	}
    
    }
    package kail.study.java.racing;
    
    import java.util.Objects;
    
    public class Car {
    	public static final int MAX_LENGTH = 5;
    
    	private final String name;
    
    	public Car(String name) {
    		validate(name);
    		this.name = name;
    	}
    
    	private void validate(String name) {
    		checkNull(name);
    		checkBlank(name);
    		checkLength(name);
    	}
    
    	private void checkNull(String name) {
    		if(name == null)
    			throw new NullPointerException("널값은 입력할 수 없습니다.");
    	}
    
    	private void checkBlank(String name) {
    		if(name.trim().isEmpty())
    			throw new IllegalArgumentException("공백은 입력할 수 없습니다.");
    	}
    
    	private void checkLength(String name) {
    		if (name.length() > MAX_LENGTH)
    			throw new IllegalArgumentException("길이가 초과되었습니다.");
    	}
    
    	@Override
    	public boolean equals(Object o) {
    		if (this == o)
    			return true;
    		if (o == null || getClass() != o.getClass())
    			return false;
    		Car car = (Car)o;
    		return Objects.equals(name, car.name);
    	}
    
    	@Override
    	public int hashCode() {
    		return Objects.hash(name);
    	}
    
    	public String getName() {
    		return name;
    	}
    }

리팩토링 : 레이싱에 참여한 자동차를 관리하는 RacingCar 클래스 생성 (Names의 기능)

  • 2탄에서 Names의 중복 처리를 위해 생성 된 이름들을 관리하는 Names 클래스를 생성하고 테스트 하였습니다. 하지만 3탄에서 Name 이 자동차의 식별자로 사용되기 때문에 자동차의 일부분으로 들어오게 되었고 Names 또한 레이싱 게임에서 사용되는 자동차 전체를 가지고 있는 RacingCar라는 객체를 생성하여 그 일부로 변경하도록 하겠습니다. Names와 현재까지는 정확하게 동일한 기능을 하고 있습니다. 추가적으로 작업을 해야 하는 부분은 뒤쪽에 다른 기능을 개발하면서 추가하겠습니다. (테스트도 똑같이 RacingCarTest 로 옮겨주시면 됩니다)
    package kail.study.java.racing;
    
    import java.util.List;
    import java.util.Set;
    import java.util.stream.Collectors;
    
    public class RacingCars {
    	private final List<Car> cars;
    
    	public RacingCars(List<Car> cars) {
    		validate(cars);
    		this.cars = cars;
    	}
    
    	private void validate(List<Car> names) {
    		checkDuplication(names);
    	}
    
    	private void checkDuplication(List<Car> names) {
    		Set<Car> set = names.stream().collect(Collectors.toSet());
    		if(set.size() != names.size())
    			throw new IllegalArgumentException("중복된 이름이 존재합니다.");
    	}
    }

패키지 분류

  • 현재 프로젝트 구조입니다. 보시다시피 도메인의 역할을 하는 부분과 View의 역할을 하는 부분이 존재하며 Utility 클래스가 존재하기에 패키지별로 묶어서 분류를 해보도록 하겠습니다.

  • 도메인의 기능을 담당하고 있는 Car와 RacingCars 를 도메인 패키지에 추가하고 Utility 클래스는 Util 패키지로, View를 담당하고 있는 부분을 View 패키지로 분리해 전체적으로 MVC 패턴을 따르는 형태로 분리하였습니다.

기능 2 : 플레이하고자 하는 라운드 수를 입력 받고 검증

  • 지금까지는 사용자의 이름을 입력 받고 그 이름을 기반으로 각각의 자동차가 정상적으로 생성되는지 테스트 및 구현을 했다. 기능 2에서는 몇 라운드를 진행하고 싶은지에 대해 입력 받고 그 라운드를 검증하는 부분을 작성해보려 한다.

  • 라운드와 관련된 규칙은 아래와 같고, 아래의 규칙들을 하나씩 Test —> Production —> Refactoring 하는 형태로 진행하고자 한다.

    • 라운드의 수는 1회 ~ 30회 사이로 설정 할 수 있다.
    • 1보다 작은 수가 입력 된 경우 예외를 발생시킨다.
    • 문자열이 입력되는 경우 예외를 발생시킨다.
  • 규칙 1 라운드의 수는 1회 ~ 30회 사이로 설정 할 수 있으며 이 경우 정상작동 해야한다.

    public class RoundTest {
    	@ParameterizedTest
    	@ValueSource(ints = {1,30,25,12})
    	@DisplayName("1회~30회 사이의 라운드가 정상적으로 입력 된 경우")
    	void correctRound(int round) {
    		assertThat(new Round(round));
    	}
    }
  • 위의 코드는 타 코드와 마찬가지로 작동하지 않는다. Round라는 클래스가 존재하지 않기 때문이다. 바로 Round 라는 클래스로 작성한 이유는 Round값에 대한 검증을 이 전 코드에서 했듯 Round를 생성하는 부분에서 검증하는 방식으로 진행하기 위해 RoundTest에서 시작하였다. 그럼 이제 테스트만 통과되도록 하는 최소한의 코드를 작성 해 보겠다.
    package kail.study.java.racing.domain;
    
    public class Round {
    	private final int round;
    
    	public Round(int round) {
    		this.round = round;
    	}
    }
  • 위와 같이 라운드 클래스를 생성하고 생성자만 추가해주면 이 테스트 코드는 통과한다. 하지만 현재는 어떠한 값이 들어와도 통과되는 구조이다. 비즈니스 로직에 맞게 라운드 클래스를 리팩토링 하는 과정을 진행 할 것이다. 우리는 1~30회까지의 횟수를 허용하기 때문에 생성자로 들어오는 round의 범위를 설정하는 방식으로 진행 하겠다.
    public Round(int round) {
    		validate(round);
    		this.round = round;
    	}
    
    	private void validate(int round) {
    		if (round < 1 || round > 30) {
    			throw new IllegalArgumentException("플레이 할 라운드는 1~30회만 가능합니다.");
    		}
    	}
  • 위와 같은 검증을 Round 클래스에 추가함으로써 우리는 입력된 라운드의 범위를 설정하였고 이를 통해 1 미만의 라운드에서는 예외를 던지는 부분까지 함께 구현되었기 때문에 요구사항인 1미만의 값에서 예외를 던지는 부분까지 같이 해결되었다. 이 부분은 테스트만 추가하고 다음 요구사항으로 넘어가겠다. 아래는 1미만의 값에서 정상적으로 에러를 던진다는 테스트 메소드이다.
    // RoundTest 에 추가하시면 됩니다.
    
        @ParameterizedTest
    	@ValueSource(ints = {-1,0,-3})
    	void smallerThanOne(int round) {
    		assertThatThrownBy(()->{
    			new Round(round);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("플레이 할 라운드는 1~30");
    	}
  • 그럼 라운드에 대한 마지막 요구사항인 문자열 입력에 대해 검증하려고 한다. 라운드를 생성 할 때 문자열을 입력하면 예외를 던지는 테스트 코드를 먼저 작성 할 것이다.
        @ParameterizedTest
    	@ValueSource(strings = {"문자열은","아니되오","숫자로","가자구"})
    	void stringException(String round) {
    		assertThatThrownBy(()->{
    			new Round(round);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("플레이 할 라운드는 1~30");
    	}
  • 위의 테스트 코드는 컴파일 조차 되지 않는다. 이유는 Round 클래스가 생성자의 파라미터로 인트타입을 받기 때문인데 그렇다면 어떻게 해결해야 하는가? Round의 생성자를 String으로 바꾸고 테스트를 하는 것이 바람직 한지, 사용자 입력에 대해 Round를 생성하기 전에 Integer.parseInt() 를 통해 검증해야 할까?
    • 본인은 전자의 방식으로 진행하겠다. 이유는 입력에 대한 검증이기도 하지만 저 부분은 Round를 생성하기 위한 과정이고 라운드의 규칙에 가깝다고 생각하기에 라운드를 수정하겠다. 물론 커멘드라인 프로그램은 기본적으로 String 타입으로 입력이 이루어지기 때문에 입력값에서 부터 NumberFormatException 은 검증하는 것도 좋은 방법이라 생각한다. 하지만 프로그램 전체로 봤을 때는 입력 오류라기 보다는 Round 생성 규칙 위반에 가깝다고 생각되고 InputView를 검증하는 것 보다 Round를 검증하는 부분이 테스트하기도 용이하기 때문에 전자로 진행하겠다. 이 부분은 개인의 선택이 아닐까 싶다.
  • String을 생성자의 파라미터로 받아오도록 바꾸게 되면 기존의 두개의 테스트가 실패한다. 이 부분까지 같이 수정 하였다.
    package kail.study.java.racing.domain;
    
    import static org.assertj.core.api.Assertions.*;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.ValueSource;
    
    public class RoundTest {
    	@ParameterizedTest
    	@ValueSource(strings = {"1","30","25","12"})
    	@DisplayName("1회~30회 사이의 라운드가 정상적으로 입력 된 경우")
    	void correctRound(String round) {
    		assertThat(new Round(round));
    	}
    
    	@ParameterizedTest
    	@ValueSource(strings = {"-1","0","-3"})
    	void smallerThanOne(String round) {
    		assertThatThrownBy(()->{
    			new Round(round);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("플레이 할 라운드는 1~30");
    	}
    
    	@ParameterizedTest
    	@ValueSource(strings = {"문자열은","아니되오","숫자로","가자구"})
    	void stringException(String round) {
    		assertThatThrownBy(()->{
    			new Round(round);
    		}).isInstanceOf(IllegalArgumentException.class)
    			.hasMessageContaining("문자열은 입력 할 수 없습니다.");
    	}
    }
    package kail.study.java.racing.domain;
    
    public class Round {
    	private final int round;
    
    	public Round(String round) {
    		validate(round);
    		this.round = Integer.parseInt(round);
    	}
    
    	private void validate(String round) {
    		checkNumberFormat(round);
    		checkRange(round);
    	}
    
    	private void checkNumberFormat(String round) {
    		try {
    			Integer.parseInt(round);
    		} catch (NumberFormatException e) {
    			throw new IllegalArgumentException("문자열은 입력 할 수 없습니다.");
    		}
    	}
    
    	private void checkRange(String round) {
    		int rounds = Integer.parseInt(round);
    		if (rounds < 1 || rounds > 30) {
    			throw new IllegalArgumentException("플레이 할 라운드는 1~30회만 가능합니다.");
    		}
    	}
    }
  • 추가적인 리팩토링은 1과 30이어떤 의미를 지니는지 조금 더 명확하게 하기 위해 상수로 분리하는 정도만 하겠다. 아래와 같이 작성하는 경우 숫자가 의미하는 바를 조금 더 명확하게 표현 할 수 있다
    private static final int MIN_ROUND = 1;
    private static final int MAX_ROUND = 30;
    
    private void checkRange(String round) {
    		int rounds = Integer.parseInt(round);
    		if (rounds < MIN_ROUND || rounds > MAX_ROUND) {
    			throw new IllegalArgumentException("플레이 할 라운드는 1~30회만 가능합니다.");
    		}
    	}
  • 참고로 상수로 분리 할 때 public 으로 분리하는 경우도 종종 존재한다. 인텔리제이 자동완성기능 command + alt + c 를 활용하면 public 으로 자동완성을 지원하는데 이 경우 타 클래스에서 상수를 활용 할 수 있다. 원시타입인 Int 형이기 때문에 값을 변경할 수는 없지만 현재 명시적으로 저 상수들을 사용 할 것이라는 곳이 존재하지 않기 때문에 private로 변경하였다.



지금까지 사용자 입력값에 대해서 검증하고 그를 바탕으로 Car , RacingCar , Round 를 생성하는 과정이었다. 4탄부터는 Random 한 수를 입력 받아 자동차를 이동시키는 메인 로직에 대해 포스팅 할 예정이고 마지막탄에서 출력 및 Main Method 를 추가하여 실제로 프로그램을 실행시키는 부분을 업로드 할 예정이다.

0개의 댓글