[Express, NestJS対応] バックエンドのテスト ~APIテスト編~


シリーズ3部作です。
バックエンドのテスト ~基礎知識編~
バックエンドのテスト ~APIテスト編~ これ
バックエンドのテスト ~ユニットテスト編~

この記事以降は基本的にjestテストランナーを利用している前提で解説します。

APIテスト

ここから実際のテストの例を解説していきます。まずはAPIテストです。

APIの例

  • /users #GET
  • /users/:id #PUT
  • /users #POST
  • /users/:id #DELETE

基本構造

あくまで例なので自分の好きな形で大丈夫です。describeの構造はAPIのurlのパスごとにネストして書くと書きやすいです。

基本構造
describe('/users', () => {
  describe('/ #GET', () => {
    // todoはjestで利用できる。未定義という意味。
    // mochaだとtest('happy path');だけでtodoの意味になる。
    test.todo('happy path');
  });
  
  describe('/ #POST', () => {
    test.todo('happy path');
  });
  
  // :idでネストする場合
  describe('/:id', () => {
    describe('/ #PUT', () => {
      test.todo('happy path');
    });
    
    describe('/ #DELETE', () => {
      test.todo('happy path');
    });
  })
  
  // :idでネストしない場合
  describe('/:id #PUT', () => {
    test.todo('happy path');
  });
  
  describe('/:id #DELETE', () => {
    test.todo('happy path');
  });
})

詳細レベルの解説です。

APIテストなので実際にAPIを叩きます。対応する言語やフレームワークによってAPIテストツールがあります。NodeJSの場合はsupertestが有名です。APIツールによっては、アプリケーション・サーバーを引数に指定できるものや、もしくはアプリケーション・サーバーを別途立ち上げておき、それに対してリクエストを送るものが存在します。

このようにリクエストと同時にアサートできるものが多いです。

import express from 'express';
import request from 'supertest';

const app = express();

test('happy path', (done) => {
  // appサーバーを直接指定
  request(app)
    .get('/users')
    .expect(200, done); // 200ステータスコードが返ってくる。
})

セットアップとティアダウン

APIテストはセットアップ(前処理)とティアダウン(後片付け)をしっかりと考える必要があります。DBが絡むテストなのでややこしいのです。

APIテストをするときは以下の項目を事前に考えましょう!

  • 認証をスキップするかしないか。
  • 認証をスキップするならセッション情報をどうするか。テスト時にアプリ内で現在ユーザーをどう取得
  • 認証をスキップしないならどのように認証用のトークンやセッション情報をもたせるか。テストだけ使える特別なトークンやCookieを作る?その場でテスト用のsecretを使ってトークンやCookieを作成する?一度ログインさせる?
  • リクエストユーザーの権限まわりをどうセットアップするか。

APIテストが難しいのは上記の項目を解決しないといけないからですね。

認証をテスト時のみスキップさせる場合

Expressだと認証をミドルウェアで、NestJSだとガードで作っていることが多いと思います。

Expressではテスト環境のとき、認証ミドルウェアをスキップします。

Express 認証スキップのイメージ
// セットアップ系のコードで
if(process.env.NODE_ENV === 'test'){
  app.use(checkAuthMiddleware);
}

NestJSでは、テスト時に偽のガードで上書きしてしまいます。偽のガードは検証をせずにそのまま素通りさせてしまいます。

NestJS 認証スキップのイメージ
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-custom';

export class JwtTestStrategy extends PassportStrategy(Strategy, 'jwtTest') {
  public async validate(req: Request): Promise<User> {
   return true;
  }
}

const moduleFixture: TestingModule = await Test.createTestingModule({
  imports: [AppModule],
}).overrideGuard(JwtGuard).useClass(JwtForTestGuard).compile();

const app = moduleFixture.createNestApplication();
await app.init();

ただし、これだとreq.userが正しく作れなくなります。認証時にセッション情報を作っているアプリが多いと思います。

