🏕️

[Express, NestJS対応] バックエンドのテスト ~基礎知識編~

2022/04/12に公開

昔の自分向けに書きました。テスト何もわからない人がなんとなくわかるようになると嬉しいです。

シリーズ3部作です。
バックエンドのテスト ~基礎知識編~ これ
バックエンドのテスト ~APIテスト編~
バックエンドのテスト ~ユニットテスト編~

サンプルコードはTypeScriptで書かれており、基本的にバックエンド向けに書きました。他言語の方も対応して汎用的に書こうとおもいましたが諦めました。

他言語の方のために。
JSやTSは関数が第一級オブジェクトなので変数に格納することができます。

const kansu = function () {
  return 'Hello';
}

最近はfunctionと書くことは少なく基本的にアロー関数で書きます。機能はほぼ一緒です。

const kansu = () => {
  return 'Hello';
}

途中テストランナーの項目で

describe('aaa', () => {
  describe('bbb', () => {});
})

のようなコードが出てきます。これは初めてJSを触る人だとぎょっとするかもしれません。describeは第1引数に文字列、第2引数に関数を受け取る関数です。

また関数およびクラスはexportをつけることで他のファイルからimportできます。

// hoge.ts
export createHoge = () => {
  return 'hoge!';
}

// 別ファイルで
import { createHoge } from './hoge'
createHoge();

参考資料

NodeJSにおけるテストのベストプラクティス
https://github.com/goldbergyoni/javascript-testing-best-practices

テストピラミッド
https://martinfowler.com/articles/practical-test-pyramid.html

テストトロフィー
https://testingjavascript.com/

テストに関する考え方

テストにはグラデーションがあります。1つのクラスや関数しかテストしないユニットテストから、複数のクラスや関数の組み合わせをテストするインテグレーションテスト、そしてすべてをまとめてテストするE2Eテストまでです。これらをよくテストピラミッドやテストトロフィーを使って、どれくらいのテスト量があると理想だよと教えられます。

ユニットテストとインテグレーションテストの違い

複数のクラスや関数を1つのテストでチェックできるなら、ユニットテストをせずに、インテグレーションテストだけ(あるいはE2Eテストだけ)でいいじゃないかと思うかもしれません。ユニットテストの有効性について説明します。

クラスAがクラスBとクラスCを利用しているとします。

テストのパターンとしては

  1. クラスCoreをユニットテストする。
  2. クラスSub1をユニットテストする。
  3. クラスSub2をユニットテストする。
  4. クラスCoreをインテグレーションテストする。
  5. 上記を組み合わせる。
    などいろいろ考えられると思います。

少し話はそれますが、理想のテストは次の順番でランキングされると思います。

  1. クラスCore、クラスSub1、クラスSub2をそれぞれユニットテストし、クラスAをインテグレーションテストする。
  2. クラスSub1とクラスSub2をユニットテストし、クラスCoreをインテグレーションテストする。
  3. クラスCore、クラスSub1、クラスSub2をそれぞれユニットテストする。
  4. クラスCoreのみをインテグレーションテストする。
  5. テストがない。

3と4の順位の順番は判断の分かれるところですね。

1番目は理想ですね。
2番目は1番目がかなり大変なので現実的に採用できる案でしょうか。
3番目と4番目はチームに余力がなかったり、ある程度バグが許容できるサービスであれば採用しているところも多いです。システムの構成的にユニットテストしにくい(DI未導入)ものもあるので、APIテストのみを採用しているなどもあります。
5番目はお祈りがさかんに行われている信心深い現場です。

ではなぜこの順位になるのか説明します。

クラスSub1の処理の範囲とクラスSub2の処理の範囲があり、これら2つの組み合わせで実現できる処理の範囲があるとします。
クラスSub1とクラスSub2の処理の範囲

クラスCoreでは組み合わせうちの一部だけ実現します。
クラスCoreの処理の範囲

ユニットテストはこれらのクラス本体の処理の範囲をカバーし、インテグレーションテストはそれらの組み合わせの処理の範囲をカバーしてくれます。だから、ユニットテストとインテグレーションテストは組み合わせることでお互いの弱点を補うことができます。

