はじめに
超ミニ4X(シヴィライゼーション風のターン制経済シミュレーション)ブラウザゲーム「小さな帝国」の開発連載、第6回です。
- サービスURL:https://satou20250828.github.io/micro-empire/
- リポジトリ:https://github.com/Satou20250828/micro-empire
| 回 | 内容 |
|---|---|
| #1 | 企画・課題発見 |
| #2 | 要件定義 |
| #3 | 設計(技術選定・状態設計・アーキテクチャ) |
| #4 | 開発①ゲームロジック実装編 |
| #5 | 開発②UI・画面実装編 |
| #6(本記事) | 開発③テスト・CI/CD構築編 |
| #7 | 開発中に遭遇した技術的問題 |
| #8 | 完成・振り返り |
今回はテストとCI/CDの構築について書きます。
テストの対象範囲:ロジック層だけに絞る
Vitestで書いたユニットテストは、board.js・workers.js・resources.js・turn.js・harvest.js・mystery.js・techtree.js・victory.js・cpuAi.jsの9ファイル・78件です。一方、画面描画を担当するmain.js・panels.js・cpuStatus.js・rules.jsにはユニットテストを書いていません。
これは、#3で書いた「状態はmain.jsに集約し、他のモジュールは状態を受け取って処理する関数の集まり」という設計のおかげで、盤面や資源といった判定ロジックと、HTML文字列を組み立てる描画処理がファイル単位で分かれていたためです。壊れると数値やゲーム進行がおかしくなる判定ロジック側を優先してテスト対象にし、見た目の崩れは目視確認でカバーする方針にしました。
ランダム性のあるロジックのテスト:Math.randomをモック化する
?マスの効果抽選のようにMath.randomを使うロジックは、そのままだと実行するたびに結果が変わり、再現性のあるテストが書けません。vi.spyOnでMath.randomの戻り値を固定することで、狙った分岐を再現しています。
// mystery.test.js
import { describe, it, expect, vi, afterEach } from 'vitest'
import { rollMysteryEffect } from './mystery.js'
afterEach(() => {
vi.restoreAllMocks()
})
describe('rollMysteryEffect', () => {
it('資源ボーナス(プラス)を抽選できる', () => {
vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.9)
expect(rollMysteryEffect()).toEqual({ type: 'bonus', resource: 'gold', amount: 2 })
})
it('何も起きないケースでは資源種別の抽選を行わない', () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValueOnce(0.6)
expect(rollMysteryEffect()).toEqual({ type: 'nothing', resource: null, amount: 0 })
expect(randomSpy).toHaveBeenCalledTimes(1)
})
})
rollMysteryEffect()の内部では「効果の種類(ボーナス/変化なし/ロス)」と「資源の種類(食料/生産力/金)」の2段階でMath.randomを呼びます。mockReturnValueOnceを2回チェーンすることで、1回目の呼び出しと2回目の呼び出しにそれぞれ別の値を返させ、「1回目は低い値(ボーナス側)、2回目は高い値(金)」のような特定の組み合わせを狙って再現しています。「何も起きない」ケースのテストでは、資源種別の抽選(2回目のMath.random呼び出し)が発生しないことまでtoHaveBeenCalledTimes(1)で確認しており、無駄な抽選が行われていないことも保証しています。
afterEachでvi.restoreAllMocks()を必ず呼び、テストごとにモックをリセットしているのは、あるテストで固定したMath.randomの戻り値が、次のテストに影響を残さないようにするためです。
テスト基盤の導入時期:実装の後から一括ではなく、運用ルールとして組み込む
テスト基盤は開発の最初期からではなく、Issue #18として途中で導入しました。既存のロジック(盤面の地形割り当て、労働者の移動判定など)にまとめてテストを追加し、CIにもテスト実行ステップを追加した上で、「以降のIssueは実装と同じPRにテストを含める」という運用ルールをこのIssueの完了条件として明記しています。後から書いたテストを個別のPRで積み増していく方式ではなく、導入のタイミングで運用ルール自体を決め切ったことで、以降の実装Issueではテストの追加漏れが起きていません。
CI:PRごとにlint・test・buildを自動実行
GitHub Actionsで、develop・main向けのPull Requestとpushの両方をトリガーに、lint・test・buildを自動実行しています。
# .github/workflows/ci.yml
on:
pull_request:
branches: [develop, main]
push:
branches: [develop, main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
Git Flow運用のため、feature/*ブランチからdevelopへのPR、release/*からmainへのPRの両方でこのワークフローが走ります。lint・test・buildの3つを1つのジョブにまとめているのは、それぞれが数秒〜数十秒で終わる軽量な処理のため、ジョブを分割して並列化するメリットよりも、1本のログで結果を追える見やすさを優先したためです。
CD:mainへのマージでGitHub Pagesへ自動デプロイ
mainブランチへのpush(release・hotfixのマージ時)をトリガーに、ビルド成果物をGitHub Pagesへ自動デプロイしています。
# .github/workflows/deploy.yml
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run build
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with: { path: dist }
deploy:
needs: build
environment: { name: github-pages }
steps:
- uses: actions/deploy-pages@v5
buildジョブとdeployジョブを分け、deploy側にenvironment: github-pagesを指定することで、GitHub上のデプロイ履歴・URLがEnvironmentsとして可視化されます。concurrency設定でデプロイの重複実行を防いでおり、mainへ短時間に複数回pushされた場合でも、常に最新の1回だけが反映される形にしています。
次回予告
次回(#7)は開発中に遭遇した技術的問題編です。Git Flow運用やMermaid記法など、実装中につまずいた具体的なトラブルとその解決策を書きます。