認証を通っていないため現在ユーザーが取得できないイメージ
// リクエストから現在ユーザー取得を取得しようとしても
// req.session.emailが入っていないのでエラー。
// checkAuthMiddlewareで入る予定だったreq.sessionが空。
req.user = User.findByEmail(req.session.email);
NestJS 現在ユーザー取得イメージ
export class JwtTestStrategy extends PassportStrategy(Strategy, 'jwtTest') {
  public async validate(req: Request): Promise<User> {
    // 本来はここでjwtをデコードしてemailを取得していた。
    // 本来はここで現在ユーザーを取得して返していた。
    return  const currentUser = User.findByEmail(?????);
  }
}

そこで解決策として、テスト用のユーザーはメールアドレスを固定するとしましょう。

Express 現在ユーザー取得イメージ
if(process.env.NODE_ENV === 'test'){
  req.user = User.findByEmail('[email protected]');
} else {
  req.user = User.findByEmail(req.session.email);
}
NestJS 現在ユーザー取得イメージ
export class JwtTestStrategy extends PassportStrategy(Strategy, 'jwtTest') {
  public async validate(req: Request): Promise<User> {
    if(process.env.NODE_ENV === 'test'){
      return User.findByEmail('[email protected]');
    } else {
      return User.findByEmail(req.session.email);
    }
  }
}

これでテスト時にセットアップ処理でリクエストユーザーを作成する場合、[email protected]でメールアドレスを作成すれば良くなりました。

ただしこの方法にも問題はあります。

  • APIテストが並列実行できなくなります。
  • テストごとにユーザーデータを削除する必要があります。

例えば次の2つのAPIテストがあり、それぞれ管理者権限のリクエストユーザーと一般権限のリクエストユーザーを作成したとします。

user.test.ts
import { request } from 'supertest';

describe('/users', () => {

  describe('アドミンユーザーのとき', () => {
    let app;
    let reqUser;
  
    // セットアップ
    beforeAll(() => {
      // アプリ側のセットアップ
      const app = express();
    
      // ここでリクエストユーザーを作る。権限とかを指定できるようにする。
      const reqUser = createReqUser('ADMIN'); 
    });
    
    // テスト実行後データ消す。
    afterAll(async () => {
      await deleteAllTable();
    })
  
    describe('/ #GET', () => {
      test('happy path', (done) => {
        request(app)
          .get('/users')
          .expect(200, done); // 200ステータスコードが返ってくる。
      })
    });
  })
});
post.test.ts
const request = require('supertest');

describe('/posts', () => {

  describe('一般ユーザーのとき', () => {
    let app;
    let reqUser;
  
    // セットアップ
    beforeAll(() => {
      // アプリ側のセットアップ
      const app = express();
    
      // ここでリクエストユーザーを作る。権限とかを指定できるようにする。
      const reqUser = createReqUser('USER'); 
    });
    
    // テスト実行後データ消す。
    afterAll(async () => {
      await deleteAllTable();
    })
  
    describe('/ #GET', () => {
      test('happy path', (done) => {
        request(app)
          .get('/posts')
          .expect(200, done); // 200ステータスコードが返ってくる。
      })
    });
  })
});

すると、同じ[email protected]のメアドを作成しようとしているので、DBのユニーク制約に引っかかります。

また、テスト実行後にデータを削除しなければ、同じように2人目のリクエストユーザーを作成した時点のDBのユニーク制約にひっかかってしまいます。

つまり。もしすべてのAPIテストを直列に実行し、テストごとにデータを削除するのであれば利用できる方法です。

認証をテスト時もスキップさせない場合

テスト時にヘッダーに認証情報を含める必要があります。