ユニットテストのメリット

  • テストが正しければ、そのクラスの処理の範囲が間違っていないことを保証できる。
  • テストが早く実行できる。

ユニットテストのデメリット

  • そのクラスが組み合わされた結果の処理の範囲までは正しいのかどうかは保証できない。
  • そのクラスの依存(利用している)クラスが多ければ、モックを多様することになる(モック沼)。なかなか大変。
  • ブラックテストよりもホワイトテストよりで、コードとの結合が強くなる。結果的に、コードを変更するとテストが壊れやすくなる。
  • そもそもシステムがユニットテストできるように作られていない可能性がある。

インテグレーションテストのメリット

  • すべてをユニットテストするよりも楽なことが多い。
  • ホワイトテストよりもブラックテストよりになるので、コードとテストの結合が弱く、コードを変えてもすぐには壊れない。
  • 組み合わせた処理の結果の範囲が間違っていないことを保証できる。

インテグレーションテストのデメリット

  • インテグレーションテストが失敗したときに、失敗の原因の候補が多く特定しにくい。
    • クラスSub1の処理が間違っていた?
    • クラスSub2の処理が間違っていた?
    • クラスCore独自の処理が間違っていた?クラスSub1とSub2の組み合わせ方が間違っていた?
  • DBなどがからんでくるとテストが遅くなる傾向にある。
  • ネットワークがからんでくると、テストがフレーキー(壊れやすい)になる傾向にある。

コーダーとテスター(QA)の役割

コードを書くと同時にテストを書きましょう。コーダーがテストを書くので、テスターはその分自動テストを書かなくてよくなる(かも)です。ではテスターの役割は何でしょうか?それは自身の経験に照らし合わせて、バグがありそうなところを手動もしくは自動でテストでつついていくことです。これを探索型テストと呼びます。

詳しくはこちらの本を読んでね。
https://www.amazon.co.jp/知識ゼロから学ぶソフトウェアテスト-【改訂版】-高橋-寿一/dp/4798130605/ref=asc_df_4798130605/?tag=jpgo-22&linkCode=df0&hvadid=295686767484&hvpos=&hvnetw=g&hvrand=12165479552722164616&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=1009756&hvtargid=pla-526294208361&psc=1&th=1&psc=1

コーダー

  • 回帰テスト、自動テストを書く。
    テスター
  • 探索型テストを実施する。
  • 負荷テストを実施する。
  • コーダーのテストの漏れをカバー

テストツールの理解

テストツールの種類を整理します。テストは主にこの5つで構成されます。カッコの中はNodeJSにおけるツール例です。つまりjestは最強です。

  • テストランナー(mocha, jest)
  • アサーションツール(chai, jest)
  • カバレッジツール(istanbul, jest)
  • テストダブルツール(sinon, jest)
  • ダミーデータツール

テストランナー

テストランナーはテストの構造を提供してくれます。主に何をテストしているのかという詳細文章と実際のテストを書きます。ツールによってはtestで始まったりitで始まったりします。

test('テストの詳細文章', () => {
  // 実際のテストの処理
  // テストが成功すれば何事もなく、
  // テストが失敗するときは例外を投げるべし。
})

it('テストの詳細文章', () => {
  // 実際のテストの処理
  // テストが成功すれば何事もなく、
  // テストが失敗するときは例外を投げるべし。
})

テストは基本的にアサーション(assert)を利用します。言語によってはコアモジュールから提供されています。NodeJSにもあります。assertは引数にtrueを受け取れば何もせず、falseを受け取れば例外を投げます。

import assert from 'assert';

test('足し算が正しい', () => {
  const answer = 1 + 1;
  const expected = 2;
  assert(answer === expected); // 何も起きない。
})

test('足し算が正しくない', () => {
  const answer = 1 + 1;
  const expected = 3;
  assert(answer === expected); // 例外を投げる。
})

テストランナーは各テストで例外をキャッチすると、そのテストを失敗したものとして、知らせてくれます。

標準出力
PASS 足し算が正しい
FAIL 足し算が正しくない

またテストランナーはBDDスタイル出かけるものを採用すると書きやすいと思います。

