前回までの記事の続きです。(シリーズ化予定)
▪️実装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
src/components/const.ts
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,
},
];
src/components/type.ts
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;
src/components/SlotMachine.tsx
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等で検証する方が効率的です。
src/components/func.ts
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で検証したいと思います。
src/components/func.test.ts
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/