Cookieセッションの場合
test('happy path', (done) => {
  const reqUser = createReqUser('ADMIN');
  const sessionInfo = createTestSession(reqUser);
  
  request(app)
    .get('/users')
    .set('Cookie', [`session=${sessionInfo};`])
    .expect(200, done); // 200ステータスコードが返ってくる。
})
JWT認証の場合
test('happy path', (done) => {
  const reqUser = createReqUser('ADMIN');
  const token = createTestToken(reqUser);
  request(app)
    .get('/users')
    .set('Authentication', `Bearer ${token}`)
    .expect(200, done); // 200ステータスコードが返ってくる。
})

これはJWTトークンやセッション用のCookieを自分たちで発行しているときに利用できる方法です。テスト時にsecretをテストように変えて、createTestSessioncreateTestTokenをそのsecretをもとに生成するようにします。

Express Cookieセッションにsecretを指定しているイメージ
app.use(session({
  secret: process.env.SESSION_SECRET, // テスト時にテスト用のsecretを指定
  resave: false,
  saveUninitialized: false,
  store: new RedisStore({ client: redisClient }),
  cookie: { httpOnly: true, secure: false, maxage: 1000 * 60 * 30 }
}));
NestJS JWTにsecretを指定しているイメージ
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      // 本番時とテスト時にそれぞれsecretを渡す。
      secretOrKey: configService.get<string>('JWT_SECRET_KEY'),
    });
  }

  async validate(payload: JWTPayload): Promise<JwtPayload> {
    return { userId: payload.userId, username: payload.username };
  }
}

ただし、トークンやセッション発行元が自分たちではなく外部サービスのとき、この方法は利用できません(Cookieセッションを外部で発行しているのは聞いたことないですが...)。

その場合は認証ライブラリをハックすることも考えてみましょう。認証ライブラリがやってることは

  1. ヘッダやCookieから認証情報(セッション)を抜き出す。
  2. secretを利用してそれが有効化チェックする。
  3. 有効であればそこからユーザーidやメールアドレスを抜き出す。
  4. 後続のリクエストにそれを代入して利用できるようにする。

です。これをテスト時はこのようにすればよいのです。

  1. ヘッダやCookieからユーザーidもしくはメールアドレスを抜き出す。
  2. 後続のリクエストにそれを代入して利用できるようにする。

このように利用することを想定しています。セッション情報部分に直接メールアドレスをわたしていることに注意してください。

Cookieセッションの場合
test('happy path', (done) => {
  const reqUser = createReqUser('ADMIN');
  
  request(app)
    .get('/users')
    .set('Cookie', [`session=${reqUser.email};`])
    .expect(200, done); // 200ステータスコードが返ってくる。
})
JWT認証の場合
test('happy path', (done) => {
  const reqUser = createReqUser('ADMIN');
  
  request(app)
    .get('/users')
    .set('Authentication', `Bearer ${reqUser.email}`)
    .expect(200, done); // 200ステータスコードが返ってくる。
})

あとはセッションチェック部分をテストように置き換えるだけです。Cookieのテスト用セッションモジュールは僕は作ったことのないのでtestSessionはイメージで書いています。動作保証はないです。

Express セッションにsecretを指定しているイメージ
const testSession = (req, res, next) => {
  req.user = req.cookies['session'];
  next();
}

if(process.env.NODE_ENV === 'test'){
  app.use(testSession); // テスト用のセッションチェックミドルウェア。
} else {
  app.use(session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    store: new RedisStore({ client: redisClient }),
    cookie: { httpOnly: true, secure: false, maxage: 1000 * 60 * 30 }
  }))
}

NestJSにおけるJWT認証のテストモジュールはこちらにサンプルコードがあります。参考にしてください。

https://zenn.dev/dove/articles/34e27a82444fe0#apiテストを並列実行できるためにやったこと(あきらめたこと)

リクエストユーザーの権限まわりをどうセットアップするか