実際のテスト複数条件下での処理が正しいかどうかを確認することが多いです。

以下のテストをBDDスタイルのテストランナーで書いてみたいと思います。
条件Aのとき、かつ条件Bのとき、ある処理Hoge1が正しい。
条件Aのとき、かつ条件Bのとき、ある処理Hoge2が正しい。
条件Aのとき、かつ条件Cのとき、ある処理Fugaが正しい。

describe(('条件Aのとき') => {
  describe('かつ条件Bのとき', () => {
    test('ある処理Hoge1が正しい', () => {
      // 処理Hoge1
    })
    
    test('ある処理Hoge2が正しい', () => {
      // 処理Hoge2
    })
  })
  
  describe('かつ条件Cのとき', () => {
    test('ある処理Fugaが正しい', () => {
      // 処理Fuga
    })
  })
})

うまく条件ごとにネストしているので、パッと見てテストの文脈がわかります。テストランナーツールによっては結果もわかりやすく表示してくれます。

標準出力
PASS 条件Aのとき > かつ条件Bのとき > ある処理Hogeが1正しい
PASS 条件Aのとき > かつ条件Bのとき > ある処理Hogeが2正しい
FAIL 条件Aのとき > かつ条件Cのとき > ある処理Fugaが正しい

また最近のテストランナーだと、共通の前処理と共通の後処理がかける様になっています。うまく活用しましょう。

describe(('条件Aのとき') => {
  // jestはbeforeAll
  // mochaはbefore
  beforeAll(() => {
    // 共通の前処理
    // DBやアプリのセットアップなど
    // モデルの下準備など
    // ダミーデータの生成など
    // テストダブルの準備など
  })
  
  // jestはafterAll
  // mochaはafter
  afterAll(() => {
    // 共通の後処理
    // DBやアプリのクローズなど
    // テストダブルのリストアなど
  });
  
  describe('かつ条件Bのとき', () => {
    test('ある処理Hogeが正しい', () => {
      // 処理Hoge
    })
  })
  
  describe('かつ条件Cのとき', () => {
    test('ある処理Fugaが正しい', () => {
      // 処理Fuga
    })
  })
})

また最近のテストランナーだと、ループ処理もかける様になっています。うまく活用しましょう。

// jestで利用できるループ
// mochaは非対応
describe.each([
    {
      permission: 'ADMIN',
      expectStatus: true,
    },
    {
      permission: 'USER',
      expectStatus: false,
    },
  ])('権限で利用チェック', ({permission, expectStatus}) => {
    test(`${accountPermission}のとき使えるかどうか`, () => {
      console.log(permission);
      console.log(expectStatus);
    })
  });

アサーションツール

前の章でテストは基本的にアサーション(assert)を利用すると言いました。しかし以下のassertよりも

cosnt a = 'hoge';
assert(a === 'hoge');

こちらのexpectの書き方のほうがより読みやすいコードになります。

cosnt a = 'hoge';
// jestのexpect
// chaiだとexpect().to.equal();
expect(a).toEqual('hoge');

このように、アサーションをより使いやすくしたツールが存在します。例えばNodeJSのテストツールjestにはアサーションとして次のメソッドが提供されています。

expect(null).toBeNull()
expect(false).toBeFalsy();
expect('abc').toHaveLength(3);
expect('grapefruits').toMatch(/fruit/);
//...他にもたくさん。

https://jestjs.io/docs/expect

自分が使用している言語とテストランナーで利用できるアサーションツールについて一度調べてみるといいですね。

カバレッジツール

テストカバレッジと呼ばれるものがあります。コード全体においてどれくらいテストを実行したのかカバー率のことをそう呼びます。テストランナーツールと統合して使うことが多いです。

例えばNodeJSで有名なカバレッジツールにはistanbulと呼ばれるものがあります。これはテストランナーツールであるmochaと一緒に使うことができます。

jestとよばれるテストツールはistanbulを内包しており--coverageというオプションをつけて実行するだけでカバレッジレポートを出力してくれます。

カバレッジを測定する目的

