bitbank techblog

Jestでテストを書こう!

はじめに

はじめまして。
エンジニアのtaptappunです。

我々は普段からビットコインという「お金」を扱ったサービスを開発しています。
そのため、日々バグをなくす努力をしており、その一つとして自動テスト(CI)を導入しています。

ビットバンクでは普段、Node.js(TypeScript)を用いて開発しています。
今回はNode.jsのテストフレームワークであるJestを利用したテストの導入方法と実践的なテストの書き方について紹介していきます。

Jestについて

Jestは、Facebookが開発を進めている、オープンソースのJavaScriptのテストフレームワークです。

Jest(Github)

TypeScriptで記述したものでも利用できます。
テストフレームワークであるため、テストを書くために必要な一通りの機能が提供されています。
弊社ではTypeScriptで記述したテストを実行し、その結果を確認することでテストを行っています。
さらに、実行したテストのCoverageを計測する機能もあります。

ウォーミングアップ

以降では、Jestの導入方法から簡単なテストの記述まで紹介していきます。

Jestのインストール

  • npmを使う場合

npm install jest --save

  • yarnを使用している場合

yarn add jest

  • jest コマンドを使いたい場合は

npm install -g jest

または

yarn add global jest

  • 今回はTypeScriptで記述していくので、Jestのインストールに加え、ts-jestとJestの型定義ライブラリであるDefinitelyTyped(@types/jest)のインストールも必要になります。

npm install ts-jest @types/jest --save

簡単なテストを作ってみる

warmingup.spec.tsというファイルを作成し、以下のようなサンプルコードを記述します。

describe('たし算', () => {
  it('1たす3は4です', () => {
    const result = 1 + 3;
    expect(result).toBe(4);
  });
});

上記のテストでは足し算を行った結果が正しい結果となっているのかを確認するためのテストになります。

Jestでは describeit を使い、テストケースについて書いていき、it ブロックの中で実際にテストが行われます。
テストの判定には expect() を使うことで、確認したいテスト結果の成否を判定します。

作ったテストを実行してみる

作成したテストを実行して、結果を検証してみます。jest コマンドを使用する場合は

jest

と実行すると、作成されたテストすべてを実行します。
個別のテストのみを実行したい場合は

jest warmingup.spec.ts

と末尾にファイルを指定することで個別にテストを実行できます。

テストの結果が正しければ、以下のように緑色のチェックで表された結果が出力されます。

test-result-green

次に、以下のように内容を書き換えテストを実行します。

describe('たし算', () => {
  it('1たす3は4です', () => {
    const result = 1 + 300;
    expect(result).toBe(4);
  });
});

今度はテストの結果が間違っているので、赤色の×マークで表された結果が出力されます。

test-result-red

このようにコードに手を加えた結果、テストに失敗していることが検知できたので、これによりエンバグやリグレッションが発生したのを検知して未然に防ぐことができました。
という流れです。

テスト記述におけるそのほかの機能について

テストを書いていくと、上記以外にも機能が必要になってきます。
Jestには beforeAll, afterAll, beforeEach, afterEach, といった関数が用意されており、いずれもテストが実行される前後に実行されます。

以下にこれらの関数を用いたサンプルコードを示します。

describe('たし算', () => {
  describe('変数を使います', () => {
    let beforeAllSumResult = 100;
    let beforeEachSumResult = 100;

    // このテストファイルのすべてのテストが実行される前1回だけ実行される
    beforeAll(() => {
      beforeAllSumResult = beforeAllSumResult + 33;
    });

    // このテストファイルのすべてのテストが実行された後1回だけ実行される
    afterAll(() => {
      beforeAllSumResult = 100;
    });

    // このテストファイルにあるテスト(it)が実行される前に毎回実行される
    beforeEach(() => {
      beforeEachSumResult = beforeEachSumResult + 33;
    });

    // このテストファイルにあるテスト(it)が実行された後に毎回実行される
    afterEach(() => {
      beforeEachSumResult = beforeEachSumResult - 33;
    });

    it('100に33をたすと133です', () => {
      expect(beforeAllSumResult).toBe(133);
    });

    it('100に33をたすと133です', () => {
      expect(beforeEachSumResult).toBe(133);
    });
  });
});