リクエストユーザーが管理者権限をもっていなければ403エラーを返すAPIがあります。これはどうテストすべきでしょうか?通常権限と管理者権限をもつリクエストユーザーそれぞれで実際にAPIを叩いてみて、200サクセスを返すのか、403エラーを返すのかをチェックすればよさそうです。

リクエストユーザーの作成はグローバルセットアップでも可能ですが、各テストごとのセットアップで作成することをおすすめします。セットアップのオーバヘッドはありますが、そちらのほうが平行テストと相性がよいからです。

リクエストユーザーを作成する場合、テスト用のユーザーのメールアドレスを固定するときとしないときで作り方が変わります。メールアドレスを例えば[email protected]固定する場合は、そのメールアドレスをもつユーザーがすでに存在していればUPDATE、存在してなければINSERTします。

ここで、ダミーのモデルを生成する関数を作っておくと楽です。idもランダムで持つことをおすすめします。

buildDummyUser.ts
export const dummyNumber = (args?: { min?: number; max?: number }): number => {
  const min = args?.min ?? 0;
  const max = args?.max ?? 2147483647; // 4バイト integer 最大

  return Math.floor(Math.random() * (max - min) + min); // The maximum is exclusive and the minimum is inclusive
};

cosnt permissionList = ['ADMIN', 'USER'];

const buildPermission = () => {
  return permissionList[
    dummyNumber({ min: 0, max: permissionList.length - 1 })
  ];
};

export const buildDummyUser = (options) => {
  id: id: options?.id ?? dummyNumber(),
  permission: options?.permission ?? buildPermission(),
}
createReqUser.ts
import { buildDummyUser } from './buildDummyUser';
export const createReqUser = async (options: UserConstructor) => {
  // ダーミデータツールのセクションで紹介した方法でユーザーモデルのダミーデータを作成する。
  // モデルのダミーデータはデフォルト値はできればランダムのほうがプロパティテストも兼ねて良い。
  const user = buildDummyUser({
    // optionsの項目をすべて渡して、その上からメールアドレスを上書きするJSの書き方。
    ...options,
    email: '[email protected]'
  });
  
  // メールアドレスを指定してアップサートする。
  await User.create(user, { onDuplicate: ['email']});
  
  return user;
}

メールアドレスを固定しない場合、通常のユーザー作成と同じになります。

createReqUser
// メールアドレスを固定しなくても良い場合。
export const createUser = async (options: UserConstructor) => {
  const user = buildDummyUser(options);
  // buildDummyUserによってidもランダムで生成されるので、衝突しない前提で保存する。
  await User.create(user);
  
  return user;
}

以下は、メールアドレスを固定しない場合のサンプルです。

user.test.ts
const request = require('supertest');
import { createUser } from './createUser';

describe('/users', () => {

  describe('アドミンユーザーのとき', () => {
    let app;

    // セットアップ
    beforeAll(() => {
      app = express();
    });
    
    // テスト実行後データ消す。
    // afterAll(async () => {
    //  await deleteAllTable();
    //})
  
    describe('アドミンのとき', () => {
      let reqUser: User;
      
      beforeAll(() => {
        reqUser = createUser({
	  permission: "ADMIN";
	});
      });
      
      describe('/users #GET', () => {
        test('happy path', (done) => {
          request(app)
            .get('/users')
            .expect(200, done); // 200ステータスコードが返ってくる。
        });
      });
    });
    
    describe('一般ユーザーのとき', () => {
      let reqUser: User;
      
      beforeAll(() => {
        reqUser = createUser({
	  permission: "USER";
	});
      });
      
      describe('/users #GET', () => {
        test('happy path', (done) => {
          request(app)
            .get('/users')
            .expect(403, done); // 403ステータスコードが返ってくる。
        });
      });
    });
  })
});

NestJSの場合はDIが内蔵されているので、直接ORM系のライブラリをimportしなくても、appモジュールから渡すことができます。たとえばRepository-Entityパターンを利用している場合、次のように、UserRepositorycreateUserBuilderに注入することができます。

