Test Code를 연습해보자 (with TDD Kata, Yatzy)

항해 플러스 사전 스터디를 진행했었을 때, Test Code를 작성하는 것은 낯설기만 한 저를 비롯한 팀원분들께 매니저님께서 "TDD Kata"라는 것을 공부해보면 좋다고 이야기해주셨었습니다. 그래서 TDD Kata가 뭐지 하면서 여러가지 자료들과 "https://kata-log.rocks/tdd" 이 사이트를 봤었습니다. 일단 제가 생각하기에 TDD Kata는 Test Code 및 TDD라는 방법론에 익숙해지기 위해 연습을 도와주는 문제들 같은 느낌이었습니다. 가장 쉽다고 알려진 Fizzbuzz는 이전에 혼자서 했었습니다. 이번에는 조금 더 난이도가 올라간 Yatzy를 통해 테스트 코드를 작성해봤는데, TDD가 아닌 일단 선 코드 후 테스트 방식으로 코드를 짰었습니다!

 

먼저 제 깃허브 링크는 다음과 같습니다.

https://github.com/JongMany/tdd-kata/tree/main/yatzy

 

Yatzy가 뭔데...?

나무위키에서는 주사위 5개를 사용하는 보드게임이라고 하며 한국에서는 "야찌"라고 불리며 룰은 다음과 같습니다.

 

Rule 1. 주사위 5개를 던집니다.

Rule 2. 이 중 원하는 주사위들은 남겨두고, 나머지 주사위를 다시 던집니다. 다시 던지기는 한 라운드에 2번까지 가능하며, 앞에서 던지지 않았던 주사위도 원한다면 다시 던질 수 있습니다.

Rule 3. 이렇게 해서 나온 값을 반드시 점수판에 기록해야 합니다. 기록할 칸이 없는 경우, 아무 칸에 0으로 기록해야 합니다. (보너스 칸에는 채울 수 없습니다)

Rul3 4. 점수판이 13칸이므로, 총 13라운드를 하면 게임이 끝납니다. 점수판의 점수 총합으로 승패를 결정합니다.

 

 

찾아보니 기록법의 이름은 약간의 차이가 있지만 저는 나무위키의 기록법을 따랐습니다

 

 

시작하기에 앞서...

Yatzy 게임의 모든 상황을 고려한 로직을 구현하지 않았고, 한 번 Dice를 던졌을 때의 상황에 대한 로직을 구현했습니다.

또한 TypeScript를 통해 구현했음을 알립니다!

 

1) 라이브러리 설치

`npm i --save -D @babel/core @babel/preset-env @babel/preset-typescript @types/jest babel-jest jest test-jest`

 

2) config 파일

 

(1) jest.config.js

// jest.config.js

/*
 * For a detailed explanation regarding each configuration property, visit:
 * https://jestjs.io/docs/configuration
 */

module.exports = {
  // Automatically clear mock calls, instances, contexts and results before every test
  clearMocks: true,

  // Indicates whether the coverage information should be collected while executing the test
  collectCoverage: false,

  // The directory where Jest should output its coverage files
  coverageDirectory: "coverage",

  // Indicates which provider should be used to instrument code for coverage
  coverageProvider: "v8",

  // An array of file extensions your modules use
  moduleFileExtensions: [
    "js",
    "mjs",
    "cjs",
    "jsx",
    "ts",
    "tsx",
    "json",
    "node",
  ],

  // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/$1",
  },

  // The test environment that will be used for testing
  testEnvironment: "jest-environment-node",

  // The glob patterns Jest uses to detect test files
  testMatch: [
    "<rootDir>/**/*.test.(js|jsx|ts|tsx)",
    "<rootDir>/(tests/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))",
  ],

  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
  transformIgnorePatterns: ["<rootDir>/node_modules/"],
};

(2) babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current",
        },
      },
    ],
    "@babel/preset-typescript",
  ],
};

 

(3) tsconfig.json

이 설정은 각자 입맛에 맞게 세팅합시다..!

 

시작

먼저 주사위 5개의 결과를 받을 수 있는 `Yatzy` 클래스 및 생성자 함수를 정의합시다.

