0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReactなしでOK: Jest + jsdomでSPA (index.html) をユニットテストする手順 (fetchモック付き)

0
Last updated at Posted at 2026-03-03

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としては、
先日、私が個人開発・公開したサイト 「ことばみまもり」 を例として、ユニットテスト作成・実行する手順を記載してみました。

「ことばみまもり」サイト

ユニットテストを追加したリポジトリ

GitHubリポジトリはこちら

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.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.js公式 Node.js®をダウンロードする

% 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"セクションを追加します。

package.json
{
  "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()
    ✓ & が &amp; にエスケープされる
    ✓ < が &lt; にエスケープされる
    ✓ > が &gt; にエスケープされる
    ✓ " が &quot; にエスケープされる
    ✓ ' が &#039; にエスケープされる
    ✓ 複数の特殊文字が混在していても正しくエスケープされる
    ✓ 特殊文字なし文字列はそのまま返る
    ✓ 空文字はそのまま返る
    ✓ 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を含むユニットテストを回せるようになりました。
「画面改修のたび、全て手動でテストするのはつらいな…」という方のご参考になれば幸いです。

以上となります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?