NestJS user.test.ts
const request = require('supertest');
import { createUserBuilder } from './createUserBuilder';
import { UserEntity, UserEntityConstructor, UserRepository } from './user/entity'; 
import { AppModule } from './app.module';

describe('/users', () => {
  describe('アドミンユーザーのとき', () => {
    let app;
    let createUser: (
      options?: Partial<UserEntityConstructor>,
    ) => Promise<UserEntity>;
  
    // セットアップ
    beforeAll(() => {
      const module = await Test.createTestingModule({
        imports: [AppModule],
      }).compile();

      app = module.createNestApplication();
      
      await app.init();  
      
      createUser = createUserBuilder(
        app.get<UserRepository>(UserRepository);
      );
    });
  
    describe('アドミンのとき', () => {
      let reqUser: User;
      
      beforeAll(async () => {
        reqUser = await createUser({
	  permission: "ADMIN";
	});
      });
      
      describe('/users #GET', () => {
        test('happy path', (done) => {
          request(app.getHttpServer())
            .get('/users')
            .expect(200, done); // 200ステータスコードが返ってくる。
        });
      });
    });
  })
});
createUserBuilder
import { UserEntity, UserEntityConstructor, UserRepository } from './user/entity'; 

export const createUserBuilder = (userRepository: UserRepository) = async (options: UserEntityConstructor) => {

  const user = buildDummyUser(options);
  
  // buildDummyUserによってidもランダムで生成されるので、衝突しない前提で保存する。
  await userRepository.save(user);
}

こちらのほうが直接UserRepositoryをimportしなくて済むので、より安全ですね。

ところで、急に() => () => {}の書き方がでてきて驚いたかもしれません。これはビルダーパターンによく利用されるやつで、このように書くと

const a = (one) => (twe) => {
  console.log(one, twe);
}

このように利用できます。

a('A')('B'); // A B と出力

その処理に2つの依存があるけど、同時に引数に指定したくないとき(依存を入れるタイミングが異なるとき)に利用します。

createUserBuilderと同じように他のデータも作成にも応用できそうですね。

APIテストのアンチパターン

URLのIDをハードコーディングする

これはテスト用のデータ作成時に連番でidが振られるタイプのテーブルモデルを利用していると陥りやすいですね。まずは以下のコードを見てください。特にurlを指定している部分です。

user.test.ts
const request = require('supertest');
import { createUserBuilder } from './createUserBuilder';
import { User, UserConstructor } from './user'; 
import { AppModule } from './app.module';

describe('/users', () => {

  describe('アドミンユーザーのとき', () => {
    let app;
    let createUser: (
      options?: Partial<UserEntityConstructor>,
    ) => Promise<UserEntity>;
  
    // セットアップ
    beforeAll(() => {
      const module = await Test.createTestingModule({
        imports: [AppModule],
      }).compile();

      app = module.createNestApplication();
      
      await app.init();   
     
      createUser = createUserBuilder(
        app.get<UserRepository>(UserRepository);
      );
    });
  
    describe('アドミンのとき', () => {
      let reqUser: User;
      
      beforeAll(async () => {
        await createUser({
	  id: 1, // IDを指定している!!
	});
      });
      
      describe('/users/1 #DELETE', () => {
        test('happy path', (done) => {
          request(app.getHttpServer())
            .delete('/users/1') // IDをハードコーディングしている!!!
            .expect(200, done); // 200ステータスコードが返ってくる。
        });
      });
    });
  })
});

コメントで指摘しているようにAPIのURL/users/1をハードコーディングしています。これは良くないです。

idをハードコーディングすると、テストが壊れやすくなります。どこでどのIDを生成して利用しているのか、コードを注意深く扱う必要があります。また並列実行がしにくくなります。