テストケースだけを先に作るベストプラクティス

TDDBDDといった開発手法が提唱されています。

テストケースだけを先に記述し、後で処理の中身を実装したいケースがあります。
このように先にテストケースだけ記述したい場合、はit.skipと記述することで実現できます。

describe('たし算', () => {
  it.skip('2.0たす3.0は5.0です', () => {});
});

it.skipを用いたテストを実行すると、以下のように黄色い◯マークが入った結果が出力されます。

test-result-skip

Jest実行時の各種設定について

Jestにはコマンド実行時に指定できるオプションが多くあります。
どのようなオプションがあるのか、詳しくはこちらを参照してください。

またこれら各種オプションはConfig(json , js)ファイルに記述し、Configファイルを指定して実行することでオプションに反映させることができます。

jest -c configファイル

Jest実行時にデフォルトで読み込むファイルはjest.config.js|jsonです。
Configファイルに設定できるオプションはコマンドオプションと少々差分があります。
詳しくはこちらにJestのConfigファイルに指定できるオプションについて記述されています。

実践的なTips

ここからは実際の開発において記述されるテストについて紹介していきます。

外部の処理を読み込んだテスト

実際にテスト記述していく場合、npm install でライブラリを読み込んだり、外部の処理を読み込んでテストを記述することがほとんどになると思います。
Jestの中でもNode.jsにて外部処理を呼び出す方法であるrequireimport を使用することで、ライブラリを読み込みます。

今回はTypeScriptでテストを記述するため、jest.config.jsに以下のような設定が必要です。

module.exports = {
  transform: {
    '^.+\\.ts$': 'ts-jest',
  },
};

次に、実際に外部処理を読み込んだテストのサンプルコードを紹介します。
たとえば、実際の処理がstandardPractices.tsに記述された処理を読み込んだテストを行います。

// functionをexportして使用する場合
export function addCalcFun(baseValue: number, addValue: number): number {
  return baseValue + addValue;
}

// classをexportして使用する場合
export class Calc {
  private baseValue: number;

  constructor(baseValue: number) {
    this.baseValue = baseValue;
  }

  add(addValue): number {
    return this.baseValue + addValue;
  }
}

standardPractices.tsに定義された処理を読み込んだテストを書くと以下のようになります。

// importを使う場合
import { addCalcFun } from './standardPractices';

describe('functionを読み込んで使う', () => {
  it('1たす8は9になる関数で実行します', () => {
    expect(addCalcFun(1, 8)).toBe(9);
  });
});

// requireを使う場合
const standardPractice = require('./standardPractices');
describe('classを読み込んで使う', () => {
  it('9たす23は32になるようなClac Classで実行します', () => {
    const calc = new standardPractice.Calc(9);
    expect(calc.add(23)).toBe(32);
  });
});

これを

jest -c jest.config.js standardPractices.spec.ts

と実行し、テストが通ることを確認してみてください。

非同期処理のテスト

Node.js(TypeScrip)で非同期処理を記述する場合、現在ではPromise, async/await が多く使われます。
今回はPromise, async/awaitを使った処理のテストのサンプルコードを以下に記述します。

実際の処理が以下のようなfunctionであるとします。

export async function addCalcAsyncFun(baseValue: number, addValue: number): Promise<number> {
  return baseValue + addValue;
}

この処理を読み込んだテストを記述したサンプルコードを以下に紹介します。

import { addCalcAsyncFun } from './standardPractices';
describe('async/awaitを使ったテスト', () => {
  describe('beforeEachでasync/await', () => {
    let result = 0;
    beforeEach(async () => {
      result = await addCalcAsyncFun(8, 22);
    });

    it('8たす22は30をasync/awaitで', () => {
      expect(result).toBe(30);
    });
  });

  describe('itの中でasync/await', () => {
    it('7たす21は28をasync/awaitで', async () => {
      const result = await addCalcAsyncFun(7, 21);
      expect(result).toBe(28);
    });

    it('7たす22は29をexpect reolvesで', () => {
      expect(addCalcAsyncFun(7, 22)).resolves.toBe(29);
    });
  });
});