カバレッジを測定する目的としては、いま自分たちの書いたコードがどれだけテストをされているのかひと目でフィードバックが得られることです。フィードバックがあることで

  • テストに対するモチベーションがあがる。
  • チームがテストを具体的な数字でみることができる(テストの量すくないからテスト書かなきゃではなくて、このモジュールのテストカバレッジ40%だからテストちゃんと書かなくちゃね、みたいな話ができる)。
  • コードのどの部分が不安でどの部分が自身があるのか数字でわかる。

ことができます。例えが変ですが、ゲームやってて、なんの効果音もなくリザルト画面もなければやる気わかないですよね。フィードバックはとても大事です。

カバレッジの種類

カバレッジには種類がありよくc0/c1/c2と呼ばれます。
https://qiita.com/bremen/items/8b6542467d2a0066e5af

こういったらテストに詳しい人に怒られるかもしれませんが、とりあえずは

  • コードの実行可能なすべての部分がどれくらいテストされているのか
  • ifが複合条件であれば各々の条件を網羅したテストができたか

を気にかけてください。

「コードの実行可能なすべての部分がどれくらいテストされているのか」を気にかけると、if文の{}の中をテストできているか気になりますね。

function shori (a: string){
  // 処理。。。
  if(a.startWith('h')){
    // 実行されるかな?
  }
}

「ifが複合条件であれば各々の条件を網羅したテストができたか」を気にかけると、if文の()の中を網羅したテストができているか気になりますね。

function shori (a: string){
  if(a.startWith('h') && a.length === 4){ // この2つの複合条件を網羅できてるかな?
    // 実行される部分
  }
}

カバレッジレポートのビジュアライズ

カバレッジレポート自体はツールがあればローカルでもCIサーバー上でも見ることができます。出力したレポートを蓄積していけば、時間軸でどれくらいカバレッジが変化したのかみることができますよね。さらにそれをグラフ化するととてもわかりやすい。

そんなサービスは存在していて有名どころだとCodecovCoverallがあります。

テストダブルツール

テストダブルとはモック、スタブ、スパイなどの総称です。

https://martinfowler.com/bliki/TestDouble.html

ここは結構ややこしいので力入れて説明します。

Spy(スパイ)

Spyは単純に記録係です。Spyでラップしたメソッドの

  • 引数に何がインプットされたのか
  • 返り値何を返したのか
  • 何回呼ばれたのか
    をチェックします。

Spyの例

sinonjsを利用
const object = {
  get test() {
    return this.property;
  },
  set test(value) {
    this.property = value * 2;
  },
};
// objectにおけるtestの振る舞いを記録する
var spy = sinon.spy(object, "test", ["get", "set"]);
// 42がsetされたぞ!!
object.test = 42;
// スパイによるとセットが一度呼ばれた -> true
assert(spy.set.calledOnce);
// getがよばれたぞ!!
assert.equals(object.test, 84);
// スパイによるとゲットが一度呼ばれた -> true
assert(spy.get.calledOnce);
jestを利用
const audio = {
  _volume: false,
  // it's a setter!
  set volume(value) {
    this._volume = value;
  },
  get volume() {
    return this._volume;
  },
};

test('plays audio', () => {
  const spy = jest.spyOn(audio, 'volume', 'set'); // we pass 'set'
  audio.volume = 100;

  expect(spy).toHaveBeenCalled();
  expect(audio.volume).toBe(100);

  spy.mockRestore();
});

Stub(スタブ)

StubはSpyよりも優秀で、記録係の仕事の他に、ラップしたメソッドを実際に実行させず、返り値を偽装することができます。Spyの機能も使えます。

Stub例

sinonjs
describe("stub", () => {
    it("新しい値に置き換わってるはず", () => {
        const myObj = {
            example: "oldValue",
        };

        // スタブでラップするし、"newValue"に偽装する
        sinon.stub(myObj, "example").value("newValue");

        // exampleの値が"newValue"
        assert.equals(myObj.example, "newValue");
    });
});
jest
describe("stub", () => {
    it("新しい値に置き換わってるはず", () => {
        const myObj = {
            example: "oldValue",
        };

        // スタブでラップするし、"newValue"に偽装する
        jest.spyOn(myObj, "example").mockReturnValue("newValue");

        // exampleの値が"newValue"
        assert.equals(myObj.example, "newValue");
    });
});

