【小ネタ】【TypeScript】Interfaceを使って依存関係をテスタブルにする

2019.10.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

CX事業本部エンジニアチームオーガナイザーの阿部です。

先日、プロダクションコードを久しぶりに書きました。しばらく検証用のコードしか書いていなかったので、緊張感がありますね。

検証用コードとは違い、リリースする際のコードの品質は担保する必要がありますし、そのためにも自動テストを書きやすい作りにしておきたいものです。 そういうことを思い出しながら自分なりに工夫してクラスの設計をしていたんですが、TypeScriptに限らず、似たようなクラス仕様を持っている言語だったら(例えばJavaとか)誰かが書いてるだろうなーと思ったら、やってたことが見当たらなかったんですよね。 何かのワナかなーとか思いつつ、かぶり覚悟で書いてみます。

背景

クラス間の依存関係をなるべく切り離して疎結合にして、独立性を高めてテストしやすい設計に持っていきたいという状況です。

例えば、AWS SDKからAPIをコールしながらデータを変換/編集する処理があるときに、フロー制御とAPIコールや個別のロジックを切り離して設計する時にどうやるか、という風にとらえていただけると。

依存関係が強い状況だと、特にAWS SDKを使っている場合など、サービスのモックツールや実際のサービスにアクセスしないと検証できないケースが多くなり、テストを自動化する動機づけとしては弱くなります(コストバランスも含めて)。 デプロイして手動でテストとなると時間もかかりますし、変更に対して臆病になる要素になります。

依存関係にあるコードを「とある前提」で動かすことで確認できることが増えるのであれば、より安心感を持って環境へのデプロイを行ったり、自動化にも取り組みやすくなると思います。

やること

さて、実際に依存関係を切り離す基本パターンです。

  1. Interfaceを作る
  2. Constructorで依存関係を注入する
  3. テストは匿名クラスでモッキングする

Interfaceを作る

依存関係を切り離したい(背景での例でいうとAWS SDKを使ってAPIをコールしているロジックを担当する部分)ロジックを切り離して、クラスにまとめてそのInterefaceを合わせて作ります。

external interface ILogic {
    methodA(param1: string): string;
    methodB(paramA: number): number;
}

external class Logic implements ILogic {
    public methodA(param1: string): string {
        // 〜〜処理
    }

    public methodB(paramA: number): number {
        // 〜〜処理
    }
}

Constructorで依存関係を注入する

このロジックとの依存関係を持っている処理(背景の例でいうとフロー制御を行う部分)については、ConstructorでInterfaceを引数に取ることによって依存関係を抽象化し、なおかつインスタンス生成時に関係が生じるように変更します。

external class FlowControl {
    readonly logic: ILogic;

    constructor(logic: ILogic) {
        this.logic = logic;
    }

    public flow(param1: string, paramA: number): string {
        this.logic.methodA(param1);
        this.logic.methodB(paramA);

        // 〜〜処理
    }
}

テストでは匿名クラスでモッキングする

上記でフロー制御を行うクラスのテストを書く場合、 ILogic を実装した匿名クラスを作成すればモッキングできます。

describe("FlowContol", () => {
    it("フロー制御のテスト", () => {
        val mockedLogic = new class implements ILogic {
            public methodA(param1: string): string {
                // 引数のアサーションとモックするレスポンスを書く
            }

            public methodB(paramA: number): nunber {
                // 引数のアサーションとモックするレスポンスを書く
            }
        };

        const actualClass = new FlowContol(mockedLogic);
        assert(actualClass.flow("テスト", 1) === "テストの結果");
    })
});

なんのことはない

依存性の注入パターン ですね。