前回までの記事の続きです。(シリーズ化予定)
▪️実装Step
Step1:基本編
Step2:Coin機能実装編
Step3: 仕様追加、リファクタリング実施 ←いまここ
Step4:ワイルドカード、セブンカードの追加
Step5:ディレクトリ整理/リファクタリング
成果物
追加仕様
・当たった果物に応じて、コインの獲得枚数が変化する。
ビジネスロジックが複雑化する上で、実装難易度が上がりデバッグにも時間がかかり、開発生産性が低下します。
今回はビジネスロジックの切り出しとJestを使った検証をスコープにします。
ソースコード
~/develop/react/react_slot_machine$ tree -I node_modules 
.
├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.tsx
│   ├── components
│   │   ├── Reel.tsx
│   │   ├── SlotMachine.test.tsx
│   │   ├── SlotMachine.tsx
│   │   ├── const.ts
│   │   ├── func.test.ts
│   │   ├── func.ts
│   │   └── type.ts
│   ├── index.tsx
│   ├── logo.svg
│   └── setupTests.ts
├── tsconfig.json
└── yarn.lock
4 directories, 21 files
import {SymbolTile} from "./type";
export const INITIAL_COINS = 100;
export const BET_COINS = 3;
export const CHERRY_ID = "cherry";
export const LEMON_ID = "lemon";
export const ORANGE_ID = "orange";
export const GRAPE_ID = "grape";
export const WATERMELON_ID = "watermelon";
export const CHERRY_SYMBOL = "🍒";
export const LEMON_SYMBOL = "🍋";
export const ORANGE_SYMBOL = "🍊";
export const GRAPE_SYMBOL = "🍇";
export const WATERMELON_SYMBOL = "🍉";
export const SYMBOLS: SymbolTile[] = [
  {
    id: CHERRY_ID,
    label: CHERRY_SYMBOL,
    point: 10,
  },
  {
    id: LEMON_ID,
    label: LEMON_SYMBOL,
    point: 10,
  },
  {
    id: ORANGE_ID,
    label: ORANGE_SYMBOL,
    point: 10,
  },
  {
    id: GRAPE_ID,
    label: GRAPE_SYMBOL,
    point: 20,
  },
  {
    id: WATERMELON_ID,
    label: WATERMELON_SYMBOL,
    point: 30,
  },
];
import {
  CHERRY_ID,
  CHERRY_SYMBOL,
  GRAPE_ID,
  GRAPE_SYMBOL,
  LEMON_ID,
  LEMON_SYMBOL,
  ORANGE_ID,
  ORANGE_SYMBOL,
  WATERMELON_ID,
  WATERMELON_SYMBOL,
} from "./const";
export type SymbolTile = {
  id: SymbolId;
  label: SymbolLabel;
  point: number;
};
export type SymbolId =
  | typeof CHERRY_ID
  | typeof LEMON_ID
  | typeof ORANGE_ID
  | typeof GRAPE_ID
  | typeof WATERMELON_ID;
export type SymbolLabel =
  | typeof CHERRY_SYMBOL
  | typeof LEMON_SYMBOL
  | typeof ORANGE_SYMBOL
  | typeof GRAPE_SYMBOL
  | typeof WATERMELON_SYMBOL;