jestはspyとstubとmockがややこしい事になっています。

  • 明確なstubという名前のモジュールはない。
  • jest.spyOnjest.mockでspy機能とstub機能を利用できる。
  • jest.spyOnはラップしたメソッドが何を返すのか指定しなければ、そのままメソッドを使う(Spyの機能)。
  • jest.mockラップしたメソッドが何を返すのか指定しなければ、何も起こさない。

https://qiita.com/s_karuta/items/ee211251d944e72b2517#jestmockをつかう-3

Mock(モック)

Mockは完全に新しい成り代わりを作って入れ替えます。Stubとの違いは、Stubは実際のオブジェクトやクラスのメソッドの一部だけの偽装ですが、Mockは完全に偽装したものを作る必要があります。

Mockはツールに頼らずとも自分で作成できます。

Mock例
interface Logger {
  info: (arg: string) => void;
} 

class MyLogger implement Logger {
  info: (arg) => {
    console.log(arg);
  }
}

class MockLogger implement Logger {
  info: (arg) => {
    // 何もしない    
  }
}

// 本番
app.useLogger(new Logger());

// テスト
app.useLogger(new MockLogger());

ツールを使ってMockを作成すると、SpyやStubの機能が利用できます。Mockを利用する前にStubが利用できないか考えましょう。

Repositoryパターンを利用すると通常のDB用のRepositoryの他にテスト用のInMemoryRepositoryを作るかもしれません。これもMockの一種ですね。

静的型付けのオブジェクト指向言語でインターフェースを指定している部分はもれなくMock化が可能です。インターフェースをうまく活用すれば、ユニットテストが可能になりやすいのでどんどん活用していきましょう。

jestはjest.fn()で簡単にモックを作成できます。

ダミーデータツール

ダミーデータも重要です。ダミーの主な種類をあげてみました。

  • string
  • number
  • boolean
  • メールアドレス
  • ダミーテキスト(いわゆるlorem)
  • 住所
  • 姓名
  • 日付
  • アバター画像
  • 上記を組み合わせたオブジェクト
  • ステータスなどのenum

ダミーデータは基本ランダム値であることを期待されます。メールアドレスをダミーで生成してDBのユニーク制約にひっかかるとか嫌ですよね。

実際にダミーデータを自分で作ってみましょう。

dummyData.ts
// ランダムな数字。最小値と最大値を指定できる。
export const number = (args?: { min?: number; max?: number }): number => {
  const min = args?.min ?? 0;
  const max = args?.max ?? 2147483647; // 4バイト integer 最大

  return Math.floor(Math.random() * (max - min) + min); // The maximum is exclusive and the minimum is inclusive
};

// ランダムな姓。ユニーク性を上げるために姓のあとにランダムな数字をつけて重ならないようにしている。例: 佐藤113
export const sirName = (): string => {
  const candidates = [
    '佐藤',
    '鈴木',
    '高橋',
    '田中',
    '渡辺',
    '伊藤',
    '山本',
    '中村',
    '小林',
    '加藤',
    '吉田',
    '山田',
    '佐々木',
    '山口',
    '斎藤',
    '松本',
    '井上',
    '木村',
    '林',
    '清水',
  ];

  return `${candidates[number({ min: 0, max: candidates.length - 1 })]}${number(
    { max: 1000 },
  )}`;
};

// ランダムな名。ユニーク性を上げるために名のあとにランダムな数字をつけて重ならないようにしている。例: 大翔313
export const givenName = (): string => {
  const candidates = [
    '大翔',
    '蓮',
    '颯太',
    '樹',
    '大和',
    '陽翔',
    '陸斗',
    '太一',
    '海翔',
    '蒼空',
    '翼',
    '陽菜',
    '結愛',
    '結衣',
    '杏',
    '莉子',
    '美羽',
    '結菜',
    '心愛',
    '愛菜',
    '美咲',
  ];

  return `${candidates[number({ min: 0, max: candidates.length - 1 })]}${number(
    { max: 1000 },
  )}`;
};

