前回のBook モデル RESTAPIの実装にユニットテストを追加する。

mocha + chai + sinon

mocha + chai を使いモデルとルーティングのユニットテストを実装する。sinonは今回は使わないがたいていの場合セットで使う場合が多いので一応入れておく。

$ npm install --save-dev mocha
$ npm install --save-dev chai
$ npm install --save-dev chai-http
$ npm install --save-dev sinon

chai-httpはルーティングのアサーションで便利なので入れる

mockdate

カレント時刻をモックするため、MockDateというパッケージを使う

$ npm install --save-dev mockdate

<small>当初sinon.useFakeTimersを使おうとしたが、うまく使えなかった。</smal>

テストコードのディレクトリ

express プロジェクトのルートディレクトリにtest/ (mocha のデフォルト) を追加してそこに置く。

テストファイル名は*.test.jsとする

src/
  bin/
  models/
    book.js
  routes/
     book.js
     index.js
  test/
    models/
      book.test.js
    routes/
      books.test.js
    helper.js
  app.js
  package.json

テスト 実行方法

mocha を npm で呼ぶ。package.jsonscriptsに test コマンドを追加する。

package.json抜粋

{
  "scripts": {
    "test": "NODE_ENV=test mocha --recursive",
    "test:watch": "NODE_ENV=test nodemon --exec mocha --recursive"
  }
}
  • 全部のテストを実行する場合

    $ npm test
    
  • 特定の テストファイルを実行する場合

    $ npm test tests/models/book.test.js
    
  • watch モード。アプリのコード変更を監視してテストを自動実行 npm runになるので注意

    $ npm run test:watch
    

    引数のファイルパスは、単一ファイルも指定できるので特定の機能を集中して実装する時など便利。

    nodemonで監視して、mocha コマンドを実行する仕組み。mocha の-w,--watchオプションもあるが、mongoose.Schemaで``OverriteError`がでるので nodemon を使う。nodemon の場合は都度新しいプロセスになるので解決する。

mongoose のテスト用 DB 接続

テスト用 DB は環境変数MONGO_URI_testサフィックスを付けたモノにする。

process.env.MONGO_URI + "_test";

helper.js

tests/helper.jsを用意。テストに関する共通処理を実装

const mongoose = require("mongoose");

before(async () => {
  // MongoDBに接続
  return mongoose
    .connect(process.env.MONGO_URI + "_test")
    .catch((e) => console.error(e));
});

after(async () => {
  // MongoDBをコネクション切断
  return mongoose.disconnect().catch((e) => console.error(e));
});

afterEach(async () => {
  try {
    // すべてのコレクションをdropしてリセットする
    const collections = await mongoose.connection.db.collections();
    await Promise.all(collections.map(async (collection) => collection.drop()));
  } catch (e) {
    console.error(e);
  }
});

このhelper.jsを各テストファイルで require することでテスト毎に実装せずに済む。

app.js

app.jsの mongoose.connect の部分もテスト環境に合わせられるようにする。

// 環境変数NODE_ENVでテストモードを確認
const isTestEnv = (process.env.NODE_ENV || "").toLowerCase() === "test";
/**
 * Connect MongoDB
 */
mongoUri = process.env.MONGO_URI;
// testの場合は_testをsufixにする
if (isTestEnv) mongoUri = mongoUri + "_test";
const mongoose = require("mongoose");
mongoose.connect(mongoUri).catch((e) => {
  console.log(e);
});

FactoryGirl

テスト用データ生成ライブラリfactory-girlを使う。Ruby の FactoryBot や Python の FactoryBoy と同じ系。

$ npm install --save-dev factory-girl

tests/facory.js に定義する。factory.define(name, Schema, attributes)でデータを定義して、factory.create(name)でデータ生成するような使い方。

const FactoryGirl = require("factory-girl");
const factory = FactoryGirl.factory;
const Book = require("../models/book");
const adapter = new FactoryGirl.MongooseAdapter();

// use the mongoose adapter as the default adapter
factory.setAdapter(adapter);

factory.define("Book", Book, {
  title: factory.sequence("Book.title", (n) => `タイトル${n}`),
  author: factory.sequence("Book.author", (n) => `著者${n}`),
  year: 2022,
  pages: 100,
});

module.exports = factory;

後述のルーティングのテストで使っている。

モデルのユニットテスト

Bookモデルをテストする。tests/models/test_book.js

const helper = require("../helper"); // 共通処理
const Book = require("../../models/book");
const assert = require("chai").assert;
const mockdate = require("mockdate");

describe("Book Model", () => {
  beforeEach((done) => {
    // カレント時刻をmockする
    mockdate.set(new Date(2022, 9, 11, 14, 55));
    done();
  });

  afterEach((done) => {
    // mockをクリアする。しないとは他のテストケースに影響する
    mockdate.reset();
    done();
  });

  it("Bookが登録できること", async () => {
    let book = new Book({
      title: "foo",
      author: "bar",
      year: 2022,
      pages: 20,
    });
    let err = book.validateSync();
    assert.isUndefined(err);

    await book.save();
    book = await Book.findById(book.id);
    assert.strictEqual(book.title, "foo");
    assert.strictEqual(book.author, "bar");
    assert.strictEqual(book.year, 2022);
    assert.strictEqual(book.pages, 20);
    assert.strictEqual(
      book.createdAt.getTime(),
      new Date(2022, 9, 11, 14, 55).getTime()
    );
    assert.strictEqual(
      book.updatedAt.getTime(),
      new Date(2022, 9, 11, 14, 55).getTime()
    );
  });

  it("ValidateErrorがある場合エラーになること", async () => {
    let book = new Book({
      title: "",
      author: "bar",
      year: 2022,
      pages: 20,
    });

    try {
      await book.save();
    } catch (err) {
      assert.isTrue("title" in err.errors);
    }
  });

  describe("Validation", () => {
    it("titleはがない場合はエラーになること", () => {
      let book = new Book({
        title: "",
        author: "bar",
        year: 2022,
        pages: 20,
      });
      let err = book.validateSync();
      assert.isTrue("title" in err.errors);
    });
    it("authorがない場合はエラーになること", () => {
      let book = new Book({
        title: "foo",
        author: "",
        year: 2022,
        pages: 20,
      });
      let err = book.validateSync();
      assert.isTrue("author" in err.errors);
    });
    it("yearがない場合はエラーになること", () => {
      let book = new Book({
        title: "foo",
        author: "bar",
        year: null,
        pages: 20,
      });
      let err = book.validateSync();
      assert.isTrue("year" in err.errors);
    });
    it("pagesがない場合はエラーになること", () => {
      let book = new Book({
        title: "foo",
        author: "bar",
        year: 2022,
        pages: null,
      });
      let err = book.validateSync();
      assert.isTrue("pages" in err.errors);
    });
  });
});

ルーティングのテスト

Bookの REST API のテストをtests/routes/test_book.jsに実装する

const helper = require("../helper");
const factory = require("../factory");
const Book = require("../../models/book");
const app = require("../../app");

const assert = require("chai").assert;
const chai = require("chai");
chai.use(require("chai-http"));

describe("Book Router", () => {
  describe("GET /books/", () => {
    beforeEach(async () => {
      // テストデータ 3件生成する。factory-girl便利。
      await factory.createMany("Book", 3);
    });
    it("Bookの一覧を返すこと", (done) => {
      chai
        .request(app)
        .get("/books/")
        .end((err, res) => {
          assert.equal(res.status, 200);
          assert.equal(res.body.length, 3);
          done();
        });
    });
  });
});

c8 でカバレッジを取得

コードカバレッジを取得するのにはc8を使う。

$ npm insall --save-dev c8

package.jsonの script で test のコマンドをでc8コマンド経由でmochaを実行
するように修正する

{
  "scripts": {
    "test": "NODE_ENV=test c8 mocha --recursive --exit",
    "test:watch": "NODE_ENV=test nodemon --exec c8 mocha --recursive --exit",
  }
}

c8コマンドのオプションはいろいろ指定できるけど、コマンドが長くなるので.c8rc.jsonという設定ファイルで指定する。.c8rc.jsonはデフォルトで読み込んでくれるファイル名。

.c8rc.json

{
  "all": false,
  "reporter": [
    "text-summary",
    "html"
  ],
  "include": [
  ],
  "exclude": [
    `test/**/*.js
  ]
}

