AngularJS 1.x系のシングルページアプリケーションにユニットテストを導入した話

MMM Corporation
mmmuser

最近Haskellに入信した小飼です。全然わからなくて最高です。

さて、先日弊社公式サイトのアクセスログを見ていた所、angularjs 請負という検索ワードでの流入がありました。
弊社では、この一年ですっかり主流となった観のある仮想DOMライブラリによるアプリケーション構築の他に、AngularJS/Vue.jsなどのMV*フレームワークによるアプリケーション構築も手掛けています。

※その際に得た知見は、このブログでも随時公開しています

AngularJSタグのポスト
Vue.jsタグのポスト

現時点から新規でアプリケーションを構築するなら仮想DOMライブラリに乗っかっておくのが時流かも知れませんが、まだまだ世の中にはAngularJS製アプリケーションも多くあるのではないでしょうか。
その中には「プロジェクト立ち上げ時には主流だったAngularJSが、すっかり流行り廃りの波に飲まれてしまってメンテナンスがしづらくなってしまった...」というような事があるのかも知れません。
(もしそういった方がいればぜひ弊社にご連絡下さい)

今回は、そんなメンテナンスしづらくなっているAngularJSアプリケーションを仮定して、継続的に開発を続けていくためにユニットテストを導入した際の一例をご紹介します。
想定しているのはこんな構成のアプリケーションです。

  • AngularJSを用いたシングルページアプリケーション(SPA)
  • AngularJSのモジュールシステムのみ使用している(!CommonJS)
  • テストコードはない
  • 要件の追加に合わせて、1年以上随時機能拡張をしてきた
  • 重複しているコードや、太りすぎたControllerが随所にある

がんばり :muscle:

想定しているリファクタリング要件

まずどんな機能についてユニットテストを書きたいのか、という前提を書いておきます。
例えばこんな感じで、あるAPIのエンドポイントに対して、Controllerが変わる毎にリクエストを送る機能があるとします。
(省略されている部分に$routeProviderによるルーティングをしている記述があり、各ページ毎にControllerがある、という設定です)

function sampleResourceWrapper($resource) {
  return $resource('/api/endpoint', {
    someParam: '@someParam',
  });
}

function sampleController1(sampleResourceWrapper) {
  const s = new sampleResourceWrapper({ someParam: 'someOwnParam' });
  s.someFixingParam1 = 'someFixingParam1';
  s.someFixingParam2 = 'someFixingParam2';
  s.someFixingParam3 = 'someFixingParam3';
  /* 中略 沢山の固定パラメータを渡している... */

  s.save().$promise
    .then((response)=> console.log(response));
}

function sampleController2(sampleResourceWrapper) {
  const s = new sampleResourceWrapper({ someParam: 'someOwnParam' });
  s.someFixingParam1 = 'someFixingParam1';
  s.someFixingParam2 = 'someFixingParam2';
  s.someFixingParam3 = 'someFixingParam3';
  /* 中略 沢山の固定パラメータを渡している... */

  s.save().$promise
    .then((response)=> console.log(response));
}

angular.module('sampleApp').controller('sampleController1', sampleController1);
angular.module('sampleApp').controller('sampleController2', sampleController2);
angular.module('sampleApp').factory('sampleResourceWrapper', sampleResourceWrapper);

// 以下Routing処理とか...

こんなコード書く人いる...?という感じになっていますが...
長い開発期間の間に、少しずつこういうコードが溜まっていったということにして下さい。

例えば最初はsampleController1だけを実装していた、ある時期に追加のsampleController2が作られた、その時に時間が無くてsampleController1の実装をコピペした...(以下繰り返し)
というようなケースは実際にあるのではないでしょうか。
しかも実開発環境では、この他に何十、何百というファイルがあるので、次に両方のコードにチェックインするまで問題が表面化しづらいように思います。
この調子でsampleController10まで増えた後に、必須パラメータが追加されたとしたらどうなるのでしょうか。

恐ろしいですね。

ちなみに、私はこういうコードを見てしまったらその時に直してしまうようにしていますが、リファクタリングタスクとしてきっちり時間を取って改善していくようにしたほうがいいかも知れません。
(この辺はチームの性格とか構成にもよると思います)

テストで全体の挙動を保護

まず、テストを追加したい機能を含む一番大きな単位に対してテストを追加します。
今回想定しているアプリケーションでは、各ページ毎に設定されているControllerが最大の単位ということになります。

