🍃

MongoDBをインメモリで動かしてユニットテスト

2022/11/05に公開約11,000字

はじめに

MongoDB へのクエリ部分をユニットテストしたいなーと思って、インメモリで MongoDB を動かしてくれるJest presetshelfio/jest-mongodbを使ってみました。
公式ドキュメント(Using with MongoDB · Jest)がそこそこわかる人向けな感じなので、本当に動くんかなと思いながら試して苦労しました。
試行錯誤しながら動かした結果を記載します。
Node.js x MongoDB 使ってバックエンド開発してて、DB へのクエリ部分もしっかりユニットテストしたい人の役に立てたら良いかなと思います。

ここにコードを置いてます。
optimisuke/hello-jest-mongodb: MongoDB をインメモリでテスト

環境構築

Docker

MongoDB を Docker で動かすためdocker-compose.ymlを準備しておきます。
MongoDB に接続する Node.js を動かすためにbackendという名前でこっちも記載しておきます。
Node.js はローカルで動かしても MongoDB にアクセスできるようにポートを開けておきます。
接続情報をMONGO_URLとして環境変数で準備してますが、ここでベタガキしちゃってます。
アンチパターンな気がするので、本番環境ではいい感じに設定必要です。

docker-compose.yml
version: "3.1"
services:
  backend:
    build: ./backend/
    depends_on:
      - mongo
    environment:
      - MONGO_URL=mongodb://testuser:password@mongo:27017/hoge?authSource=admin
    volumes:
      - ./backend:/app
    working_dir: /app
    tty: true
    networks:
      - dev_network
    # command: bash -c "npm install && npm start"
  mongo:
    image: mongo
    restart: always
    networks:
      - dev_network
    ports:
      - 27017:27017
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: password
    volumes:
      - mongo_db_data:/data/db
      - ./mongodb/init.js:/docker-entrypoint-initdb.d/init.js:ro
volumes:
  mongo_db_data:
networks:
  dev_network:

backendのイメージは Dockerfile をビルドしてます。
USERの設定しないと permission のエラー出たので、調べながらコピペして書きました。
結局、基本的にローカルで動かしてたので、徒労気味でした。

Dockerfile
FROM node:18-slim

RUN npm i npm@latest -g
RUN mkdir /app && chown node:node /app
WORKDIR /app

USER node
COPY --chown=node:node package.json package-lock.json* ./
RUN npm ci && npm cache clean --force
ENV PATH /app/node_modules/.bin:$PATH

MongoDB の初期設定のために、js ファイルをマウントしてます。
接続時の権限を絞るためにユーザーを作成してます。
ここも、環境変数で読み込む等の気配りが必要ですがベタガキしちゃってます。要注意。

mongodb/init.js
db = db.getSiblingDB("admin");
db.auth("root", "password");
db.createUser({
  user: "testuser",
  pwd: "password",
  roles: [
    {
      role: "readWrite",
      db: "hoge",
    },
  ],
});

db = db.getSiblingDB("hoge");
db.createCollection("test");
const doc1 = { name: "Hoge", age: 36 };
const doc2 = { name: "Fuga", age: 32 };
db.test.insertMany([doc1, doc2]);

ローカルとコンテナと両方使って確認してたので、node_modulesが被らないように、.dockerignoreに記載しました。

node_modules
.git

Node.js

依存してるパッケージを色々インストールします。

npm init --yes
npx tsc --init
npm install mongodb
npm install -D ts-node
npm install -D typescript @types/node
npm install -D @shelf/jest-mongodb
npm install -D jest @types/jest ts-jest

package.jsonはこんな感じです。

package.json
{
  "name": "hello-jest-mongodb",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.js",
  "scripts": {
    "start": "npx ts-node src/index.ts",
    "test": "jest"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@shelf/jest-mongodb": "^4.1.3",
    "@types/jest": "^29.2.2",
    "@types/node": "^18.11.9",
    "jest": "^29.2.2",
    "ts-jest": "^29.0.3",
    "ts-node": "^10.9.1",
    "typescript": "^4.8.4"
  },
  "dependencies": {
    "mongodb": "^4.11.0"
  }
}