エラーの発生を確認するテスト

正常に処理が実行されたかどうかだけでなく、エラーが発生してほしい時にエラーが正常に発生してくれたかどうかを確認することもJestではできます。

実際の処理が以下のような処理だったとします。

export function callErrorFun(): void {
  throw new Error('error');
}

export async function callErrorAsyncFun(): Promise<void> {
  throw new Error('asyncError');
}

この処理を実行したらエラーが発生したかどうかを確認するテストのサンプルコードは以下に示します。

import { callErrorFun, callErrorAsyncFun } from './standardPractices';
describe('エラーの発生を確認するテスト', () => {
  it('通常のエラー', () => {
    expect(callErrorFun).toThrow();
  });

  it('async/await(Promise)の中でのエラー', () => {
    expect(callErrorAsyncFun()).rejects.toThrow();
  });
});

stub, mock, spyを使ったテスト

stub、mock、spyいずれもテストを実行する場合に代わりとなる値を返してくれるものです。

たとえば乱数を使ったテストや通信を用いたテストを記述する場合など、本当に処理を実行してほしくない場合や実行されるとテストを通せない場合のテストを書く場合に使われます。

以下にJestにてmock, spyを使ったテストのサンプルコードを紹介します。

describe('stub, mock, spyを使ったテスト', () => {
  it('Math.random関数をmockとして取得し、呼び出してみる', () => {
    // function自体を上書いて仮のmock functionを作ってしまう
    const randomMock = jest.fn(Math.random);
    const mathMockFunc = randomMock.mockReturnValue(0.5);
    expect(mathMockFunc()).toBe(0.5);
  });

  it('Math.random関数をspyして、呼び出してみる', () => {
    // functionの結果を上書いてしまう場合はspyOnを使う
    const randomMock = jest.spyOn(Math, 'random');
    randomMock.mockReturnValue(0.7);
    expect(Math.random()).toBe(0.7);
  });
});

処理が呼ばれたかどうか確認するためのテスト

logが出力されたかどうか確認したい場合など、特定の処理が呼ばれたかどうか確認したいケースも出てきます。Jestではこのような場合のテストも書くことができます。

実際の処理が以下のような処理だったとします。

export function callConsoleLog(logMessage: string): void {
  console.log(logMessage);
}

特定の処理が呼ばれたかどうかを確認するテストのサンプルコードは以下のようになります

import { callConsoleLog } from './standardPractices';
describe('処理が呼ばれたかどうか確認するためのテスト', () => {
  it('console.log()をspyOnしてMockしたものを呼び出したのかどうか確認する', () => {
    const consoleLogMock = jest.spyOn(console, 'log');
    callConsoleLog('test');
    // toHaveBeenCalledWithは指定された関数が指定された引数の値で一回でも呼ばれたのかどうか確認する場合に使用する
    expect(consoleLogMock).toHaveBeenCalledWith('test');
  });
});

APIとして返すテスト

Node.jsをサーバとして使う場合、Express を使ってサーバを作成することが多くあります。

(Nest.jsSails.jsといったNode.jsフレームワークにおいてもExpressが使われています)

Expressを使って、サーバを作成し、レスポンスとしてJSONを返すWeb APIを作ったときのテストのサンプルコードを以下に記述します。

まず、Express のインストールとTypeScriptの型定義をインストールします。

npm install express @types/express --save

そして、実際の処理が以下のような処理だったとします。

import * as express from 'express';

const app = express();
app.get('/', (req, res) => {
  res.json({ hello: 'world' });
});
// ポート番号5678でリクエストを受け取る
app.listen(5678);

export default app;

上記の処理の内容のテストを記述していきます。
Expressでは内部ではJestではなくsupertestというテストフレームワークを使ってテストが作られています。このExpresssupertestを使うことで、Expressのユニットテストを記述できます。
まず、supertest のインストールとTypeScriptの型定義をインストールします。

npm install supertest @types/supertest --save

