【入門編】GitHub Actionsワークフローを「ユニットテスト」する:Jestを活用したアクションコードの品質向上術 – バージョン管理・CI/CD活用バイブル

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パイプラインを組めるようになりますよ。ぜひ、あなたのプロジェクトでも今日から試してみてくださいね!応援しています!

タイトルとURLをコピーしました