type DiceNum = 1 | 2 | 3 | 4 | 5 | 6;

export default class Yatzy {
  private readonly dice: number[];

  constructor(d1: DiceNum, d2: DiceNum, d3: DiceNum, d4: DiceNum, d5: DiceNum) {
    this.dice = [d1, d2, d3, d4, d5];
  }
  
  // upper section
  
  // lower section
  
  // helper method
  
}

 

그리고, 가장 쉬운 메소드인 모든 주사위 눈들을 더하는 `chance` 메소드를 작성해봅시다.

export default class Yatzy {
 // ...
 
 // lower section
 chance(): number {
    return this.dice.reduce((acc, curr) => acc + curr, 0);
  } 
}

 

이 메소드를 테스트해볼까요? 에러케이스가 딱히 없으니 잘 계산된 결과를 반환하는지만 테스트했습니다.

describe("Yatzy's Lower section", () => {
  it("chance 메서드는 모든 dice 눈의 합을 넘긴 값을 반환합니다.", () => {
    // arrange
    const yatzy1 = new Yatzy(1, 2, 3, 4, 5);
    const yatzy2 = new Yatzy(1, 3, 3, 4, 5);

    // act & assert
    expect(yatzy1.chance()).toBe(15);
    expect(yatzy2.chance()).toBe(16);
  });
  
})

 

그 다음 UpperCase에 있는 Aces 부터 Sixes를 구현하고 테스트를 해보겠습니다. (보너스 같은 경우는 점수판이 있어야 구현 및 테스트가 가능합니다)

 

먼저 Aces~Sixes 같은 경우는 특정한 수가 몇 개 있는지를 판단한 뒤에 더해야 한다는 공통의 로직이 있습니다. 또한, 이 로직은 외부에 노출하지 않아도 되므로 private 메서드로 구현해도 될 것 같습니다.

먼저 `singles` 라는 메서드로 공통의 로직을 구현한 뒤에 UpperCase를 구현하는 방식으로 해결했습니다.

export default class Yatzy {
  // ...
  
  // upper section
  aces(): number {
    return this.singles(1);
  }
  twos(): number {
    return this.singles(2);
  }
  threes(): number {
    return this.singles(3);
  }
  fours(): number {
    return this.singles(4);
  }
  fives(): number {
    return this.singles(5);
  }
  sixes(): number {
    return this.singles(6);
  }

  // helper section
  /**
   * @description
   * 한 개의 값만을 필터링해서 더해주는 메서드
   * */
  private singles(n: number): number {
    return this.dice
      .filter((d) => d === n)
      .reduce((acc, curr) => acc + curr, 0);
  }
  
}

 

저는 이때까지만 해도 클래스 내의 모든 메서드를 다 테스트를 해봐야하는 줄 알았습니다. 그래서 private 메소드의 모킹 방법 등을 찾아보니 private 메소드의 경우는 테스트를 진행하지 않고, private 메소드를 사용하고 외부에 노출된 함수들로 테스트가 가능하다는 결론을 얻게 되었습니다. 테스트를 진행한 코드는 다음과 같습니다