そして、実際にテストとして記述したサンプルコードを以下に示します。

import app from './apiServer';
import * as request from 'supertest';

describe('APIとして返すテスト', () => {
  it('expressサーバー上でAPIの結果を受け取るテスト', async () => {
    const response = await request(app).get('/');
    expect(response.status).toBe(200);
    expect(response.body).toEqual({ hello: 'world' });
  });
});

通信を送る場合のテスト

Node.jsを用いた処理を記述する場合、外部のWeb APIを実行し、データを取得することも多くあります。
この場合ユニットテストにおいては実際に通信を行ってしまうと、テストとして動作が不安定になるなどの問題が発生してしまいます。

そのため、ユニットテストにおいては、上記に挙げた、mockを使って通信のシミュレーションを行ってテストを行います。
今回は通信を行うライブラリとしてaxiosを用いて、通信を行う場合のサンプルコードを記述していきます。

まず、axios のインストールとTypeScriptの型定義をインストールします。

npm install axios @types/axios --save

そして、実際の処理が以下のような処理だったとします。

import axios from 'axios';

export async function getRequest(): Promise<any> {
  const response = await axios.get('http://localhost:5678');
  return response.data;
}

処理を読み込み、意図した通信とその結果を取得できるかどうか確認するテストのサンプルコードを以下に示します。

import axios from 'axios';
import { getRequest } from './httpRequest';

describe('通信を送る場合のテスト', () => {
  let httpRequestGetMock: jest.SpyInstance;

  beforeEach(() => {
    httpRequestGetMock = jest.spyOn(axios, 'get');
    httpRequestGetMock.mockResolvedValue({ data: { message: 'hello!!' } });
  });

  it('mockで意図したURLに通信を送り、返ってくるはずのものを確認するテスト', async () => {
    const res = await getRequest();
    expect(res.message).toBe('hello!!');
    expect(httpRequestGetMock).toHaveBeenCalledWith('http://localhost:5678');
  });
});

データベースのデータと連携するテスト

Node.jsでWebサーバを作成する場合、Databaseを操作が必要になることが多くあります。

上記の紹介に合わせて、TypeORMMySQLと連携させたテストの記述例を紹介します。

まず、MySQLをインストールし、TypeORMMySQLのアダプタをインストールします。

npm install typeorm mysql --save

TypeORM からMySQL
にアクセスして使えるうようになるために、ormconfig.json に設定を追加します。

以下はサンプル設定です。

{
  "type": "mysql",
  "host": "localhost",
  "port": 3306,
  "username": "root",
  "password": "",
  "database": "testtest",
  "synchronize": true,
  "logging": false,
  "entities": [
    "./entity/**/*.ts"
  ]
}

上記の設定のように、MySQLにて testtest のデータベースをあらかじめ作成しておきます。

CREATE DATBASE testtest

次にTypeORMにおけるEntityを作成します。

以下のように、entity/user.tsUserEntityを作成します。

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;
}

この状態で、typeorm schema:sync コマンドを実行することで、MySQLに user テーブルが作成されます。
また、上記、typeorm schema:sync を実行させるためには、tsconfig.jsonに以下の設定を追記する必要があります。

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowJs": true
  }
}

この状態で実際にTypeORMを使い、MySQLからデータの操作を行うサンプルコードを以下に示します。

import { createConnection, Connection } from 'typeorm';
import { User } from './entity/user';

let connection: Connection = null;

// MySQLに接続しに行く処理
export async function getConnection(): Promise<Connection> {
  if (connection != null) {
    return connection;
  }
  connection = await createConnection();
  return connection;
}

// userテーブルからnameが一致するデータを取得する
export async function findByName(name: string): Promise<User> {
  const conn = await getConnection();
  return conn.getRepository(User).findOne({ name });
}

このMySQLからデータの操作を行うテストのサンプルコードを以下に示します。

import { User } from './entity/user';
import { getConnection, findByName } from './userRepositorySample';