ここはほとんどE2Eのテストを書くようなイメージで、最終的にこのページが責任を持たなければいけない機能に対してテストを書きます。
ただし、太りすぎたControllerが随所にあるという想定ですので、全ての機能を担保するテストコードを書くのは現実的では無いと思います。
そこで、まずはユニットテストとして切り出したい機能のみに注目してテストを書きます。

テストが入っていない場合、各機能が密結合していて単機能だけのテストを書きづらいので、まずはこの機能は何をしなければならないのかこのページは何をしなければならないのかの中に含めてしまう、というイメージです。

この時、チャチャッと直してしまいたい箇所が目につくと思いますが、グッと堪えます。
また、他にも事前の通信処理が必要だったり、サードパーティ製ライブラリ(アナリティクス系ツールとか)がリクエスト投げてたりしていて沢山のモックを作る必要が出てくるかも知れませんが、頑張ります。
(テストを書けば書くほど、新たに必要なモックは少なくなっていくので、その日を夢見て頑張ります)

describe('Controllers: sampleController1', ()=> {
  var scope, element, httpBackend;
  beforeEach(()=> module('sampleApp', 'httpBackendMock', 'controllerTemplate1.html'));
  beforeEach(()=> {
    inject(($rootScope)=> {
      // scopeを作って
      scope = $rootScope.$new();
      scope.$digest();
    });
    inject(($httpBackend)=> {
      // $httpBackendへの参照を取得して
      httpBackend = $httpBackend;
    });
    inject(($compile, $templateCache)=> {
      // 描画するエレメントを生成してbodyに適用して
      const template = $templateCache.get('controllerTemplate1.html');
      element = $compile(template)(scope);
      document.querySelector('body').appendChild(element);
    });
    inject(($controller)=> {
      // controllerをセットする
      $controller('sampleController1', { $element: element });
      scope.$digest();
    });
  });

  it('リクエストを投げている', ()=> {
    httpBackend.expectPOST(//api/endpoint/).respond({});
    httpBackend.flush();
  });

  it('パラメータが渡っている', ()=> {
    httpBackend.expectPOST(//api/endpoint/, {
      someParam: 'someOwnParam',
      someFixingParam1: 'someFixingParam1',
      someFixingParam2: 'someFixingParam2',
      someFixingParam3: 'someFixingParam3',
      // ...
    }).respond({});
    httpBackend.flush();
  });
});

describe('Controllers: sampleController2', ()=> {
  var scope, element, httpBackend;
  beforeEach(()=> module('sampleApp', 'httpBackendMock', 'controllerTemplate2.html'));
  beforeEach(()=> {
    inject(($rootScope)=> {
      scope = $rootScope.$new();
      scope.$digest();
    });
    inject(($httpBackend)=> {
      httpBackend = $httpBackend;
    });
    inject(($compile, $templateCache)=> {
      const template = $templateCache.get('controllerTemplate2.html');
      element = $compile(template)(scope);
      document.querySelector('body').appendChild(element);
    });
    inject(($controller)=> {
      // controllerをセットする
      $controller('sampleController2', { $element: element });
      scope.$digest();
    });
  });

  it('リクエストを投げている', ()=> {
    httpBackend.expectPOST(//api/endpoint/).respond({});
    httpBackend.flush();
  });

  it('パラメータが渡っている', ()=> {
    httpBackend.expectPOST(//api/endpoint/, {
      someParam: 'someOwnParam',
      someFixingParam1: 'someFixingParam1',
      someFixingParam2: 'someFixingParam2',
      someFixingParam3: 'someFixingParam3',
      // ...
    }).respond({});
    httpBackend.flush();
  });
});

AngularJS unit testingなんかで検索すると、whenPOSTで擬似レスポンスを設定する記事の方が多く見つかるかも知れません。
expectPOSTにすることによって、1回1回のリクエストを、どんなパラメータで発行されたかがテストしやすいので、特に事情がなければexpectPOSTをオススメします
(もちろん他のリクエストメソッドについても同様です)

この時、Controllerのテストが太りに太って大変な感じになるかも知れません。
これは一時的なことで、きちんとテストが整備されていくにつれて、Controllerのテストも軽くなっていく(本来の責務に応じたサイズになっていく)ので、耐えます。

ただし共通化できる処理(スコープ・コントローラ・コンポーネントの生成など)は極力共通化して少しでも楽をできるようにした方が良いかも知れません。