describe("Yatzy's Upper section", () => {
  it("ones 메서드는 1을 필터링해서 더한 값을 반환합니다..", () => {
    const yatzy1 = new Yatzy(1, 2, 1, 4, 5);
    const yatzy2 = new Yatzy(1, 2, 1, 4, 1);
    expect(yatzy1.aces()).toBe(2);
    expect(yatzy2.aces()).toBe(3);
  });

  it("twos 메서드는 2를 필터링해서 더한 값을 반환합니다.", () => {
    const yatzy1 = new Yatzy(1, 2, 1, 4, 5);
    const yatzy2 = new Yatzy(1, 2, 2, 4, 2);
    expect(yatzy1.twos()).toBe(2);
    expect(yatzy2.twos()).toBe(6);
  });

  it("threes 메서드는 3을 필터링해서 더한 값을 반환합니다.", () => {
    const yatzy1 = new Yatzy(1, 2, 1, 4, 5);
    const yatzy2 = new Yatzy(1, 3, 3, 3, 3);
    expect(yatzy1.threes()).toBe(0);
    expect(yatzy2.threes()).toBe(12);
  });

  it("fours 메서드는 4를 필터링해서 더한 값을 반환합니다.", () => {
    const yatzy1 = new Yatzy(1, 2, 1, 4, 5);
    const yatzy2 = new Yatzy(4, 4, 4, 5, 5);
    expect(yatzy1.fours()).toBe(4);
    expect(yatzy2.fours()).toBe(12);
  });

  it("fives 메서드는 5를 필터링해서 더한 값을 반환합니다.", () => {
    const yatzy1 = new Yatzy(1, 2, 1, 4, 5);
    const yatzy2 = new Yatzy(4, 4, 5, 5, 5);
    expect(yatzy1.fives()).toBe(5);
    expect(yatzy2.fives()).toBe(15);
  });

  it("sixes 메서드는 6을 필터링해서 더한 값을 반환합니다.", () => {
    const yatzy1 = new Yatzy(1, 2, 1, 4, 5);
    const yatzy2 = new Yatzy(4, 4, 6, 5, 5);
    expect(yatzy1.sixes()).toBe(0);
    expect(yatzy2.sixes()).toBe(6);
  });
});

 

이제 Upper Section에 있는 메소드들은 모두 구현 및 테스트 작성이 완료되었습니다.

 

이제 threeOfAKind, fourOfAKind, fullHouse, yahtzee를 구현 및 테스트를 작성해보겠습니다.

이 4개의 메소드를 묶은 이유는 공통된 로직이 있기 때문입니다. 먼저 n개 중복된 카드가 있는지를 체크하는 로직이 필요합니다. 이를 위해서는 몇가지 방법이 있을 것 같습니다. 생각했던 방법은 bucket List를 쓰는 알고리즘과 mapper 객체를 만들어 쓰는 알고리즘이 있는데 저는 mapper 객체를 생성자 함수에서 만드는 방식을 택했습니다. 먼저, 공통 로직을 구현한 코드는 다음과 같습니다.

type DiceNum = 1 | 2 | 3 | 4 | 5 | 6;

type DiceCountMapper = {
  [key in DiceNum]: number;
};

export default class Yatzy {
  private readonly dice: DiceNum[];
  private readonly diceCountMapper: DiceCountMapper;
  
  constructor(d1: DiceNum, d2: DiceNum, d3: DiceNum, d4: DiceNum, d5: DiceNum) {
    this.dice = [d1, d2, d3, d4, d5];
    this.diceCountMapper = this.dice.reduce(
      (acc, cur) => ({
        ...acc,
        [cur]: (acc[cur as DiceNum] || 0) + 1,
      }),
      {} as DiceCountMapper
    );
  }
  // ...
  
  // helper section
  // ...
  
   /**
   * @description
   * 원하는 중복 개수를 체크하는 함수
   */
  private checkQuantity(count: DiceNum) {
    return Object.values(this.diceCountMapper).some((v) => v === count);
  }
}

 

이를 이용하여 4개의 Upper Section 메소드를 구현하면 다음과 같습니다!

export default class Yatzy {
  // ..

  // lower section
  // ...
  threeOfAKind(): number {
    if (this.checkQuantity(3)) {
      return this.dice.reduce((acc, curr) => acc + curr, 0);
    } else {
      throw new Error("No three of a kind");
    }
  }

  fourOfAKind(): number {
    if (this.checkQuantity(4)) {
      return this.dice.reduce((acc, curr) => acc + curr, 0);
    } else {
      throw new Error("No four of a kind");
    }
  }

  fullHouse(): number {
    if (this.checkQuantity(3) && this.checkQuantity(2)) {
      return this.dice.reduce((acc, curr) => acc + curr, 0);
    } else {
      throw new Error("No full house");
    }
  }

  yahtzee(): number {
    if (this.checkQuantity(5)) {
      this.yatzy = true;
      return 50;
    } else {
      throw new Error("No yahtzee");
    }
  }
}

 

