1. はじめに
お疲れさまです。
フレームワークなしのSPA(index.htmlやVanilla JS)に対して、DOMやfetchのユニットテストを作ってみました。
本記事では、シンプルなSPAに対して、Jestとjsdom(jest-environment-jsdom)を導入し、ユニットテストコードを作成することで、ユニットテスト(単体テスト)を実行する手順をご紹介いたします。
- SPA(React等のフレームワークを使っていないSPA)に対して、フォーム入力、バリデーション、API通信(fetch/XMLHttpRequest)、DOM操作や表示切替のユニットテストを作成します。
- npmのパッケージ Jest、jsdomテスト用パッケージ jest-environment-jsdom を利用して、ユニットテストを実行する手順です。
- (※) React導入しなくても、ユニットテスト実行できます。
ユニットテスト対象のサイト
ユニットテスト対象のSPAとしては、
先日、私が個人開発・公開したサイト 「ことばみまもり」 を例として、ユニットテスト作成・実行する手順を記載してみました。
ユニットテストを追加したリポジトリ
2. 解決したい課題
- 個人開発したサイトについて、小さな改修を続けております。
- 運営者として、サイト画面の改修時、機能単位の正常性確認、早期のバグ発見を行えるようにしたく、ユニットテストを行えるようにしたいと考えました。
3. ユニットテストの作成、実行手順
本記事では、例として、リポジトリ内のindex.html(と内含しているjavascript)のユニットテスト(単体テスト)を実行する手順を記します。
サイトの index.html をよく改修するので、index.html のフォーム入力、バリデーション、API通信(fetch/XMLHttpRequest), DOM操作や表示切替について、ユニットテストを行えるようにします。
ユニットテスト実行環境
本記事の作成にあたり、以下の環境で動作を確認しております。
後述の手順では、Macでの実行コマンドを記載しております。
- OS: macOS Tahoe 26.3 (Apple M3)
ユニットテストコードの作成
index.html 用のユニットテストコード index.test.js を作成します。
% vi index.test.js
作成する内容は、以下の内容になります。
(ユニットテストコードの全文は長文になるため、以下リポジトリの index.test.js に記載しております)
https://github.com/na0AaooQ/words-watching-app/blob/main/index.test.js
ユニットテストコードの内容 (fetchモック付き)
以下は作成したユニットテストコードの一部抜粋です。
fetchモックの部分を記載しております。
テストコードの全文は https://github.com/na0AaooQ/words-watching-app/blob/main/index.test.js をご参照ください。
/**
* ことばみまもり — index.html 単体テスト
* フレームワーク : Jest (jsdom 環境)
* セットアップ : npm install --save-dev jest jest-environment-jsdom
* package.json に "jest": { "testEnvironment": "jsdom" } を追加
*/
// ─────────────────────────────────────────────
// テスト対象の関数を直接定義(index.html の <script> と同一ロジック)
// 実装を外部 JS ファイルに切り出した場合は import/require に変更してください
// ─────────────────────────────────────────────
/* ---------- テスト対象関数 ---------- */
function onTextInput() {
const ta = document.getElementById('input-text');
const cc = document.getElementById('char-count');
const btn = document.getElementById('submit-btn');
const len = ta.value.length;
cc.textContent = len + ' / 2200';
cc.className = 'char-count' + (len > 1800 ? ' warn' : '') + (len >= 2200 ? ' over' : '');
btn.disabled = len === 0;
}
(全文は長いので省略)
async function checkText() {
const text = document.getElementById('input-text').value.trim();
if (!text) return;
document.getElementById('submit-btn').disabled = true;
document.getElementById('loading').style.display = 'flex';
document.getElementById('result-area').style.display = 'none';
try {
const res = await fetch('/prod/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (!res.ok) throw new Error('API error: ' + res.status);
let data = await res.json();
if (data.body && typeof data.body === 'string') {
data = JSON.parse(data.body);
} else if (data.body && typeof data.body === 'object') {
data = data.body;
}
renderResult(data);
} catch (e) {
const demoData = generateDemoData(text);
renderResult(demoData);
} finally {
document.getElementById('loading').style.display = 'none';
document.getElementById('submit-btn').disabled = false;
}
}
(全文は長いので途中省略)
// ================================================================
// 9. checkText — API通信・ローディング制御・フォールバック
// ================================================================
describe('checkText()', () => {
beforeEach(() => {
setupDom();
document.getElementById('input-text').value = 'テスト投稿文';
document.getElementById('submit-btn').disabled = false;
});
afterEach(() => {
jest.restoreAllMocks();
});
test('textarea が空のときは何もしない(fetch を呼ばない)', async () => {
global.fetch = jest.fn();
document.getElementById('input-text').value = ' '; // 空白のみ
await checkText();
expect(global.fetch).not.toHaveBeenCalled();
});
test('API成功時: renderResult が呼ばれ result-area が表示される', async () => {
const mockData = {
risk: 'low',
summary: 'APIからの結果',
reasons: ['問題なし'],
suggestions: []
};
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => mockData
});
await checkText();
expect(document.getElementById('result-area').style.display).toBe('block');
expect(document.getElementById('result-area').innerHTML).toContain('APIからの結果');
});
test('API成功時: ローディングが非表示になる', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ risk: 'low', summary: 'ok', reasons: [], suggestions: [] })
});
await checkText();
expect(document.getElementById('loading').style.display).toBe('none');
});
test('API成功時: ボタンが再び enabled になる', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ risk: 'low', summary: 'ok', reasons: [], suggestions: [] })
});
await checkText();
expect(document.getElementById('submit-btn').disabled).toBe(false);
});
(全文は長いので途中省略)
ユニットテストの実行手順
(1) ユニットテスト実行時に必要となる Node.js と npm がインストールされていない場合、Node.js と npm をインストールします。
Node.jsの公式の手順を参照し、Node.jsとnpmをインストールします。
(※) なお、Node.jsは、LTS版のバージョン24でもテスト動作します。
% node -v
v25.6.1
% npm -v
11.9.0
%
(2) リポジトリをgit cloneします。
% git clone git@github.com:na0AaooQ/words-watching-app.git
(3) git cloneしたディレクトリへ移動します。
% cd words-watching-app
(4) テスト実行に必要なパッケージ Jest と jest-environment-jsdom をインストールします。
以下のコマンドを実行し、テスト実行に必要なパッケージ Jest と jest-environment-jsdom をインストールします。
(※) jest-environment-jsdom については、Jest バージョン28以降、以下の手順のように、個別インストールが必要となります。
% npm install --save-dev jest jest-environment-jsdom
(省略)
added 339 packages in 7s
51 packages are looking for funding
run `npm fund` for details
%
jestとjest-environment-jsdomがインストールされたことを確認します。
% npx jest --version
30.1.3
%
% grep jest-environment-jsdom package.json
"jest-environment-jsdom": "^30.2.0"
%
リポジトリのディレクトリ内に package.json が生成されます。
% cat package.json
{
"devDependencies": {
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0"
}
}
%
(5) package.json に対して、Jestとjest-environment-jsdomを追加します。
Cursor, VSCode, Claude Code, またはviコマンドなどで、package.json を開きます。
この例では、viコマンドで、ファイルを編集します。
% vi package.json
package.json に "jest"や"scripts"セクションを追加します。
{
"devDependencies": {
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0"
},
"jest": {
"testEnvironment": "jsdom"
},
"scripts": { "test": "jest" }
}
(※) "scripts" セクションの追加は任意です。本手順では、npx jest index.test.js or npm test -- index.test.js のどちらのコマンドでも、テストを実行できるようにするため、セクションを追加しています。
(6) 以下のいずれかのコマンドを実行し、ユニットテストコード index.test.js を実行します。これにより、index.html のユニットテストを実行できます。
% npm test -- index.test.js
または
% npx jest index.test.js
(7) 単体テスト結果が表示されます。テスト結果を確認し、[Test Suites: 1 passed, 1 total]、[Tests: 72 passed, 72 total] と表示されており、テストが全件成功していることを確認します。
% npm test -- index.test.js
> test
> jest index.test.js
PASS ./index.test.js
onTextInput()
✓ 空文字のとき「0 / 2200」と表示し、ボタンが disabled になる (4 ms)
✓ 1文字以上入力するとボタンが enabled になる (1 ms)
✓ 1800文字以内は warn クラスなし (1 ms)
✓ 1801文字で warn クラスが付く (1 ms)
✓ 2200文字で over クラスが付く
✓ 文字数が正しくカウントされる(絵文字含む) (1 ms)
setSample()
✓ textarea に指定テキストがセットされる (1 ms)
✓ input イベントが発火して文字数カウントが更新される (1 ms)
✓ 空文字をセットできる (1 ms)
toggleAccordion()
✓ 閉じている状態でクリックすると aria-expanded="true" になる
✓ 開いた状態でクリックすると aria-expanded="false" になる
✓ 閉じている→開くと body に open クラスが追加される
✓ 開いている→閉じると body から open クラスが除去される (1 ms)
✓ 2回トグルすると元の状態に戻る
toggleFaq()
✓ Q1をクリックすると開く (3 ms)
✓ Q1が開いている状態でQ2をクリックすると Q1 が閉じる (1 ms)
✓ 開いているFAQをもう一度クリックすると閉じる (3 ms)
escapeHtml()
✓ & が & にエスケープされる
✓ < が < にエスケープされる
✓ > が > にエスケープされる
✓ " が " にエスケープされる
✓ ' が ' にエスケープされる
✓ 複数の特殊文字が混在していても正しくエスケープされる
✓ 特殊文字なし文字列はそのまま返る
✓ 空文字はそのまま返る
✓ string 以外の型(null)は空文字を返す
✓ string 以外の型(数値)は空文字を返す
✓ string 以外の型(undefined)は空文字を返す
generateDemoData()
✓ ネガティブワードを含むテキストは risk: high を返す
✓ 「バカ」を含むテキストは risk: high を返す
✓ 「死」を含むテキストは risk: high を返す
✓ 「消えろ」を含むテキストは risk: high を返す
✓ 「嫌い」を含むテキストは risk: high を返す
✓ high リスク時は reasons が2件返る (1 ms)
✓ high リスク時は suggestions が2件返る
✓ ネガティブワードなしのテキストは risk: low を返す
✓ low リスク時は suggestions が空配列を返す
✓ 空文字は risk: low を返す
✓ summary が string 型で返る
renderResult()
✓ result-area が表示される(display: block) (3 ms)
✓ risk: low のとき verdict safe クラスが付く (1 ms)
✓ risk: medium のとき verdict caution クラスが付く (1 ms)
✓ risk: high のとき verdict danger クラスが付く (1 ms)
✓ risk: low のとき 😊 アイコンが表示される (1 ms)
✓ risk: medium のとき 🤔 アイコンが表示される (1 ms)
✓ risk: high のとき ⚠️ アイコンが表示される (2 ms)
✓ risk: low のとき「低リスク」ラベルが表示される (1 ms)
✓ risk: medium のとき「要注意」ラベルが表示される (1 ms)
✓ risk: high のとき「高リスク」ラベルが表示される (1 ms)
✓ summary が表示される (1 ms)
✓ reasons が <li> として描画される (1 ms)
✓ suggestions がある場合「改善のヒント」セクションが表示される (1 ms)
✓ suggestions が空のとき「改善のヒント」セクションが表示されない (1 ms)
✓ XSS: summary の <script> タグがエスケープされる (1 ms)
✓ XSS: reasons の悪意ある文字列がエスケープされる (1 ms)
✓ SNS共有リンク(X)が結果エリアに含まれる
✓ SNS共有リンク(Facebook)が結果エリアに含まれる
✓ Instagram リンクが結果エリアに含まれる (1 ms)
✓ TikTok リンクが結果エリアに含まれる (1 ms)
✓ Threads リンクが結果エリアに含まれる (1 ms)
recheck()
✓ result-area が非表示になる (1 ms)
✓ input-text にフォーカスが移る
checkText()
✓ textarea が空のときは何もしない(fetch を呼ばない) (1 ms)
✓ API成功時: renderResult が呼ばれ result-area が表示される (1 ms)
✓ API成功時: ローディングが非表示になる (2 ms)
✓ API成功時: ボタンが再び enabled になる (1 ms)
✓ Lambda プロキシ形式 { body: string } も正しくパースされる (1 ms)
✓ Lambda プロキシ形式 { body: object } も正しくパースされる
✓ API が res.ok=false のとき: デモデータで結果が表示される (1 ms)
✓ fetch が reject のとき: デモデータで結果が表示される (1 ms)
✓ チェック実行中はローディングが表示される
✓ POST リクエストが JSON ボディで送信される (1 ms)
Test Suites: 1 passed, 1 total
Tests: 72 passed, 72 total
Snapshots: 0 total
Time: 0.327 s, estimated 1 s
Ran all test suites matching index.test.js.
%
4. 終わりに
記事を閲覧いただき、ありがとうございます。
ユニットテストを作成しますと、小さな機能単位で意図通りに機能が動作していること、バグの早期発見、テスト工数削減など、様々なメリットがあると思います。
今回は、以下で開発しましたサイトを例として、ユニットテストを作成、実行する手順をご紹介させていただきました。
Jest と jsdom(jest-environment-jsdom)を用いて、フレームワークなしの SPA(index.html) でも DOM操作やfetchを含むユニットテストを回せるようになりました。
「画面改修のたび、全て手動でテストするのはつらいな…」という方のご参考になれば幸いです。
以上となります。