import React, {useState} from "react";
import {Reel} from "./Reel";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import {BET_COINS, INITIAL_COINS, SYMBOLS} from "./const"; // SymbolTileのインポート
import {SymbolTile} from "./type";
import {spinReels} from "./func";
export const SlotMachine: React.FC = () => {
  const [coins, setCoins] = useState(INITIAL_COINS);
  const [reels, setReels] = useState<SymbolTile[]>(Array(3).fill(SYMBOLS[0])); // SymbolTile型の配列
  const handleSpin = () => {
    const {newReels, win, points} = spinReels(reels, SYMBOLS, BET_COINS);
    setReels(newReels);
    setCoins((prev) => prev - BET_COINS + (win ? points : 0));
  };
  // TODO リファクタリング前
  //   const handleSpin = () => {
  //     const newReels = reels.map(
  //       () => SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]
  //     );
  //     setReels(newReels);
  //     setCoins((prev) => prev - BET_COINS);
  //     // 当たり判定
  //     if (new Set(newReels.map((tile) => tile.id)).size === 1) {
  //       setCoins((prev) => prev - BET_COINS + newReels[0].point); // 当たりならコインを追加
  //     }
  //   };
  const isWin = new Set(reels.map((tile) => tile.id)).size === 1;
  return (
    <Box sx={{textAlign: "center", marginTop: 4}}>
      <Box>Coinの枚数:{coins}枚</Box>
      <Box sx={{display: "flex", justifyContent: "center", gap: 2}}>
        {reels.map((tile, index) => (
          <Reel key={index} symbol={tile.label} />
        ))}
      </Box>
      <Button
        variant="contained"
        color="primary"
        style={{marginTop: "16px"}}
        onClick={handleSpin}
        disabled={coins < BET_COINS}
      >
        スピン
      </Button>
      {isWin && <Box sx={{marginTop: 2}}>勝利!</Box>}
    </Box>
  );
};
注目して欲しいのが、handleSpinの箇所です。
ビジネスロジックが複雑化するにつれ、handleSpinも行数が増え検証に時間がかかります。
フロントエンド開発でデバッグは「コード改修→レンダリング→ブラウザ操作→コンポーネント・ログ確認」で実施する事が一般的です。
ただコードが複雑化するにつれて回数も増えるため、デバッグの時間が長時間化します。そのため、ビジネスロジックは外部関数化し、関数はjest等で検証する方が効率的です。
import {SymbolTile} from "./type";
export const spinReels = (
  currentReels: SymbolTile[],
  symbols: SymbolTile[],
  betCoins: number
): {newReels: SymbolTile[]; win: boolean; points: number} => {
  const newReels = currentReels.map(
    () => symbols[Math.floor(Math.random() * symbols.length)]
  );
  const win = new Set(newReels.map((tile) => tile.id)).size === 1;
  const points = win ? newReels[0].point : 0;
  return {newReels, win, points};
};
複雑化しそうなロジックは外部関数化しました。
この後、Jestで本関数の妥当性を検証していきます。
テストコード
今回は外部関数化したコードをJestで検証したいと思います。
import {spinReels} from "./func";
import {SymbolTile} from "./type";
describe("spinReels function", () => {
  // テスト用のシンボルデータ
  const testSymbols: SymbolTile[] = [
    {id: "cherry", label: "🍒", point: 10},
    {id: "lemon", label: "🍋", point: 5},
  ];
  test("should return win true and points when all reels match", () => {
    // モック関数を使用して常に同じシンボルを返すようにする
    jest.spyOn(global.Math, "random").mockReturnValue(0);
    const {newReels, win, points} = spinReels(testSymbols, testSymbols, 3);
    expect(win).toBe(true);
    expect(points).toBe(testSymbols[0].point);
    expect(newReels.every((tile) => tile.id === testSymbols[0].id)).toBe(true);
    // モックをリセット
    jest.spyOn(global.Math, "random").mockRestore();
  });
  test("should return win false and points zero when not all reels match", () => {
    // ランダムな結果を得るためにモックを使用しない
    const {win, points} = spinReels(testSymbols, testSymbols, 3);
    expect(win).toBe(false);
    expect(points).toBe(0);
  });
});
~/develop/react/react_slot_machine$ yarn test src/components/func.test.ts
 PASS  src/components/func.test.ts
  spinReels function
    ✓ should return win true and points when all reels match (1 ms)
    ✓ should return win false and points zero when not all reels match
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.285 s, estimated 1 s
Ran all test suites matching /src\/components\/func.test.ts/i.
Active Filters: filename /src/components/func.test.ts/
