ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TDD 기반 야구 게임 개발하기 3. 랜덤 숫자열 생성기
    24년 11월 이전/레거시-야구 게임 (Feat. TDD) 2018. 8. 3. 16:40
    반응형

    * 먼저 이 프로젝트의 지적 자산은 '코드 스쿼드'에 있음을 밝힙니다. 필자의 프로젝트의 소스코드는 https://github.com/gurumee92/baseballtdd/ 에 있습니다.


    테스트 주도 개발 기반 야구 게임 만들기

    3단계. 두번째 기능 랜덤 숫자열 생성기


    1. 기능 정의와 테스트 시작


    먼저 기능을 선정하였으면 그 기능에 대해서 명확한 정의를 내리는 것이 필요합니다. 왜냐하면 저희는 아주 간단한 정적 모델링을 했기 때문에 기능들이 아직 명확하지 않을 수 있거든요. 랜덤 숫자열 생성기는 1~9까지 중 서로 다른 숫자 3개를 임의로 뽑아서 문자열을 만드는 기능을 가지고 있습니다. 자 이제 명확하게 기능을 정의했으면 바로 테스트를  시작하죠. 일단은 역시 테스트 클래스를 생성하는 것부터 시작합니다. 그전에 랜덤 숫자열 생성기의 이름을 무엇으로 지을까요? RandomBallsGenerator라고 지으면 의미가 잘 전달되는 것 같습니다. 그럼 테스트 클래스부터 생성하도록 하죠. RandomBallsGeneratorTest를 만들어주세요. 그리고 JUnit 테스팅을 위해 다음과 같이 작성해주시면 됩니다.


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Test;

    public class RandomBallsGeneratorTest {
    @Test
    public void nothing(){

    }
    }

    바로 테스트를 진행해보면 성공할겁니다. 자 이제 테스트를 기능 테스트를 시작할 시간입니다. 제일 쉬운 테스트는 클래스를 생성하는 것입니다. 이제 생성기를 만들어보죠. 다음과 같이 테스트 코드를 변경 해주세요.


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Test;

    public class RandomBallsGeneratorTest {
    @Test
    public void isRandomBallsGeneratorCreateAvailable(){
    RandomBallsGenerator generator = new RandomBallsGenerator();
    }
    }

    이제 우리가 원하는 테스트 실패가 이루어졌습니다. 이제 생성기 클래스를 만들도록 하죠.


    [src/main/java/RandomBallsGenerator.java]

    public class RandomBallsGenerator {
    }

    자 이제 테스트를 진행합시다. 테스트가 통과될 겁니다. 이제 리팩토링이 있는지 확인해 봅시다. 없군요! 한데 이 생성기 역시 게임이 지정한 최대 길이에 영향을 받습니다. 따라서 길이 정보에 대해서 클래스를 생성할 수 있는 생성자가 필요하죠. 바로 테스팅을 작성해볼까요?


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Test;

    public class RandomBallsGeneratorTest {
    //이전 동일

    @Test
    public void isRandomBallsGeneratorCreateAvailableWhenLenIsDefaultLen(){
    RandomBallsGenerator generator =
    new RandomBallsGenerator(Game.DEFAULT_LEN);
    }
    }

    이제 테스트가 실패합니다. 이것들을 통과하기 위해서는 역시 길이 정보를 저장하는 필드와 디폴트 생성자와 길이 정보를 파라미터로 받는 생성자가 필요합니다. 바로 만들어주죠!


    [src/main/java/RandomBallsGenerator.java]

    public class RandomBallsGenerator {
    private int maxLen;
    public RandomBallsGenerator(){
    this(Game.DEFAULT_LEN);
    }
    public RandomBallsGenerator(int maxLen){
    this.maxLen = maxLen;
    }
    }

    이제 테스팅이 통과 될겁니다. 자 이제 리팩토링을 하죠. 현재까지 작성된 2개의 테스팅은 모두 생성기를 생성합니다. 따라서 이것을 @Before setUp으로 옮겨주도록 하죠. 테스트 코드는 다음처럼 바뀌게 됩니다.


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Before;
    import org.junit.Test;

    public class RandomBallsGeneratorTest {

    private RandomBallsGenerator generator;

    @Before
    public void setUp(){
    generator = new RandomBallsGenerator(Game.DEFAULT_LEN);
    }
    }

    이제 리팩토링을 마쳤으니 Git에 올리도록 하죠. 아 마찬가지로 디폴트 생성자는 private으로 바꿔주세요! 쓰이지 않을 거니까요 일단은... 커멘트는 "RandomBallsGenerator 생성, 테스트, 리팩토링 완료"라고 하죠. 


    2. 본격적인 테스트 시작


    자 이제 무작위로 생성되는 문자열을 어떻게 테스트할까요? 저는 이 부분에 대해서 굉장히 고민했었는데 일단 제가 생각할 수 있는 테스트는 생성된 문자열에 대해서 유효성 검사를 진행하는 것이죠. 그럼 생성되는 문자열이 의미를 지니려면 어떤 조건들이 있어야 할까요? 제가 생각한 문자열들은 다음의 조건을 만족해야 합니다.


    1. 생성기가 생성한 문자열은 null일 수 없다.
    2. 생성기가 생성한 문자열의 길이는 게임에서 정한 주어진 길이이다.
    3. 생성기가 생성한 문자열은 모두 1~9까지 숫자로 이루어져 있다.
    4. 생성기가 생성한 문자열은 원소가 문자열 내 각각 1개씩만 존재한다.
    5. 생성기가 생성한 문자열은 적어도 한 번은 바뀌어야 한다.


    자 일단 이것들을 테스트해봅시다. 테스트는 언제나 제일 쉬운것부터! 생성된 문자열이 NULL이 아닌지 여부부터 테스트 해보도록 하죠! 테스트 코드에 다음 코드를 추가해주세요.


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Before;
    import org.junit.Test;

    import static org.junit.Assert.assertEquals;

    public class RandomBallsGeneratorTest {

    //이전과 동일

    //유효성 검사 1. 널이 아닌가?
    @Test
    public void isStringIsValidatedIsNotNullWhichIsGeneratedByRandomBallsGenerator(){
    String balls =
    generator.generateBalls();
    assertEquals(balls != null, true);
    }
    }

    자 테스트의 실패했으니 통과를 위한 최소한의 코드를 작성할 차례입니다. 어떻게 하면 이 테스트를 통과할 수 있을까요? 많은 방법이 있겠지만 저는 메소드를 다음처럼 추가해 주었습니다.


    [src/main/java/RandomBallsGenerator.java]

    public class RandomBallsGenerator {
    //이전과 동일
    public String generateBalls() {
    return "";
    }
    }

    이제 테스트가 통과됩니다. 그렇다면 리팩토링 여부를 따져봐야겠군요? 일단 리팩토링할 거리는 없습니다. 따라서 Git에 "RandomBallsGenerator 유효성 검사1 Is Not Null" 코멘트를 달아 올립시다. 이제 2번째 유효성 검사를 진행하죠. 바로 생성된 문자열이 해당 길이인지 여부를 판단하는 것입니다. 테스트 코드에 다음을 추가해주세요


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Before;
    import org.junit.Test;

    import static org.junit.Assert.assertEquals;

    public class RandomBallsGeneratorTest {

    //이전과 동일
    //유효성 검사 2. 게임에서 지정한 길이인가?
    @Test
    public void isStringIsValidatedIsLengthEqualsGamesMaxLenWhichIsGeneratedByRandomBallsGenerator(){
    String balls =
    generator.generateBalls();
    assertEquals(balls.length(), Game.DEFAULT_LEN);

    }
    }

    자 이제 어떻게 해야 통과가 될까요? 제일 쉬운 방법은 StringBuilder에 문자열을 주어진 길이만큼 더해서 반환하는거지요. 소스 코드를 다음과 같이 작성해주시면 됩니다.


    [src/main/java/RandomBallsGenerator.java]

    public class RandomBallsGenerator {
    //이전과 동일

    public String generateBalls() {
    StringBuilder builder =
    new StringBuilder();
    for (int i=0; i<maxLen; i++)
    builder.append(
    " ");
    return builder.toString();
    }
    }

    이제 테스트가 통과됩니다. 리팩토링할 것이 있는지 확인해보죠. 아직까진 없는것 같습니다. 바로 Git에 "RandomBallsGenerator 유효성 검사2 Generate String's Length Equals Game's Length" 라고 코멘트를 달아 올리시면 됩니다. 이제 문자열이 모두 숫자로 이루어져 있는지 확인해보죠. 테스트 코드를 다음처럼 만들어주세요!


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Before;
    import org.junit.Test;

    import static org.junit.Assert.assertEquals;

    public class RandomBallsGeneratorTest {

    //이전과 동일
    //유효성 검사 3. 문자열이 1~9까지의 숫자들로 이루어져 있는가
    @Test
    public void isStringValidatedAllLettersComposedNumber1To9(){
    String balls =
    generator.generateBalls();
    for (int i=0; i<balls.length(); i++){
    int c = balls.charAt(i) - '0';
    assertEquals( (c >= 1 && c <= 9), true );
    }
    }
    }

    먼저 해당 자리의 char를 반환받아 '0'을 뺴줍니다. 만약 숫자라면 아스키 문자 위치에 따라서 1~9 사이의 숫자가 반활될 것입니다. 이 테스트는 통과할까요? 당연히 실패합니다. 이번에는 어떻게 하면 테스트를 통과할 수 있으까요? 음 많은 방법이 있겠지만 저는 generateBalls에서 for문을 돌 때 i를 이용하면 될 것 같습니다. 아래 코드처럼 말이죠.


    [src/main/java/RandomBallsGenerator.java]

    public class RandomBallsGenerator {
    private int maxLen;
    private RandomBallsGenerator(){
    this(Game.DEFAULT_LEN);
    }
    public RandomBallsGenerator(int maxLen){
    this.maxLen = maxLen;
    }

    public String generateBalls() {
    StringBuilder builder = new StringBuilder();
    for (int i=0; i<maxLen; i++)
    builder.append(i+1);
    return builder.toString();
    }
    }

    이제 테스트를 통과하는군요. 리팩토링할 것이 있을까요? 제 눈에는 안보이는군요. 바로 Git에 "RandomBallsGenerator 유효성 검사3 Generate String's composed 1 to 9 characters"라는 코멘트를 달아서 올려둡시다. 이제 4번째 유효성 검사 문자열을 서로 다른 문자로 이루어져 있는가입니다. 어떻게 테스트 할 수 있을까요? 저는 문자열을 순회해서 해당 위치의 문자의 개수를 구합니다. 그 후 그 개수들의 총합과 문자열의 길이와 비교를하면 될 것 같습니다. 중복된 숫자가 하나라도 있으면 길이랑 개수의 합이 다르게 나오겠지요. 테스트 코드를 다음처럼 추가해주세요.


    [src/test/java/RandomBallsGeneratorTest.java]

    import org.junit.Before;
    import org.junit.Test;

    import java.util.Arrays;

    import static org.junit.Assert.assertEquals;

    public class RandomBallsGeneratorTest {

    //이전과 동일
    //유효성 검사 5. 랜덤적으로 테스팅하기
    @Test
    public void isStringValidatedIsRandomlyGenerated(){
    int testCnt = 10;
    String ballsArr [] =new String[testCnt];

    for (int i=0; i<testCnt; i++)
    ballsArr[i] =
    generator.generateBalls();

    for (int i=0; i<testCnt; i++){
    final String balls =ballsArr[i];
    assertEquals(Arrays.stream(ballsArr).filter(s -> s.equals(balls)).count(), 1);
    }
    }
    }

    이제 테스트를 진행하면 실패할 겁니다. 왜냐하면 소스코드는 무조건 길이가 3이라면 123 이라는 공을 생성하기 때문이죠. 이제 어떻게 하면 테스트를 통과시킬 수 있을까요? 제일 쉬운 방법으로는 Random 객체를 생성해서 주어진 길이만큼 random.nextInf(9) + 1 을 계속 호출하는 방법이 있겠습니다. 이렇게 말이죠. 


    [src/main/java/RandomBallsGenerator.java]

    import java.util.Random;

    public class RandomBallsGenerator {
    //이전과 동일
    public String generateBalls() {
    StringBuilder builder =
    new StringBuilder();
    Random random = new Random();
    for (int i=0; i<maxLen; i++)
    builder.append(random.nextInt(
    9) + 1);
    return builder.toString();
    }
    }

    이 코드는 테스트를 통과할까요? 물론 많은 테스트가 통과합니다만.. 수 백번, 수 천번, 수 만번 하다 보면 테스트 실패가 뜰 수 있습니다. 왜냐하면 랜덤 객체는 1~9까지의 수만을 임의로 고르기 때문에 즉 확률이기 때문에 언제나 모든 테스트가 통과된다고 보장하기 어렵기 때문이죠. 뭐 임의로 고르는거니까 어느 정도는 눈 감아줄 수 있습니다. 그러나 이 코드의 진짜 위험한 점은 지금까지 짜온 테스트 코드조차 실패로 만들 수 있는 확률을 내포하고 있다는 겁니다. 예를 들어서 랜덤 테스트동안 만들어낸 문자열이 다음과 같다고 합시다.


    1. 123
    2. 456
    3. 789
    4. 145
    5. 238
    6. 359
    7. 498
    8. 257
    9. 391
    10. 333


    분명 랜덤 테스팅은 통과합니다. 하지만 마지막 333은 기존 유효성 검사를 무시하는 문자열을 만든다는겁니다. 현재 만들어진 기능이 이전에 모든 테스팅 및 프로그램을 망칠 경우 TDD에선 '퇴행'이라고 말합니다. 이럴 때는 다른 방법들이 있지만 모든 테스트 코드를 지우고 다시 짜는 것이 좋습니다. 하지만 저희 같은 경우는 로직에 조금만 변형을 거치면 테스트를 지우지 않고도 통과시킬 수 있는 방법이 있습니다. 바로 리스트와 셔플을 이용하는건데요 일단 로직은 이렇습니다.


    1. 1~9까지의 리스트를 만듭니다.
    2. 임의로 리스트 내 원소를 삭제합니다.
    3. 리스트의 길이가 주어진 길이가 될 때까지 계속 반복합니다.
    4. 그 후 정해진 길이에 다다르면 또 자바 기능을 이용해 리스트를 셔플합니다.
    5. 리스트 == 주어진길이일때 배열의 원소들을 문자열로 반환합니다.


    쉽죠? 이렇게 하면 지금 검사하는 랜덤 테스트는 역시 확률이기 때문에 무조건 통과한다고 보장할 순 없겠지만 적어도 이전의 테스트들을 100% 확률로 통과시킬 수 있죠. 한 번 코드를 짜보죠.


    [src/main/java/RandomBallsGenerator.java] 

    import java.util.Arrays;
    import java.util.List;
    import java.util.Random;
    import java.util.stream.Collectors;

    public class RandomBallsGenerator {
    //이전과 동일
    public String generateBalls() {
    List<Integer> list = Arrays.
    asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
    Random random = new Random();

    while(list.size() > maxLen){
    int removeIdx = random.nextInt(list.size());
    int elem = list.get(removeIdx);
    list = list.stream().filter(i -> i != elem).collect(Collectors.toList()); //리스트 삭제
    } Collections.shuffle(list);
    return list.stream().map( i -> i.toString() ).reduce("", (a , b) -> a+b); //리스트를 문자열로!
    }
    }

    이렇게 하면 테스트를 통과합니다. 이제 TDD 주기 중 테스트 실패 -> 성공을 거쳤으니 리팩토링을 할 차례입니다. 코드 내에서는 딱히 중복되는 내용은 없기 때문에 일반적이라면 넘어가도 될 법 합니다. 하지만 프로그래밍 제약 사항 10줄 이내에 코드를 짜라고 명시되어 있고 클린 코더스 강의에서도 함수는 3~5줄 이내로 짜기를 권하고 있습니다. 따라서 위의 코드는 최대한 맞추기 위한 필자의 몸부림으로 함수형 프로그래밍을 이용하여 작성하였습니다. 이렇게 하면 함수형 프로그래밍을 모르는 개발자가 볼 때 이 코드가 무슨 코드인지 의미를 모를 수 있는 일이 생길 수 있겠죠? 따라서 함수형 프로그래밍을 하는 부분에 private 메소드를 만들어서 이 코드가 무엇인지 명확히 해두도록 하는 것이 좋습니다. 이렇게 말이죠. 


    [src/main/java/RandomBallsGenerator.java] 

    import java.util.Arrays;
    import java.util.List;
    import java.util.Random;
    import java.util.stream.Collectors;

    public class RandomBallsGenerator {
    //이전과 동일

    public String generateBalls() {
    List<Integer> list = Arrays.
    asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
    Random random = new Random();

    while(list.size() > maxLen){

    int removeIdx = random.nextInt(list.size());

    int elem = list.get(removeIdx);

    list = getListWhichIsRemovedThisElement(list, elem);
    }


               
    Collections.shuffle(list);

    return getStringWhichIsConvertedList(list);
    }

    private List<Integer> getListWhichIsRemovedThisElement(List<Integer> list, int elem){
    return list.stream()
    .filter(i -> i !=
    elem) //이 요소가 아닌 요소들을 필터링 -> 이 요소 삭제
    .collect(Collectors.toList()); //그 필터링 된 요소들을 다시 리스트로
    }

    private String getStringWhichIsConvertedList(List<Integer> list){
    return list.stream()
    .map( i -> i.toString() )
    //숫자 요소들을 String으로
    .reduce("", (a , b) -> a+b); //각 요소들을 묶어준다 1 2 3 -> "" + 1 -> 1 + 2 -> 12 + 3 = 123
    }
    }

    이제 파라미터로 넘긴 요소를 삭제한 리스트라는 것과, 이 리스트를 변환해서 얻은 문자열이라는 것이 명확해졌나요?(영어를 못해서 틀린 메소드명일 수도 있어요;;) 이 코드에서는 또 한가지 개선점이 있는데 바로 임의의 숫자를 선택해서 그 인덱스의 요소를 반환하는 일입니다. 이는 함수적으로 봤을 때 랜점적으로 삭제될 요소를 반환하는 한가지 행위로 묶어줄 수 있습니다. 따라서 private 메소드를 1개 더 추가해서 다음과 같이 만들 수 있습니다.


    [src/main/java/RandomBallsGenerator.java] 

    import java.util.Arrays;
    import java.util.List;
    import java.util.Random;
    import java.util.stream.Collectors;

    public class RandomBallsGenerator {
    //이전과 동일

    public String generateBalls() {
    List<Integer> list = Arrays.
    asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

    while(list.size() > maxLen){
    int removeElem = getElemWillBeRemovedRandom(list);
    list = getListWhichIsRemovedThisElement(list, removeElem);
    }


    Collections.shuffle(list);
    return getStringWhichIsConvertedList(list);
    }
    private int getElemWillBeRemovedRandom(List<Integer> list){


    Random random = new Random();
    int removeIdx = random.nextInt(list.size());

    int elem = list.get(removeIdx);
    return elem;
    }

    //이전과 동일
    }

    자 이제 리팩토링을 마쳤으니 동작을 잘 하는지 테스트해봅시다. 역시 확률이기 때문에 5번 랜덤 테스트는 통과를 못할 수도 있습니다. 한 10번 정도 돌리면 그래도 최소 6~7번 이상은 통과하는 것 같네요. 여기서 중요한 건 1~4번이 모두 통과되는지입니다. 자 이제 Git에 올리죠. 참고로 이제 생성기의 모든 기능은 완성되었습니다. 랜덤 생성기는 딱 이 기능 외에 하지 않거든요. "RandomBallsGenerator 유효성 검사 완료 - 기능 완료" 라고 코멘트를 달아 올리도록 하겠습니다!


    3. 마치며...


    오늘은 랜덤 숫자열 생성기를 TDD 기반으로 작성해보았습니다. 기능 퇴행에 대해서도 아주 간단하게 살펴보았습니다.(아예 코드를 지우고 다시 테스트를 하는 건 다른 프로젝트에서 볼 수 있겠죠? 아마...) 다음 포스팅에서는 콘솔 기반 사용자 입력기와 게임 결과 출력기를 TDD로 작성해보겠습니다.

    728x90
Designed by Tistory.