describe('userRepository', () => {
  // あらかじめ、MySQLにデータを入れる
  beforeEach(async () => {
    const dbConnection = await getConnection();
    const user = new User();
    user.name = 'testUser';
    await dbConnection.manager.save(user);
  });

  // テストが終わったらMySQLにいれたデータを空にする
  afterEach(async () => {
    const dbConnection = await getConnection();
    await dbConnection.manager.clear(User);
  });

  it('findByName', async () => {
    const user = await findByName('testUser');
    expect(user.name).toBe('testUser');
  });
});

結合テスト

Jestは一般的にユニットテストを記述するテストフレームワークとして利用されることが多いですが、Jestを使ってインテグレーションテスト(結合テスト)を記述し、実行することもできます。

上記までに紹介した処理を組み合わせて、結合テストのサンプルコードを紹介します。

実際の処理はintegrationSample.tsにて、Expressを用いてサーバを起動する処理を作成します。

import { User } from './entity/user';
import { getConnection } from './userRepositorySample';
import * as express from 'express';
import * as bodyParser from 'body-parser';

const app = express();
app.use(bodyParser());
app.post('/', async (req, res) => {
  const dbConnection = await getConnection();
  const user = new User();
  user.name = req.body.name;
  await dbConnection.manager.save(user);
  res.json(user);
});

app.listen(8080);

export default app;

結合テストを行うために、実際にあらかじめサーバを起動させます。
サーバを常駐起動させるためにpm2をインストールします。

npm install -g pm2

pm2でTypeScriptのサーバを実行できるようにします。

pm2 install typescript

pm2を使ってをサーバを常駐起動します。

pm2 start integrationSample.ts

pm2によってサーバが起動した状態でaxiosを使って実際にlocalhostにリクエストを実行し、その結果を確認する結合テストのサンプルコードの紹介します。

import { User } from './entity/user';
import { getConnection, findByName } from './userRepositorySample';
import axios from 'axios';

describe('結合テスト', () => {
  afterEach(async () => {
    const dbConnection = await getConnection();
    await dbConnection.manager.clear(User);
  });

  it('稼働しているはずのサーバーにリクエストを投げて、意図した挙動になるか確認', async () => {
    const response = await axios.post('http://localhost:8080', { name: 'integrationTestUser' });
    expect(response.data.name).toBe('integrationTestUser');
    // リクエストを受け取っていればデータが作られているはず
    const user = await findByName('integrationTestUser');
    expect(user.name).toBe('integrationTestUser');
  });
});

これにより、サーバが起動している状態でリクエストを受け取り、意図した結果となるかどうかを確認する結合テストができました。

Coverage計測

テストにおける Coverage とは実際の処理の中でテストがどれくらいカバーできているか算出するものです。Jestにはテストの記述・実行だけでなく、この Coverage を計測する機能も備わっています。

Coverageの計測方法

実際に Coverage を計測するには以下のようなコマンドを実行して、テストを実行します。
jest --coverage

このコマンドを実行することで計測した Coverage を以下のように出力結果として表示します。

test-coverage

また、計測した Coverage をHTMLの形でファイルに出力し、参照できるようにすることもできます。その場合は以下のコマンドを実行して、テストを実行します。

jest --collectCoverage

これにより、テストを行って計測された Coverage がHTMLの形で${project}/coverage/lcov-report/index.htmlに出力されます。

Coverageの目安

Coverage はテストの記述量を表す一つの指標です。
目指すべき Coverage が何%なのかはプロジェクトによって変わります。

80%ぐらいまではテストを記述した分だけ上がりますので、まだテストが書かれていないのなら、少しずつテストを書いていき Coverage を上げていくことを一つの目標とすることをお勧めします。

最後に

Jestを使ってNode.js(TypeScript)におけるテストの書き方を紹介しました。
今回、ここで紹介したもの以外の機能もJestには存在しますので、ぜひ活用してみてください。
自動テストは確実にバグを減らし、ソフトウェアの品質を高めることができる唯一の方法です。TypeScript(JavaScript)でテストを記述しようと思われている方々の助けになってくれれば幸いです。
ぜひ、皆様もJestを使ってテストを書いていきましょう!!

Author image
About taptappun
expand_less