📚

JestとSupertestでAPIをテストしてみた

に公開

Jestとは

JavascriptやTypescriptのプロジェクトをテストするためのフレームワーク。あくまでフレームワークなのでテストコードなどは自分で書く必要がある。
今回は初めてのプロジェクトのテストということで雰囲気だけつかんでいけたらなと思う。

Supertestとは

httpリクエストをシミュレートするためのライブラリ。
jestから操作されてテストを実行する感じ。

導入手順

  1. JestとSupertestをインストール
npm install jest supertest --save-dev

とりあえずサンプルのテストファイルを作成して動作確認してみる

sample.test.js
describe('Sample Test', () => {
    it('should test that true === true', () => {
        expect(true).toBe(true)
    })
})

どうやらpackege.jsonを以下のように編集しとくといいらしい

package.json
{
    "jest": {
        "testEnvironment": "node",
        "coveragePathIgnorePatterns": [
          "/node_modules/"
        ]
      },
      "scripts": {
        "test": "jest",
      },
}

そしてnpm run testで実行

> backend@1.0.0 test
> jest

 PASS  controllers/sample.test.js
  Sample Test
    √ should test that true === true (3 ms)                                                                                                                                       
                                                                                                                                                                                  
Test Suites: 1 passed, 1 total                                                                                                                                                    
Tests:       1 passed, 1 total                                                                                                                                                    
Snapshots:   0 total
Time:        0.631 s
Ran all test suites.

こんな感じで出てくれれば成功

テストファイルの作成

今回テストしていくAPIはこちら

const db = require('../db/database'); // db/database.jsからデータベース接続を読み込む
const { updateUserContribution } = require('./util/upContribution'); // 貢献度更新関数をインポート

/**
 * 温泉の名前を編集するコントローラー
 * @route PUT /api/onsen/:id/nameedit
 * @param {number} req.params.id - 温泉ID
 * @param {string} req.body.newName - 新しい温泉の名前
 * @param {object} req.user - 認証済みユーザー情報 (JWTから取得)
 */

exports.editOnsenName = async (req, res) => {
  const { id } = req.params; // URLパスから温泉IDを取得
  const { newName } = req.body;
  const userId = req.user.id; 
  const userRole = req.user.role;

  if (!newName || newName.trim() === ""){
    return res.status(400).json({ message: '新しい名前は必須です。' });
  }

  const client = await db.connect(); // トランザクション用にクライアントを取得
  try {
    // 1. 温泉が存在するか確認
    const onsenResult = await db.query('SELECT * FROM hot_springs WHERE id = $1', [id]);
    if (onsenResult.rows.length === 0) {
      return res.status(404).json({ message: '指定された温泉が見つかりませんでした。' });
    }
    
    await client.query('BEGIN'); // トランザクション開始

    // 2. 名前の重複チェック
    const nameCheck = await client.query('SELECT * FROM hot_springs WHERE name = $1', [newName]);
    if (nameCheck.rows.length > 0) {
      await client.query('ROLLBACK');
      return res.status(409).json({ message: 'その名前は既に使用されています。' });
    }

    // 3. ユーザーの権限確認
    if (!(userRole === '温泉マイスター' || userRole === '名湯案内人')) {
      await client.query('ROLLBACK');
      return res.status(403).json({ message: '名前を変更する権限がありません。' });
    }

    // 4. 名前の更新
    await client.query(`
      UPDATE hot_springs
      SET name = $1, name_changer_user_id = $2, name_complaints = 0
      WHERE id = $3
    `, [newName, userId, id]);

  } catch (e) {
    await client.query('ROLLBACK');
    console.error('温泉名前編集エラー:', e);
    return res.status(500).json({ message: '名前の更新中にエラーが発生しました。' });

  } finally {
    await client.query('COMMIT'); // トランザクションをコミット
    client.release(); // クライアントを解放
  }
}

個人開発中のONSEN GOODSの中のAPI
ユーザーが温泉の名前を編集するためのAPIの処理

.
└── contoroller/
    ├── editOnsenName.js
    └── editOnsenName.test.js

同じディレクトリにテストファイルを設置
testファイルはいったんAIに書いてもらう

editOnsenName.test.js
const request = require('supertest');
const express = require('express');
const app = express();
const db = require('../db/database'); // データベース接続
const editOnsenName = require('./editOnsenName'); // テスト対象のコントローラー

// モック関数の設定
jest.mock('../db/database'); // データベース接続をモック化
jest.mock('./util/upContribution', () => ({
  updateUserContribution: jest.fn(),
}));

// Expressアプリの設定
app.use(express.json());
// 認証ミドルウェアをモック化
const mockAuth = (req, res, next) => {
  req.user = {
    id: 1,
    username: 'test_user',
    role: '温泉マイスター'
  };
  next();
};

app.put('/api/onsen/:id/nameedit', mockAuth, editOnsenName.editOnsenName);