tsconfig.jsonはデフォルトのままです。

tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "commonjs",                                /* Specify what module code is generated. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
    "strict": true,                                      /* Enable all strict type-checking options. */
    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
  }
}

Jest

jest の設定はこんな感じで、ここでやっと@shelf/jest-mongodbが出てきます。

jest.config.js
module.exports = {
  preset: "@shelf/jest-mongodb",
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest",
  },
};

jest-mongodbの設定はこんな感じです。
実際の MongoDB の名前と合わせるため、dbNamehogeと入れてますが、必要ない気がしてます。実際、コメントアウトしてもテスト通りました。ただ、合わせた方が気持ち良いので、設定してます。

jest-mongodb-config.js
module.exports = {
  mongodbMemoryServerOptions: {
    binary: {
      version: "4.0.3",
      skipMD5: true,
    },
    autoStart: false,
    instance: {
      dbName: "hoge",
    },
  },
};

コード

コードは 3 つのファイルに分けて書いてます。
最初はこんな感じ。
mongoDB へのコネクションを確立して、クエリして、クローズする感じです。そこそこシンプルに書いたつもりです。

index.ts
import { MongoDB } from "./mongo";
import { TestQuery } from "./testQuery";

const main = async () => {
  const mongoDB = new MongoDB();
  await mongoDB.connect();
  console.log("MongoDB Connected.");
  const collections = mongoDB.getCollections();

  const testQuery = new TestQuery(collections);
  const users = await testQuery.getUsers();
  console.log(users);
  const user = await testQuery.getUserByName("Hoge");
  console.log(user);
  await mongoDB.close();
};

main();

下記サイトを参考に、TypeScript の型を活用するために、コレクションをまとめる型を作りました。現在一つしかないので、ややこしくしてるだけですが、将来楽になるはずです(なってほしい)。

MongoDB + Node.js + TypeScript が強力だった | kohsweblog

あとは、ローカルで動かしているときは、環境変数を設定しなくても動くようにしてます。

collectionsclient| undefinedにしてるのが気になってます。もうちょい、うまいこと書く方法ないんやろか。

mongo.ts
import { Collection, MongoClient, ObjectId } from "mongodb";

export type Test = {
  _id?: ObjectId;
  name: string;
  age: number;
};

export type Collections = {
  test: Collection<Test>;
};

export class MongoDB {
  collections: Collections | undefined;
  client: MongoClient | undefined;
  connect = async () => {
    const URL =
      process.env.MONGO_URL ||
      "mongodb://testuser:password@127.0.0.1:27017/hoge?authSource=admin";
    this.client = await MongoClient.connect(URL);
    const db = this.client.db("hoge");
    // this.collections!.test =
    const test = db.collection<Test>("test");
    this.collections = { test: test };
  };
  getCollections = () => {
    return this.collections!;
  };
  close = async () => {
    await this.client?.close();
  };
}

最後に、MongoDB へのクエリ部分です。ようやくです。
コンストラクタで、コレクションをもらう感じにして DI 的なことをやってます。これにより、テスト時に MongoDB をインメモリのものに簡単に置き換えられます。

testQuery.ts
import { Collections, Test } from "./mongo";

export class TestQuery {
  collections: Collections;
  constructor(collections: Collections) {
    this.collections = collections;
  }
  getUsers = async () => {
    const result = (await this.collections.test.find().toArray()) as Test[];
    return result;
  };
  getUserByName = async (name: string) => {
    const result = await this.collections.test.findOne(
      { name: name },
      { projection: { _id: 0 } }
    );
    return result as Test;
  };
}

動かすとこんな感じです。

$ npm start

> hello-jest-mongodb@1.0.0 start
> npx ts-node src/index.ts