이를 테스트하기 위한 코드는 다음과 같습니다.

이 테스트 코드를 작성할 때 에러를 캐치해내지 못했던 이슈가 존재했었습니다. 이는 다름아닌 제가 공식문서를 제대로 체크해보지 못해서 발생했던 문제였으며 공식 문서에서는 에러를 캐치할 때, 함수를 호출하는 함수를 함수로 묶어서 assertion을 진행하라고 되어있었습니다.

공식 문서를 제대로 읽어보자...

describe("Yatzy's Lower section", () => {
  // ...
  it("ThreeOfAKind 메서드는 3개의 눈이 동일할 때, 주사위 5개의 합을 반환합니다..", () => {
    const yatzy = new Yatzy(3, 3, 3, 4, 4);

    expect(yatzy.threeOfAKind()).toBe(17);
  });

  it("ThreeOfAKind 메서드는 3개의 눈이 동일하지 않는 경우, 에러를 반환합니다...", () => {
    const yatzy = new Yatzy(3, 3, 3, 3, 4);

    expect(() => yatzy.threeOfAKind()).toThrow("No three of a kind");
  });

  it("FourOfAKind 메서드는 4개의 눈이 동일할 때, 주사위 5개의 합을 반환합니다..", () => {
    const yatzy = new Yatzy(3, 3, 3, 3, 4);

    expect(yatzy.fourOfAKind()).toBe(16);
  });

  it("FourOfAKind 메서드는 4개의 눈이 동일하지 않는 경우, 에러를 반환합니다...", () => {
    const yatzy = new Yatzy(3, 3, 3, 3, 3);

    expect(() => yatzy.fourOfAKind()).toThrow("No four of a kind");
  });

  it("FullHouse 메서드는 3개의 눈이 동일하고, 2개의 눈이 동일할 때, 주사위 5개의 합을 반환합니다..", () => {
    const yatzy = new Yatzy(3, 3, 3, 4, 4);

    expect(yatzy.fullHouse()).toBe(17);
  });

  it("FullHouse 메서드는 3개의 눈이 동일하지 않거나, 2개의 눈이 동일하지 않는 경우, 에러를 반환합니다...", () => {
    const yatzy = new Yatzy(3, 3, 3, 4, 5);

    expect(() => yatzy.fullHouse()).toThrow("No full house");
  });

  it("Yahtzee 메서드는 5개의 눈이 동일할 때, 50을 반환하고, yatzee 값을 true로 바꿉니다.", () => {
    const yatzy = new Yatzy(3, 3, 3, 3, 3);

    expect(yatzy.yahtzee()).toBe(50);
    expect(yatzy.yatzee).toBe(true);
  });

  it("Yahtzee 메서드는 5개의 눈이 동일하지 않을 때, 에러를 반환합니다...", () => {
    const yatzy = new Yatzy(3, 3, 3, 3, 4);

    expect(() => yatzy.yahtzee()).toThrow("No yahtzee");
  });
  
})

 

이제 2개의 Upper Section 케이스만 남았습니다. 바로 smallStraight와 largeStraight 입니다. 

일단 이 두 함수도 알고리즘 적으로 유사한데, 알고리즘을 짜는데 애를 좀 먹었습니다. 연속성을 판단해야 하므로, buckerList를 통해 구현했습니다. 먼저 헬퍼함수를 구현해보겠습니다.

export default class Yatzy {

  // helper section
  /**
   * @description
   * 눈이 연속인지 체크하는 함수
   *  */
  private checkDiceSequence(seqCount = 4): boolean {
    // const sortedDice = this.dice.sort((a, b) => a - b);
    const initialValue: number[] = Array(6).fill(0);
    const bucket = this.dice.reduce((acc, cur) => {
      acc[cur - 1] = (acc[cur - 1] || 0) + 1;
      return acc;
    }, initialValue);

    let seq = 0;
    let tempSeq = 0;
    for (const cnt of bucket) {
      if (cnt > 0) {
        seq++;
        if (seq === seqCount) {
          return true;
        }
      } else {
        tempSeq = seq;
        seq = 0;
      }
    }

    if (seq === seqCount) {
      return true;
    } else if (tempSeq === seqCount) {
      return true;
    } else {
      return false;
    }
  }
}

 