describe('温泉名前編集API', () => {
  beforeEach(() => {
    // 各テストの前にモックをリセット
    jest.clearAllMocks();
    db.connect.mockReturnValue({
      query: jest.fn(),
      release: jest.fn(),
    });
  });

  const mockClient = db.connect();
  it('新しい名前に更新できること', async () => {
    // データベースのモック応答を設定
    db.query
      .mockResolvedValueOnce({ rows: [{ id: 1, name: '古い名前' }] }) // 温泉存在チェック
      .mockResolvedValueOnce({ rows: [] }) // 名前重複チェック
      .mockResolvedValueOnce({ rows: [] }); // UPDATE文の応答

    const response = await request(app)
      .put('/api/onsen/1/nameedit')
      .send({ newName: '新しい温泉の名前' });

    expect(response.status).toBe(200);
    // 成功したメッセージを想定して検証
    expect(response.body.message).toBe('名前が正常に更新されました。'); // 💡 オリジナルのコントローラーには成功時の`res.json`がないため、テストするには追加が必要です
    expect(db.query).toHaveBeenCalledWith('BEGIN');
    expect(db.query).toHaveBeenCalledWith('COMMIT');
  });

  it('新しい名前が空の場合に400エラーを返すこと', async () => {
    const response = await request(app)
      .put('/api/onsen/1/nameedit')
      .send({ newName: '' });

    expect(response.status).toBe(400);
    expect(response.body.message).toBe('新しい名前は必須です。');
    expect(db.query).not.toHaveBeenCalled(); // データベースへの呼び出しがないことを確認
  });

  it('温泉IDが見つからない場合に404エラーを返すこと', async () => {
    db.query.mockResolvedValueOnce({ rows: [] }); // 温泉存在チェックが失敗する応答

    const response = await request(app)
      .put('/api/onsen/999/nameedit')
      .send({ newName: '新しい名前' });

    expect(response.status).toBe(404);
    expect(response.body.message).toBe('指定された温泉が見つかりませんでした。');
  });

  it('名前がすでに使用されている場合に409エラーを返すこと', async () => {
    db.query
      .mockResolvedValueOnce({ rows: [{ id: 1, name: '古い名前' }] }) // 温泉存在チェック
      .mockResolvedValueOnce({ rows: [{ id: 2, name: '新しい名前' }] }); // 名前重複チェックが成功する応答

    const response = await request(app)
      .put('/api/onsen/1/nameedit')
      .send({ newName: '新しい名前' });

    expect(response.status).toBe(409);
    expect(response.body.message).toBe('その名前は既に使用されています。');
  });

  it('権限がない場合に403エラーを返すこと', async () => {
    // ユーザーロールを権限のないものに上書きする
    const mockAuthForbidden = (req, res, next) => {
      req.user = {
        id: 2,
        username: 'unauthorized_user',
        role: '一般ユーザー'
      };
      next();
    };

    app.put('/api/onsen/:id/nameedit', mockAuthForbidden, onsenController.editOnsenName);

    db.query.mockResolvedValueOnce({ rows: [{ id: 1, name: '古い名前' }] }); // 温泉存在チェック

    const response = await request(app)
      .put('/api/onsen/1/nameedit')
      .send({ newName: '新しい名前' });

    expect(response.status).toBe(403);
    expect(response.body.message).toBe('名前を変更する権限がありません。');
  });
});

結果はこんな感じ

 FAIL  controllers/editOnsenName.test.js
  温泉名前編集API
    × 新しい名前に更新できること (82 ms)                                                
    √ 新しい名前が空の場合に400エラーを返すこと (9 ms)
    √ 温泉IDが見つからない場合に404エラーを返すこと (5 ms)                              
    × 名前がすでに使用されている場合に409エラーを返すこと (8 ms)                        
    × 権限がない場合に403エラーを返すこと (1 ms)                                        
                                                                                        
  ● 温泉名前編集API › 新しい名前に更新できること                                        
                                                                                        
    expect(received).toBe(expected) // Object.is equality

    Expected: 200
    Received: 500

      48 |       .send({ newName: '新しい温泉の名前' });
      49 |
    > 50 |     expect(response.status).toBe(200);
         |                             ^
      51 |     // 成功したメッセージを想定して検証
      52 |     expect(response.body.message).toBe('名前が正常に更新されました。'); // 💡 オリジナルのコントローラーには成功時の`res.json`がないため、テストするには追加が必要で す
      53 |     expect(db.query).toHaveBeenCalledWith('BEGIN');

      at Object.toBe (controllers/editOnsenName.test.js:50:29)

  ● 温泉名前編集API › 名前がすでに使用されている場合に409エラーを返すこと

    expect(received).toBe(expected) // Object.is equality

    Expected: 409
    Received: 404

      85 |       .send({ newName: '新しい名前' });
      86 |
    > 87 |     expect(response.status).toBe(409);
         |                             ^
      88 |     expect(response.body.message).toBe('その名前は既に使用されています。');  
      89 |   });
      90 |

      at Object.toBe (controllers/editOnsenName.test.js:87:29)

  ● 温泉名前編集API › 権限がない場合に403エラーを返すこと

    ReferenceError: onsenController is not defined

      100 |     };
      101 |
    > 102 |     app.put('/api/onsen/:id/nameedit', mockAuthForbidden, onsenController.editOnsenName);
          |                                                           ^
      103 |
      104 |     db.query.mockResolvedValueOnce({ rows: [{ id: 1, name: '古い名前' }] });
 // 温泉存在チェック
      105 |

      at Object.onsenController (controllers/editOnsenName.test.js:102:59)

Test Suites: 1 failed, 1 total                                                          
Tests:       3 failed, 2 passed, 5 total                                                
Snapshots:   0 total
Time:        1.119 s

これをもとにテスト対象を修正、必要に応じてテストコードも修正していく
まあともかく動作することはわかったので今回はここまで
正直動作するかどうかを確かめるならPostmanで十分
エラー時のレスポンスなどを検証するためのテストコードを書くためにはそれなりに知識が必要になると感じた。

Discussion