概要
最近,TypeScriptでテスト駆動で開発しています.
しかし,TypeScript特有の細かい部分だったりで,ハマったり試行錯誤した部分があったので,その内容を書きます.基本的にTypeScriptやjsの環境周りの設定の仕方やJest特有のテストの書き方のハマりどころを書いています.
今回つくったリポジトリはこちら.
ライブラリのインストール
# npmのひな型作成
$ npm init -y
# 依存ライブラリの導入
$ npm i typescript express supertest
# TypeScript用の型の定義の導入
$ npm i @types/express @types/jest @types/supertest
# 開発系のライブラリの導入
$ npm i -D jest ts-jest
今回利用したライブラリ
Facebook製のテストライブラリです.他のMocha,Chai,istanbulなどいろいろあるのですが,個人的には一番使っているライブラリです.設定ファイルがかなり少なく,モック機能が強力です.
supertest
https://github.com/visionmedia/supertest
あんまり知見が出てこないのですが,express用のコントローラー層のテストをするには良さそうだったので導入しました.普通のテストライブラリだと,一般的なアサーションのテストはやりやすいですが,「/usersに対してPOST」といったエントリポイントに対するテストが行えないので導入致しました.
ツール周りの初期設定の仕方
あんまり明示されることもないのですが,初期設定を知っていると便利なこともあるので,設定の仕方を書いておきます.
# tsconfig.jsonの設定.(TypeScriptのデフォルト設定ファイルの生成)
$ npx tsc --init
# jest.config.jsの設定(TypeScriptを使う場合の,jestの設定の生成)
$ npx ts-jest config:init
ツールの設定
tsconfig.jsonの変更
今回,tsconfig.jsonを一部修正する必要があります.
それは,expressを使う際,
import * as express from "express";
と表記する必要があります.そのため,一部変更する必要があり,
"esModuleInterop": false
の設定を追加する必要があります.また,async/awaitを使うため,
"lib": ["ES2015"]
の設定も必須です.
jest.config.jsの変更
今回のリポジトリでは以下のようにしました.
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
moduleFileExtensions: ["ts", "tsx", "js"],
transform: {
"^.+\\.(ts|tsx)$": "ts-jest"
},
globals: {
"ts-jest": {
tsConfig: "tsconfig.json"
}
},
testMatch: ["**/*.spec.+(ts|tsx|js)"]
};
一番大事なところはtestMatchの部分で,*.spec.tsのような名前のファイルをテスト対象としています.
細かい設定の意味は,https://jestjs.io/docs/ja/configuration を参考にしてください
テストの仕方
実行自体は非常に簡単です.
$ npx jest --coverage
--coverage
はカバレッジの出力で,coverage
というフォルダが生成され,カバレッジの結果が格納されています.
テストの書き方
値のチェック
非常に基本的な,値が同じか?をチェックするタイプのものです.
このあたりの記法に違和感は少ないと思います.
describe("TypescriptとJestのテスト その1", () => {
it("基本的なテスト", () => {
const a = 10;
expect(a).toBe(10);
});
});
例外のチェック
ある関数が例外を送出する場合のテストです.それほど難しくはないですが,expectの引数は関数を渡してやる必要があります.
describe("TypescriptとJestのテスト その2", () => {
it("例外のテスト", () => {
const testFunction = () => {
throw new Error();
};
expect(testFunction).toThrow(Error);
});
});
非同期(async関数)のテスト
このあたりの非同期を用いたテストを書き始めると話がややこしくなってきます.
describe("TypescriptとJestのテスト その3", () => {
it("非同期のテスト", async () => {
const testFunction = async () => {
return 10;
};
await expect(testFunction()).resolves.toEqual(10);
});
});
- itに渡す関数がasync
- expectに対してawait
- 返り値のチェックはresolves.toEqualの形式
という3つを気にする必要があります.
非同期の例外のテスト
一番ややこしいのが非同期+例外の場合です.
describe("TypescriptとJestのテスト その4", () => {
it("非同期の例外のテスト", async () => {
const testFunction = async () => {
throw new Error();
};
await expect(testFunction()).rejects.toBeInstanceOf(Error);
});
});
ほぼほぼ非同期のテストと同じですが,
- itに渡す関数がasync
- expectに対してawait
- 返り値のチェックはrejects.toBeInstanceOfで判定
例外のチェックをtoBeInstanceOfで判断するのは違和感があるかもしれませんが,公式のドキュメントでも近いこと(toMatchによる検出)をやっています.
https://jestjs.io/docs/ja/asynchronous
このjestのややこしいところですが,toThrowが使えないことです.私も細かくは見ていませんが,asyncを使っているせいで,うまく動いていないようです.そのため,このような記法になっています.
公式のドキュメントでは,try-catchをするタイプの書き方も書かれていますが,私としては正常系のテストの書き方と,異常系のテストの書き方がほぼ同じになるので,わかりやすいかな.と思ってこの書き方を採用しています.
モックによるテスト
単体テストを行う際,テスト専用のオブジェクトを用意する場合があると思います.そのような場合のTypeScriptでjestのモックを使う方法を紹介したいと思います.
class Driver2 {
public getData(): string {
return "DriverClass";
}
}
class Formatter2 {
public constructor(private driver: Driver1) {}
public run(): string {
const output = this.driver.getData();
return `Driver is ${output}`;
}
}
describe("モックを用いたテスト", () => {
it("モックを用いる場合", () => {
const driverMockClass = jest.fn<Driver2>(() => ({
getData: (): string => {
return "MockClass";
}
}));
const driverMock = new driverMockClass();
const format = new Formatter2(driverMock);
expect(format.run()).toBe("Driver is MockClass");
});
});
jestが素晴らしいのはモックを使う場合,非常に簡単に使うことができることです.「モックにしたいクラスをjest.fn<>で定義する」のみです.私がテスト駆動を行うことに関して厳しいな.と思うことはこのモッククラスの生成でした.元々はinterfaceを用いたテストを書いていました(ソースはこちら).
これ自身,ライブラリを利用せず非常に簡単な方法で出来ます.しかし,一方で変更に弱く,クラスへ関数の追加などの変更があった場合,interfaceの変更が必要になり,それに伴ってモッククラスの変更も多くなります.そのため,仕様があまり固まっていないプロダクトを作る際には向いていない場合があります.私自身あまりモックライブラリは好きではなかったのですが,適切な使い方をする場合は有用なものだと感じるようになりました.特にjestの場合は,1つのライブラリで済むので不用意に使用するライブラリを増やさず,jest自体と設計思想がキレイに地続きとなっているので非常に使いやすいです.
エントリポイントに対するGETのテスト
次はsupertestを使ったexpressのアプリケーションのテストです.
import * as express from "express";
import * as supertest from "supertest";
describe("expressのcontroller層のテスト その1", () => {
it("getの場合", async () => {
const app = express();
app.get(
"/",
async (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
res.write("hello");
res.end();
}
);
const request = supertest(app);
const response = await request.get("/");
expect(response.status).toBe(200);
expect(response.text).toEqual("hello");
});
});
ポイントとしては3点
- itの関数はasyncで定義
- expressのappをsupertestでくるむ
- await request.get("/")で/へのGETを再現
これによってエントリポイントの/へのGETのテストが可能になります.
エントリポイントに対するPOSTのテスト
import * as express from "express";
import * as supertest from "supertest";
describe("expressのcontroller層のテスト その2", () => {
it("postの場合", async () => {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.post(
"/",
async (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
res.write(req.body.value);
res.end();
}
);
const request = supertest(app);
const response = await request.post("/").send({ value: "text" });
expect(response.status).toBe(200);
expect(response.text).toEqual("text");
});
});
こちらはポイントが4点
- itに渡す関数はasync
- expressのappをsupertestでくるむ
- await request.post("/")で/へのPOSTを再現
- sendでリクエストボディを設定
という感じです.ほぼほぼGETと同じですが,POSTはボディがあるので,そのためにsendがあるイメージです.
感想
TypeScriptでプログラムは何個か組んでいますが,そのたびに環境周りは悩むので一旦きれいに書いてみました.TypeScriptは新しい言語で環境が整っている雰囲気があるのですが,細かいハマりどころが多いイメージです.やはりもともとのjsの環境がカオスなので,そのあたりの前方互換性を取りつつの言語仕様にひきづられて辛いものが若干あったりします.今回で言えばimport * from 'supertest'
のあたりとか.
あとは本当に設定周りが多いので,結構しんどいです.TypeScriptだと,コードをコピペしても,設定値が違うとトランスパイル周りがこけたり,ts-jestも設定を間違えると動かなかったりします.それをいろんな記事からコピペして動かしてはみるのですが,なかなかハマったりします.このあたり完全な動くレポジトリを公開していただいて,記事を書くほうが色々とハッピーな気がするので,私はこういうスタイルで今回公開してみました.色んな人の役に立てれば幸いです.