이 헬퍼함수를 기반으로 `smallStraight`과 `largeStraight` 메서드를 구현해보도록 합시다!

export default class Yatzy {

  // lower Section
  // ...
  // 4개의 눈이 연속일 때 30점
  smallStraight(): number {
    if (this.checkDiceSequence(4)) {
      return 30;
    } else {
      throw new Error("No small straight");
    }
  }

  // 5개의 눈이 연속일 때 40점
  largeStraight(): number {
    if (this.checkDiceSequence(5)) {
      return 40;
    } else {
      throw new Error("No large straight");
    }
  }
}

 

 

마지막으로 두 메서드를 테스트하는 코드를 작성해보겠습니다

describe("Yatzy's Lower section", () => {
  // ... 
  it("smallStraight 메서드는 4개 이상의 수가 연속일 때, 30점을 반환합니다...", () => {
    const yatzy1 = new Yatzy(1, 2, 3, 4, 6);
    const yatzy2 = new Yatzy(1, 2, 3, 4, 5);

    expect(yatzy1.smallStraight()).toBe(30);
    expect(yatzy2.smallStraight()).toBe(30);
  });

  it("smallStraight 메서드는 4개 이상의 수가 연속이 아닐 때, 에러를 반환합니다...", () => {
    const yatzy1 = new Yatzy(1, 2, 3, 5, 6);
    const yatzy2 = new Yatzy(1, 2, 4, 5, 6);

    expect(() => yatzy1.smallStraight()).toThrow("No small straight");
    expect(() => yatzy2.smallStraight()).toThrow("No small straight");
  });

  it("largeStraight 메서드는 5개의 수가 연속일 때, 40점을 반환합니다...", () => {
    const yatzy1 = new Yatzy(1, 2, 3, 4, 5);
    const yatzy2 = new Yatzy(3, 2, 4, 5, 6);

    expect(yatzy1.largeStraight()).toBe(40);
    expect(yatzy2.largeStraight()).toBe(40);
  });

  it("largeStraight 메서드는 5개의 수가 연속이 아닐 때, 에러를 반환합니다...", () => {
    const yatzy1 = new Yatzy(1, 2, 3, 4, 6);
    const yatzy2 = new Yatzy(2, 2, 4, 5, 6);

    expect(() => yatzy1.largeStraight()).toThrow("No large straight");
    expect(() => yatzy2.largeStraight()).toThrow("No large straight");
  });
});

 

여기까지 완료했다면 기본적인 Yatzy 클래스에 대한 구현과 테스트코드 작성을 완료한 셈입니다.

더 깊게 들어가면 게임 자체에 대한 점수판, 전반적인 게임 방식을 구현 및 테스트해볼 수 있을 것 같습니다. 

배운점들

  1. 클래스의 private 메서드는 테스트 코드를 직접 작성하기보다는 private 메서드를 사용한 메서드들을 통해 검증하는 것이 올바른 방법이다. 만약 private 메서드를 사용하지 않는다면 해당 private 메서드를 지우는 것이 맞다.
  2. Throw된 Error를 검증하는 메서드를 사용할 때는 메서드를 실행한 코드를 함수로 감싸야 한다.
// 실패한 경우
it("ThreeOfAKind 메서드는 3개의 눈이 동일하지 않는 경우, 에러를 반환합니다...", () => {
  const yatzy = new Yatzy(3, 3, 3, 3, 4);

  expect(yatzy.ThreeOfAKind()).toThrow("No three of a kind");
});

// 삽질의 결과..!
it("ThreeOfAKind 메서드는 3개의 눈이 동일하지 않는 경우, 에러를 반환합니다...", () => {
  const yatzy = new Yatzy(3, 3, 3, 3, 4);

  expect(() => yatzy.ThreeOfAKind()).toThrow("No three of a kind");
});

 

'프로그래밍 방법론 > 테스트코드' 카테고리의 다른 글

Jest의 Mock 정리 방법  (0) 2024.04.21