// フルネーム
export const fullName = (): string => {
  return `${sirName()}${givenName()}`;
};

// ダミーテキスト
export const text = (args: { min?: number; max?: number }): string => {
  const candidate =
    '彼らは今近頃こうした矛盾院という方の限りを命じだで。けっして結果に随行めはひょろひょろその意味ないたかもが考えが来ないがは煩悶できるたらあるから、たったにはするますですだない。方々に立った方はたとい先刻がずっとなですで。何とも岩崎君が注意箸多少注文に承で自己その理由何か理解からというご紹介たたいたですから、その今日は私か価値力に流れるて、岡田君のものが陰のあなたを同時にごお話しとあるばそれ先生におお話をさようにしきりに皆意味へ悟っでずから、ついにひょろひょろ講演にしないてみるうのに知れたいなら。';

  return candidate.slice(args?.min ?? 0, args?.max);
};

// true or false
export const boolean = (): boolean => {
  const candidate = [true, false];

  return candidate[number({ min: 0, max: 1 })];
};

// メールアドレス
export const email = (): string => {
  return `${number()}@example.com`;
};

// string
export const string = (args?: { min?: number; max?: number }): string => {
  const chars =
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

  const len = number({
    min: args?.min ?? 0,
    max: args?.max ?? 1000,
  });

  return [...Array(len)]
    .map(() => chars.charAt(Math.floor(Math.random() * chars.length)))
    .join('');
};

// ランダム日付。引数が指定されていなければ未来の日付を取得
export const date = (args?: { min?: Date; max?: Date }): Date => {
  const startTime = (args?.min ?? new Date()).getTime();
  const endTime = args?.max?.getTime() ?? 2147483647; // 4バイト integer 最大

  return new Date(startTime + Math.random() * (endTime - startTime));
};

// ランダム日付。引数が指定されていなければ過去の日付を取得
export const pastDate = (args?: { min?: Date; max?: Date }): Date => {
  const startTime = args?.min?.getTime() ?? 0;
  const endTime = (args?.max ?? new Date()).getTime();

  return new Date(startTime + Math.random() * (endTime - startTime));
};

これを利用すれば、自分でモデルのダミーデータも生成できます。

userEntity.dummy.ts
const userStatusList = ['ACTIVE', 'PENDING'];

export const buildUserStatus = (): UserStatus => {
  return userStatusList[
    dummyData.number({ min: 0, max: userStatusList.length - 1 })
  ];
};

export const buildUser = (
  options?: Partial<UserEntityConstruct>,
): UserEntity => {
  return new UserEntity({
    id: options?.id ?? dummyData.number(),
    sirName: options?.sirName ?? dummyData.sirName(),
    givenName: options?.givenName ?? dummyData.givenName(),
    email: options?.email ?? dummyData.email(),
    status: options?.status ?? buildUserStatus(),

  });
};

ダミーモデルを生成できるとテストのデータ準備に威力を発揮します。

テストの流れ

テストは基本的にAAAで行うと良いでしょう。

  1. Arrage(準備)
  2. Act(実行)
  3. Assert(アサーション)

APIテストでは。

    describe('アドミンのとき', () => {
      let user:UserEntity;
      
      // Arrange
      beforeAll(async () => {
        const user = await createUser({
	  id: 1,
	});
      });
      
      describe('/users/1 #DELETE', () => {
        test('happy path', (done) => {
	  // Act
          request(app.getHttpServer())
            .delete(`/users/{user.id}`)
	    // Assert
            .expect(200, done); // 200ステータスコードが返ってくる。
        });
      });
    });

モデルのユニットテストでは。

  describe('#introduceSelfWithDecorate', () => {
    test('名前がデコって表示される', () => {
     // Arrange
      const user = buildDummyUser({
        name: 'Takeshi'
      });
      
      // Act & Assert
      expect(user.introduceSelf()).toEqual("#I'm Takeshi#");
    });
  });

テストってなにやってるのかわからなくなったら、AAAを思い出してください。

次へ

バックエンドのテスト ~APIテスト編~

Discussion