オプションはドキュメントを参照のこと

  • all: テストスイートで触れないファイルのカバレッジを取得するか否か
  • reporter: 結果の出力方式。text-summaryは標準出力にサマリを表示。html/coverageディレクトリに html で出力。他にも指定できる。
  • include: カバレッジ対象のホワイトリスト。ファイルパスを指定する。**,*ワイルドカード可
  • exclude: カバレッジ対象の除外リスト。前述のincludeexcludeでカバレッジ対象を絞る。
    node_modulesディレクトリはデフォルトで除外してくれるのでexcludeに指定する必要はない。
Udemy 独学でのアプリ開発に限界を感じたら

プログラミング初学者の皆さんに、 Udemy を強くお勧めしたいと思います。 Udemy は世界中のトップレベルのプログラミング講師が提供するオンライン講座を取り揃えています。以下は、 Udemy で学ぶことのメリットについての詳細です。

1. Udemy の豊富なコースの選択肢

Udemyには、数千ものコースがあります。初心者から上級者まで、プログラミングのあらゆるレベルを網羅しています。また、様々なプログラミング言語やツールに関するコースも多数あり、希望に合わせたコースを選ぶことができます。

2. 実践的な学習方法

Udemyのコースは、理論だけでなく実践的な学習も行えます。多くのコースには、プログラムの作成や実際のプロジェクトに取り組む演習が含まれています。これにより、理論だけでなく実践的なスキルも身につけることができます。

3. Udemy には質の高い講師陣

Udemyの講師陣には、世界中のトップレベルのプログラマーが多数在籍しています。彼らは、実務での経験を活かして、分かりやすい講義を行っています。

4. 初学者が始めるのにとても手頃な価格

Udemyのコースは、他のオンライン講座と比べて手頃な価格で提供されています。また、一度購入すると、終身アクセスが得られるので、自分のペースで学習することができます。

以上の理由から、プログラミング初学者の方には、Udemyが最適な学習プラットフォームであると考えられます。ぜひ一度、Udemyのコースを体験してみてください。


ウェブ開発の人気オンラインコース


ITとソフトウェアの人気オンラインコース


デザインの人気オンラインコース