MongoDB Connected.
[
  {
    _id: new ObjectId("63652a85ca98ee33ec63e882"),
    name: 'Hoge',
    age: 36
  },
  {
    _id: new ObjectId("63652a85ca98ee33ec63e883"),
    name: 'Fuga',
    age: 32
  }
]
{ name: 'Hoge', age: 36 }

テスト

テストコードはこんな感じです。
beforeAllに書かれてる接続部分が、シンプルなので、とっつきやすい気がしてます。

TestQuery()のコンストラクタにインラインメモリで動く MongoDB 上に作ったコレクションを渡してあげることで、いい感じにテストできるようにしてます。

複数のテスト項目があるときは、どこでデータ入れるか等をもうちょいケアしてあげる必要がありそうです。

testQuery.test.ts
import { Collections, Test } from "./mongo";
import { TestQuery } from "./testQuery";

import { MongoClient, Db } from "mongodb";

describe("試しにテスト", () => {
  let connection: MongoClient;
  let db: Db;
  let collections: Collections;

  beforeAll(async () => {
    console.log("beforeAll");

    connection = await MongoClient.connect((global as any).__MONGO_URI__);
    db = connection.db();
    db.createCollection("test");
    collections = { test: db.collection<Test>("test") };
  });

  beforeEach(async () => {
    console.log("beforeEach");
    const test = db.collection("test");

    const doc1 = { name: "インメモリほげ", age: 36 };
    const doc2 = { name: "インメモリふが", age: 32 };
    test.insertMany([doc1, doc2]);
  });

  afterAll(async () => {
    console.log("afterAll");
    await connection.close();
  });

  test("指定したusernameのユーザーが取得できる", async () => {
    console.log("test start");

    const testQuery = new TestQuery(collections);

    const result = await testQuery.getUserByName("インメモリほげ");
    console.log(result);

    expect(result).toStrictEqual({ name: "インメモリほげ", age: 36 });
  });
});

テストを実行すると、こんな感じに出力されます。いい感じ。

$ npm test

> hello-jest-mongodb@1.0.0 test
> jest

  console.log
    beforeAll

      at src/testQuery.test.ts:12:13

  console.log
    beforeEach

      at src/testQuery.test.ts:21:13

  console.log
    test start

      at src/testQuery.test.ts:35:13

  console.log
    { name: 'インメモリほげ', age: 36 }

      at src/testQuery.test.ts:40:13

  console.log
    afterAll

      at src/testQuery.test.ts:30:13

 PASS  src/testQuery.test.ts
  試しにテスト
    ✓ 指定したusernameのユーザーが取得できる (22 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.826 s, estimated 2 s
Ran all test suites.

おわりに

いい感じに、インメモリでサクッと動作確認できたので満足です。
テスト駆動開発したいので、インメモリでサクッと動く MongoDB を使って、ユニットテストできるのはかなり良さげでした。
MongoDB 使ってる時点で、そんな難しいクエリが必要ない気もするから、本当に DB 接続したテストって必要なのかってところではありますが。
書き方は、もうちょいリファクタリングするかもですが、活用していきたいです。

今回触れてませんが、下記サイトを見ると、もっとシンプルにnodkz/mongodb-memory-serverを使って、テストしてるので、そっちの方が依存先減らせて良いのかもです。いつか試してみたいです。
MongoDB + Jest のテスト方法 | kohsweblog

参考

jest-mongodb

Using with MongoDB · Jest
shelfio/jest-mongodb: Jest preset for MongoDB in-memory server
MongoDB + Jest のテスト方法 | kohsweblog
Express + MongoDB で作成した API サーバーを Jest でテストする - Qiita

Jest

TypeScript のテストを Jest (ts-jest) でやってみる - Qiita
kulshekhar/ts-jest: A Jest transformer with source map support that lets you use Jest to test projects written in TypeScript.

MongoDB

はじめての MongoDB with Docker
Node.js から MongoDB に接続してみる - Qiita

GitHubで編集を提案

Discussion

ログインするとコメントできます