describe('Controllers: sampleController1', ()=> {
  var scope, element, httpBackend;
  // `testHelper`というモジュールを作成して、そこにテスト用のヘルパー関数をまとめている
  // AngularJSのモジュールシステムのみを使っている想定なので、当然`module.exports`とかはできない
  beforeEach(()=> module('sampleApp', 'httpBackendMock', 'testHelper', 'controllerTemplate1.html'));
  beforeEach(()=> {
    inject((
      createScope, createElement, setController,
      $httpBackend
    )=> {
      scope = createScope();
      element = createElement();
      setController('sampleController1', element);

      httpBackend = $httpBackend;
    });
  });
  // ...
});

切り分ける

前段で保護した範囲の機能を部品として切り出します。
今回は通信機能のみなので、Factoryとして切り出すのが良さそうですが、Viewに関わる部分でしたらElement Directives(Component)として切り出すことになると思います。

function sampleResourceWrapper($resource) {
  const baseResource = $resource('/api/endpoint', { someParam: '@someParam' });

  return (someOwnParam)-> {
    const s = new baseResource({ someParam: someOwnParam });
    s.someFixingParam1 = 'someFixingParam1';
    s.someFixingParam2 = 'someFixingParam2';
    s.someFixingParam3 = 'someFixingParam3';
    /* 中略 沢山の固定パラメータを渡している... */

    return s.save().$promise;
  };
}

切り出した部品を、元のControllerの該当箇所に差し替えます。

function sampleController1(sampleResourceWrapper) {
  sampleResourceWrapper()
    .then((response)=> console.log(response));
}

function sampleController2(sampleResourceWrapper) {
  sampleResourceWrapper()
    .then((response)=> console.log(response));
}

この時点でテストが失敗していたら、切り出した部品が仕様を満たしていないということなので、直します。
テストが通れば部品化の成功です!

テストを切り分ける

最初にControllerのテストに書いたテストを、Factoryのテストとして切り出します。

describe('Factories: sampleResourceWrapper', ()=> {
  var _sampleResourceWrapper, httpBackend;
  beforeEach(()=> module('sampleApp', 'httpBackendMock'));
  beforeEach(()=> {
    inject(($httpBackend, sampleResourceWrapper)=> {
      httpBackend = $httpBackend;
      _sampleResourceWrapper = sampleResourceWrapper;
    });
  });

  it('パラメータが渡っている', ()=> {
    _sampleResourceWrapper({ someParam: 'someOwnParam' });
    httpBackend.expectPOST(//api/endpoint/, {
      someParam: 'someOwnParam',
      someFixingParam1: 'someFixingParam1',
      someFixingParam2: 'someFixingParam2',
      someFixingParam3: 'someFixingParam3',
      // ...
    }).respond({});
    httpBackend.flush();
  });
});

ただし、この場合リクエストを投げることそのものはControllerの責務なので、Controllerのテストとして残しておきます。

describe('Controllers: sampleController1', ()=> {
  var scope, element, httpBackend;
  beforeEach(()=> module('sampleApp', 'httpBackendMock', 'testHelper', 'controllerTemplate1.html'));
  // ...

  it('リクエストを投げている', ()=> {
    httpBackend.expectPOST(//api/endpoint/).respond({});
    httpBackend.flush();
  });
  // ...
});

これでリファクタリング作業は完了です!

必須パラメータが追加された?
切り出したFactoryにちょっと追記するだけで良いので、楽勝ですね!

まとめ

長期間アプリケーションをメンテナンスしていく上で、重複した記述や非効率的な書き方が出てくるのは仕方がないと思っています。
コードレビューでフィルタする事ができれば理想的ですが、レビューのスコープ外のコードと重複していたりして、中々発見しづらいのが現実です。
また、時間の経過によって非効率的な書き方ということになってしまう書き方というのもあると思います。
(トレンドの変化が激しいJavaScript界隈ではなおさらです)

ですので、書いた時点で完璧な書き方を目指すよりは、良くない書き方が紛れ込んだ時・見つけてしまった時に改善する仕組みを走らせられるかが大事なんじゃないかと思いました。

本当はDOMの操作を含む機能のテストにも触れるつもりだったのですが、ここまでで長くなりすぎて全く触れられませんでした...
DOMにまつわるテストが整備されていくと、作りたいコンポーネントだけをテスト環境で描画して作っていくような、TDDっぽい手法で開発ができて最高なので、いずれ紹介したいと思います。

今回ご紹介したテスト手法を取り入れ、品質の見える化を実現したサービス開発をご検討の企業様は、是非MMMにご相談下さいませ!

参考にした書籍やコンテンツ

AUTHOR
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社はアマゾン ウェブ サービス(AWS)に 専門性や実績を認定された公式パートナーです。
記事URLをコピーしました