解決策はテスト実行前にモデルを生成して、それを変数に保持し、そこからidを参照することです。

    describe('アドミンのとき', () => {
      let user: UserEntity;
      
      beforeAll(async () => {
        const user = await createUser(); // idの生成はランダムに任せる。
      });
      
      describe('/users/1 #DELETE', () => {
        test('happy path', (done) => {
          request(app.getHttpServer())
            .delete(`/users/{user.id}`)
            .expect(200, done); // 200ステータスコードが返ってくる。
        });
      });
    });

複数のテストに共通のテストフィクスチャを使う

テストフィクスチャとはテストの前にあらかじめ入れておくデータのことです。アンチパターンとまではいきませんが、このテストフィクスチャに複数のテストが依存してしまうのがよくないという話をします。

複数のテストが依存しているため、そのテストフィクスチャを変更すると他のテストに影響が出てくる可能性があるからです。逆に影響がでるからテストフィクスチャの変更に慎重になり、あれは触ってはいけないものだとなってしまうと、今度はテストがしにくくなります。

アプリで前もって必須なデータ出ない限りは、テストデータはその場その場で作成することをおすすめしています。

Repositoryパターンを利用しているのにデータ作成にRepositoryを利用しない

DBへのアクセスにSeuquelizeやTypeORMを利用しているけどRepositoryパターンを導入しているとき、テスト時のモデル生成に直接ライブラリのモデルを使いたくなるかもしれません。しかし、そこは自分で作ったRepositoryを使いましょう。RepositoryはEntityに特化しているため、テストの場合においても、モデルの生成にRepositoryを使うと、自然とEntityを扱うことができ、テストとコード間のギャップが少なくなりあとから楽です。

APIテストで活躍するグローバルセットアップとグローバルティアダウン

jestにはグローバルセットアップとグローバルティアダウンという機能があります。

const config = {
  globalTeardown: '<rootDir>/src/share/teardownJest.ts',
  globalSetup: '<rootDir>/src/share/setupJest.ts',
};

module.exports = config;

グローバルセットアップではアプリで前もって必須なデータを準備しておくのにい役に立ちます。
グローバルティアダウンではデータベースの削除など全体の後処理をするのに役に立ちます。

APIテスト時はStripeとかの外部サービスなどをどうするか。

できる限り、Dockerやdocker-composeを使ってmockすることをおすすめします。なるべくjestのstubやmock機能を使わずにすむのであれば、テストがシンプルになります。もし公式がモックサーバーを提供していない場合は、外部サービスのurlとパスさえわかればDocker使って自分でモックサーバーを作れるので、それで対応しましょう。

https://www.mock-server.com/where/docker.html

例えば僕はAPIテストをするときは、docker-composeを使って、外部サービスのモックサーバーを以下のようにたてています。

docker-compose
version: '3.7'

services:

  app:
    build:
      context: .
      dockerfile: ./infra/node/Dockerfile
    ports:
      - '3000:3000'
      - '9229:9229'
    init: true
    volumes:
      - '.:/home/node/my-app'
    env_file:
      - .env.local
    command: npm run start:dev

  postgres:
    build: ./infra/postgres
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./infra/postgres/initdb:/docker-entrypoint-initdb.d
    ports:
      - '5432:5432'
    environment:
      - POSTGRES_HOST_AUTH_METHOD=trust

  mock-server:
    image: mockserver/mockserver:latest
    ports:
      - 1080:1080
    environment:
      MOCKSERVER_WATCH_INITIALIZATION_JSON: 'true'
      MOCKSERVER_PROPERTY_FILE: /config/mockserver.properties
      MOCKSERVER_INITIALIZATION_JSON_PATH: /config/initializerJson.json
    volumes:
      - ./infra/mockServer:/config

  stripe:
    image: stripemock/stripe-mock:v0.110.0
    ports:
      - '12111-12112:12111-12112'

volumes:
  pg-data:
    driver: 'local'
  node_modules:

次へ

バックエンドのテスト ~ユニットテスト編~