Help us understand the problem. What is going on with this article?

【Jest】テスト用のAPIとDBとうまく付き合っていく方法

始めに

apiのnodeのバージョンを8から14にしました(まだリリースはしてない)!!
14への移行自体は簡単だったもののテストの移行でかなり苦戦しました。。。
テストは古のライブラリmocha-coで書かれており、仕方なくmocha-coのシンタックスをjestに置き換える作業を開始しました。これが予想以上にめんどくさくその時のHow toを解決する方法がこの記事になります。
以下の問題にお悩みの方はこちらを参考していただけるかと思います。またいい方法をご存知の方はお教えください!!

  • TooManyConnectionとなってしまう
  • 毎回のテストでサーバー立ち上げとクローズをするのめんどくさい
  • --ranInBandつけたけどなんか挙動がよくわからない・・・。
  • mochaからのjestに移行したい

正確にjestのランタイムの仕様が分かっているわけではなく、調べたり試したりした結果なので間違いやもっと詳細が分かる方がいらっしゃいましたらぜひお教えください!!
また正確なコードを書くと記述量が多いので雰囲気で書いているところもあるのでご了承ください。

前提条件

  • テスト用のDBを立ててデータを流しこみながら行っている
  • モジュール単位のテストではなくApiに対してリクエストを投げてそのレスポンスとDBのデータを確認してテストを行う

といったことを前提とします。データベースはインメモリではなくデータベースサーバーを立てる前提です。ですのでテストは順次実行をしていく必要があります。(もともとそうなっていたのが理由としては大きいですが)実際の環境に近い環境を立てることができることがこの作りのメリットだったりします。逆にインメモリにすると並列でテストを実行することができるので、実行速度をあげることができます。

では、データベースサーバーを立てるテストの場合、順次実行をしていかないといけない理由としては、

スクリーンショット 2020-11-21 13.17.32.png

こんな感じに一斉に更新がかかると他のテストに影響が出ることがあります。
たとえば、テスト前にテーブルを消したり、データの更新をしたりとか・・・。すると想定されていた状態にないデータが生まれテストが落ちたします。しかも恐ろしいことにそれが処理時間によって変わるのでテストガチャが生まれます。
そのためこの場合は、順次実行をしていくのがよいです。

スクリーンショット 2020-11-21 13.21.50.png

こうすればテストファイルが一斉に更新されることによっておこるデータベースの不整合を防ぐことができます。
そのためのオプションとして、jestでは--runInBandオプションを利用します。

jest --runInBand

ただし、これだけでは問題が発生します。

テスト用のAPIサーバーが立ち上がらない or 立てたり切ったりし続けると遅い問題

具体的にどういう問題かについての前にAPIテストで行うことを書きます。

APIテスト

APIのテストでは

  • モジュール単位でテストする方法
  • APIサーバーを立ててリクエストを送ることによって

があります。後者の方法で有名なライブラリとしてはsupertestが挙げられます。
例えばexpresssupertestの両方を使ってサーバーを立てるコードは下記のように書けます。

// app.js
const express = require('express');
const app = express();

app.get('/', function (req, res) {
    res.send('Hello World');
}); 
module.exports = app;
// test-request.js
const supertest = require('supertest');
const app = require('./app');

module.exports = supertest.agent(app.listen(3000));
// index.test.js
const testRequest = ('./test-request');

describe('test', () => {
    it('200?', () => {
        testRequest
            .get('/')
            .expect(200);
    });
});

またテスト用のデータを流し込むためのモジュールも下記のように定義します。

// test-model.js
const Sequelize = require('sequelize');
const sequelize = new Sequelize('test', 'test', 'test', {
    host: '127.0.0.1',
    dialect: 'mysql',
});

const models = {...};

module.exports = {
    sequelize: sequelize,
    models: models
};

mochaの場合はtest-requestやtest-modelを読み込んでテストをすればそれでよく、問題は発生しません。
しかし、jestの場合はそうはいきません。

mochaにおける挙動

スクリーンショット 2020-11-21 18.53.43.png

といった形になります。そのため先ほどのコードで一度しかコネクションはできないですし、一度しかサーバーは立ち上がりません。

jestにおける挙動と問題

jestにおいてはどのようになるのかというと、

スクリーンショット 2020-11-21 18.54.12.png

といった形になります。そのためmochaのときのような書き方はやめてテストファイルごとに『立てて、切って』とする必要があります(書き換えの際はめっちゃ大変です)。
とはいえ、毎回そのようにするのは結構大変ですし、毎回『起動、コネクト、停止、切断』と実行していくとテスト完了にかかる時間が増えてしまいます。

  • 毎回のテストでサーバーの起動・停止、dbへのコネクション・停止をする必要がある
    • 記述量が増え、closeを忘れるとテストが落ちる
    • 処理が増えるため必然的に実行時間が長くなる
  • エラーハンドリングをしてcloseしなくても動くようにする
    • 大量のコネクションが貼られることになりtoo many connectionとなってしまう

といった問題が発生します。

そのためには、globalSetupとtestEnvironmentを使います。

globalSetupを使い一度のみ起動する

globalSetupを使います。

スクリーンショット 2020-11-21 18.54.37.png

jestではこういった形で各ファイルが実行されるため、一度しか実行されないglobalSetupでサーバーの起動をなどを行うのが良いかと思われます。

// globalSetup
const supertest = require('supertest');
const app = require('./app');

global.__request = supertest.agent(app.listen(3000));
// test-utils/request.js
module.exports = global.__request;
// test.ts
import request from 'test-utils/request';

