GitHub Actionsのワークフローを書いていて、こんな経験はありませんか?
「1行コードを修正してコミット&プッシュし、GitHub上の『Actions』タブを開いて3部待ち、エラーが出たらまた1行直してプッシュ……」
通称「CIデバッグの無限ループ」です。Typoや些細なロジックエラーを修正するためだけに、何十回も無意味なコミットを積み重ねてしまうのは、時間も集中力も奪われて本当に辛いですよね。
でも、安心してください!
ワークフロー内で自作したJavaScript/TypeScriptカスタムアクションは、Jestを使って「手元のローカル環境で1秒でユニットテスト」できるのです。
これを一度マスターしてしまえば、クラウドにプッシュすることなく、手元でロジックの正当性を100%検証できるようになります。毎日の開発効率が劇的に跳ね上がりますよ!
今回は、世界標準のツールキットである `@actions/core` と `Jest` を組み合わせ、初心者でも迷わずに完璧なテスト環境を構築できる手順を、丁寧に解説していきますね。
—
1. なぜカスタムアクションの「ユニットテスト」が必要なのか?
最初に、全体像とツールの役割を整理しておきましょう。
【開発の流れ】
[従来] コード変更 ➔ git push ➔ GitHub上で実行(3分待機) ➔ 失敗 ➔ 絶望…
[改善] コード変更 ➔ npm test (1秒) ➔ 成功 ➔ git push ➔ 快適!
- GitHub Actions Toolkit (`@actions/core`): アクションの入力取得(`getInput`)や出力設定(`setOutput`)、ログ出力などを安全に行うための公式ライブラリです。
- Jest: Facebook(Meta)が開発した、JavaScript/TypeScript向けの超人気テスティングフレームワークです。
カスタムアクションは「Node.js上で動くプログラム」に過ぎません。つまり、適切な構造でコードを書き、`@actions/core` の動きをJestで模倣(モック化)してあげれば、ローカルPC上でいくらでもテストができるのです。
—
2. 開発環境のセットアップ(ゼロから構築)
まずはプロジェクトを作成し、必要なツールをインストールしていきましょう。今回は型安全でテストが書きやすい TypeScript をベースに進めますが、JavaScriptでも考え方は全く同じです。
2-1. プロジェクトの初期化
端末(ターミナル)を開き、以下のコマンドを順に実行してください。
プロジェクトフォルダを作成して移動
mkdir my-smart-action
cd my-smart-action
Node.jsプロジェクトの初期化 (-y で全てデフォルト設定)
npm init -y
必要なライブラリのインストール
@actions/core: GitHub Actionsの公式開発キット
npm install @actions/core
開発用ライブラリ(テストツールとTypeScript関連)のインストール
npm install –save-dev jest ts-jest @types/jest @types/node typescript
2-2. 設定ファイルの作成
次に、TypeScriptとJestの設定を行います。
① `tsconfig.json` (TypeScriptの設定)
プロジェクト直下に作成します。
{
“compilerOptions”: {
“target”: “ES2022”,
“module”: “commonjs”,
“lib”: [“es2022”],
“rootDir”: “./src”,
“outDir”: “./dist”,
“strict”: true,
“esModuleInterop”: true,
“skipLibCheck”: true,
“forceConsistentCasingInFileNames”: true
},
“include”: [“src//”]
}
② `jest.config.js` (Jestの設定)
同じくプロジェクト直下に作成します。
module.exports = {
// TypeScriptをJestで動かすためのプリセット
preset: ‘ts-jest’,
// Node.js環境でテストを実行する設定
testEnvironment: ‘node’,
// テスト対象ファイルのパターンを指定(__tests__ フォルダ内の .ts ファイル)
testMatch: [‘/__tests__//.test.ts’],
// 詳細なログを表示
verbose: true,
// テスト実行ごとにモックの履歴を自動クリア(重要!)
clearMocks: true,
};
③ `package.json` のスクリプト更新
`package.json` の `”scripts”` セクションにテストコマンドを追加します。
“scripts”: {
“test”: “jest”,
“build”: “tsc”
}
—
3. テストしやすいコード設計の極意:エントリーポイントの分離
ここが超重要なアーキテクチャのポイントです!
アクションの処理を1つのファイルに全部書いてしまうと、ファイルが読み込まれた瞬間に実行されてしまい、Jestからコントロールできなくなります。
そのため、「ロジック本体」と「実行用のエントリーポイント」を別々のファイルに分離します。
my-smart-action/
├── action.yml # アクションの定義ファイル
├── package.json
├── jest.config.js
├── tsconfig.json
└── src/
├── run.ts # 【ロジック本体】(テスト対象!)
├── index.ts # 【エントリーポイント】(runを呼ぶだけ)
└── __tests__/
└── run.test.ts # 【ユニットテストコード】
3-1. アクション定義 `action.yml`
まずはGitHub Actionsに「このアクションの入力と出力は何か」を教えるメタデータです。
name: ‘Friendly Greeter’
description: ‘ユーザー名を渡すと挨拶を返し、名前が空ならエラーにするアクション’
アクションの入力パラメータ
inputs:
username:
description: ‘挨拶する対象の名前’
required: true
default: ‘World’
アクションの出力パラメータ
outputs:
greeting:
description: ‘生成された挨拶メッセージ’
runs:
using: ‘node20’
main: ‘dist/index.js’
3-2. ロジック本体 `src/run.ts`
ここがテストするコアロジックです。`@actions/core` を使って入出力を処理します。
import as core from ‘@actions/core’;
/
- アクションのメインロジック関数
- テストから呼び出せるように export しておきます
/
export async function run(): Promise
try {
// 1. アクションの入力を取得
const username = core.getInput(‘username’, { required: true });
// 2. バリデーションチェック(空文字ならエラー)
if (username.trim() === ”) {
throw new Error(‘Username cannot be empty!’);
}
// 3. ロジックの実行とログ出力
const message = `Hello, ${username}! Welcome to GitHub Actions!`;
core.info(message);
// 4. 結果を出力パラメータとして設定
core.setOutput(‘greeting’, message);
} catch (error) {
// エラーが発生した場合はアクションを「失敗」としてマーク
if (error instanceof Error) {
core.setFailed(error.message);
} else {
core.setFailed(‘An unexpected error occurred.’);
}
}
}
3-3. エントリーポイント `src/index.ts`
実際のワークフロー実行時にのみ叩かれるファイルです。非常にシンプルですね。
import { run } from ‘./run’;
// メインロジックを実行するだけ
run();
—
4. 運命のテストコードを書く! (`src/__tests__/run.test.ts`)
さあ、いよいよ本丸です!Jestを使って、上記で書いた `run.ts` の動作をローカルで完全検証します。
`@actions/core` のメソッド(`getInput`, `setOutput`, `setFailed`)を `jest.spyOn` で監視し、「正しく入力を受け取って、期待通りの出力関数が呼ばれたか」をチェックします。
`src/__tests__/run.test.ts` を作成してください。
import as core from ‘@actions/core’;
import { run } from ‘../run’;
// @actions/core の各関数を監視(スパイ)するための準備
let getInputSpy: jest.SpyInstance;
let setOutputSpy: jest.SpyInstance;
let setFailedSpy: jest.SpyInstance;
describe(‘Friendly Greeter Actionのテスト’, () => {
beforeEach(() => {
// 各テストを実行する前に、スパイをセットアップ
getInputSpy = jest.spyOn(core, ‘getInput’);
setOutputSpy = jest.spyOn(core, ‘setOutput’);
setFailedSpy = jest.spyOn(core, ‘setFailed’);
});
afterEach(() => {
// テスト終了後にモックの状態をリセット
jest.restoreAllMocks();
});
// —————————————————-
// テストケース 1: 正常系(名前が正しく渡された場合)
// —————————————————-
it(‘正しいユーザー名が渡されたとき、適切な挨拶を出力すること’, async () => {
// 【準備】 getInput(‘username’) が呼ばれたら ‘Alice’ を返すよう偽装
getInputSpy.mockImplementation((name: string) => {
if (name === ‘username’) return ‘Alice’;
return ”;
});
// 【実行】 アクションのメイン関数を実行!
await run();
// 【検証】 setOutput(‘greeting’, ‘Hello, Alice!…’) が呼ばれたか?
expect(setOutputSpy).toHaveBeenCalledWith(
‘greeting’,
‘Hello, Alice! Welcome to GitHub Actions!’
);
// 失敗用関数 setFailed が呼ばれていないことを確認
expect(setFailedSpy).not.toHaveBeenCalled();
});
// —————————————————-
// テストケース 2: 異常系(名前が空文字だった場合)
// —————————————————-
it(‘ユーザー名が空文字のとき、アクションを失敗(setFailed)にすること’, async () => {
// 【準備】 getInput(‘username’) が空文字 ” を返すように設定
getInputSpy.mockImplementation((name: string) => {
if (name === ‘username’) return ‘ ‘; // 空白文字のみ
return ”;
});
// 【実行】
await run();
// 【検証】 setFailed が特定のエラーメッセージとともに呼ばれたか?
expect(setFailedSpy).toHaveBeenCalledWith(‘Username cannot be empty!’);
// 成功用関数 setOutput が呼ばれていないことを確認
expect(setOutputSpy).not.toHaveBeenCalled();
});
});
—
5. テストを実行して爆速フィードバックを体験する
ターミナルで以下のコマンドを叩いてみましょう!
npm test
実行結果を見てみてください。
PASS src/__tests__/run.test.ts
Friendly Greeter Actionのテスト
✓ 正しいユーザー名が渡されたとき、適切な挨拶を出力すること (12 ms)
✓ ユーザー名が空文字のとき、アクションを失敗(setFailed)にすること (2 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.825 s
わずか 1秒未満(0.825秒) で、すべてのテストがパスしました!
もしロジックにバグがあれば、GitHubにプッシュする前に、この数ミリ秒のテストが一瞬で「ここがおかしいよ!」と教えてくれます。この圧倒的なスピード感こそが、ユニットテストを導入する最大のマジックです。
—
6. さらに一歩先へ:CI上でも品質を絶対防衛する
ローカルでテストが書けたら、最後はこのユニットテスト自体をGitHub上のCI(Pull Request)で自動実行するようにしましょう。
これで、チームメンバーがコードを壊すPRを出してきても、自動でブロックできます。
`.github/workflows/test.yml` を作成します。
name: “Unit Tests”
on:
push:
branches: [ main ]
pull_request:
jobs:
# テストを実行するジョブ
unit-test:
runs-on: ubuntu-latest
steps:
- name: リポジトリのチェックアウト
uses: actions/checkout@v4
- name: Node.jsのセットアップ
uses: actions/setup-node@v4
with:
node-version: 20
cache: ‘npm’
- name: 依存ライブラリのインストール
run: npm ci
- name: ユニットテストの実行
run: npm test
自分自身(カスタムアクション)のテストを、GitHub Actionsで自動実行する……まさに「ドッグフーディング(自社製品を自分で使う)」の美しい品質保証プロセスの完成です!
—
まとめ:もう「CIプッシュの待ち時間」には悩まない!
今回学んだポイントを振り返ってみましょう。
1. エントリーポイントの分離: `run()` ロジックを独立させてエクスポートする。
2. `@actions/core` のモック化: `jest.spyOn` を使って、入出力関数をローカルでシミュレートする。
3. 超高速フィードバック: わずか1秒で正常系・異常系・境界値のテストを完結させる。
「GitHub Actionsのコードを書く=クラウドに上げて試す」という固定観念を捨て、手元でJestを回す習慣をつけるだけで、開発速度は3倍以上に加速します。
毎日の作業が劇的に楽になり、自信を持って堅牢なCI/CDパイプラインを組めるようになりますよ。ぜひ、あなたのプロジェクトでも今日から試してみてくださいね!応援しています!