describe('test', () => {
   it('200', () => {
     request.get('/').expect(200);
   });
});

気持ち的にこんな感じにglobalにロードしたモジュールを保存させてテストで利用できるようにしたいんですが、ここにはここで落とし穴があります。その落とし穴はsetUpFilesにあります。
setUpFilesでは何を行っているのかというか『あたらしい環境のセットアップです』具体的にはglobalをはじめとしたテスト実行用の環境を生成するタイミングになり、新しくglobalを作っているため、下記のようなコードの場合、

// globalSetUp
global.__global = 'I am from global setup';
// setUpFiles
console.log(global.__global); // <- 新しいコンフィグになるためundefined
global.__setUp = 'I am from setup files'; // <- ただしここで追加したglobalメンバーにはテストファイルがアクセス可能

となります。そのため、実際のテストファイルでも

// test.js
describe('test', () => {
  console.log(global.__global); // undefined
  console.log(global.__setUp); // I am from setup files;

となります。
親玉のランナーの実行環境とは別の環境が作られるというところがポイントになります。
setUpAfterEnvもタイミングは異なれど同じ挙動をします。

そこでどうするのかというとtestEnvironmentを利用します。

testEnvironment

testEnvironmentを使うことで柔軟にテスト環境を構築することができます。

// package.json
    "testEnvironment": "./my-custom-environment.js",
// my-custom-environment
const NodeEnvironment = require('jest-environment-node');

class CustomEnvironment extends NodeEnvironment {
  constructor(config, context) {
    super(config, context);
  }

  async setup() {
    this.global.__request = global.__request;
    await super.setup();
  }
}
module.exports = CustomEnvironment;

このようにsetupメソッドでglobal.__requestthis.global__request に設定することによってテストファイルの方で global.__requestにアクセスすることができます。

まとめ

最終的にはこのようになります。

アプリケーションコード

// app.js
const express = require('express');
const app = express();

app.get('/', function (req, res) {
    res.send('Hello World');
}); 
module.exports = app;
// model.js
const Sequelize = require('sequelize');
const sequelize = new Sequelize('test', 'test', 'test', {
    host: '127.0.0.1',
    dialect: 'mysql',
});

const models = {...};

module.exports = {
    sequelize: sequelize,
    models: models
};

テストユーティリティコード

// test-request.js
module.exports = global.__request;
// test-model
module.exports = global.__db;
// global-setup.js
const supertest = require('supertest');
const app = require('./app');
const db = require('model');

global.__request = supertest.agent(app.listen(3000));
global.__db = db;
// my-custom-environment
const NodeEnvironment = require('jest-environment-node');

class CustomEnvironment extends NodeEnvironment {
  constructor(config, context) {
    super(config, context);
  }

  async setup() {
    this.global.__request = global.__request;
    this.global.__db = global.__db;
    await super.setup();
  }
}
module.exports = CustomEnvironment;
// jest.config.json
{
    "testEnvironment": "./global-setup.js",
    "globalSetup": "./my-custom-environment.js"
}

テストコード

// test.js
const testRequest = ('./test-request');
const testModel = ('./test-model');

describe('test', () => {
    beforeAll(async () => {
      await testModel.reset();
    });
    it('200?', () => {
        testRequest
            .get('/')
            .expect(200);
    });
});

全体図

こんな感じに実装することができます。
また、mockを使ってinjectionすることも可能です。

Mockを使ったインジェクションによる方法

この方法では、管理するファイルは増えるもののアプリケーション内でデータベースなどにコネクトするためのモジュールなどを一括で変更できる点が優秀です。

テストユーティリティコード

// test-request.js
const supertest = require('supertest');
const app = require('./app');

module.exports = supertest.agent(app.listen(3000));
// test-model
const db = require('model');

module.exports = db;
// global-setup.js

global.__request = require('./test-request');
global.__db = require('./test-model');
// setup-after-env.js
jest.mock('test-request', () => global.__request);
jest.mock('test-model', () => global.__db);
// my-custom-environment
const NodeEnvironment = require('jest-environment-node');

class CustomEnvironment extends NodeEnvironment {
  constructor(config, context) {
    super(config, context);
  }

  async setup() {
    this.global.__request = global.__request;
    this.global.__db = global.__db;
    await super.setup();
  }
}
module.exports = CustomEnvironment;
// jest.config.json
{
    "testEnvironment": "./global-setup.js",
    "globalSetup": "./my-custom-environment.js",
    "setupFilesAfterEnv": "./setup-after-env.js",
}

テストコード

// test.js
const testRequest = ('./test-request');
const testModel = ('./test-model');

describe('test', () => {
    beforeAll(async () => {
      await testModel.reset();
    });
    it('200?', () => {
        testRequest
            .get('/')
            .expect(200);
    });
});

問題点

ただし、jestのmockと相性が悪いという問題点があります・・・。
というのもglobal上にサーバーコードがロードされているため、それらを実行したい時は各ファイルごとに一回サーバーを落としてもう一度ロードする必要があります。
そのため、globalにこれらをやるための入り口を開けてあげる必要がありました。
ここら辺みなさんどうしてるんですかね?いろいろとご意見伺いたいのもあって書かせていただきました。

YutamaKotaro
エンジニア兼データサイエンティストのはずが今では管理職兼デュエリスト
aircloset
「新しい当たり前を作る」を作ることをミッションに、airClosetを開発・運営しています。
http://corp.air-closet.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away