2016年11月12日土曜日

Tips for unit testing a Polymer.dart 1.x web app

Polymer.dart 1.xを使ってウェブアプリを書く際、単体テストを作るときの個人的な知見をまとめた。なおこれはテストフレームワークとしてpackage:testpackage:mockitoを使った場合を前提とする。

アプリの初期化はreadyハンドラで行わず別のinit関数を用意する

ユーザーがウェブサイトにアクセスした際、何らかの初期化コードを実行したいときがある。例えばページがロードされたあと背後でサーバーとやりとりするようなコードだ。 色々調べてるとアプリの初期化はroot elementのreadyハンドラでやればいいよ!という印象を受けるが、 ここで初期化すると単体テストで困ることになる。

例えば下記のようなコードがあったとすると、単体テスト時に(本来は呼びたくない)サーバーとの通信が発生してテストがしづらくなってしまう。readyハンドラはelementのlocal DOMが用意できたとき自動で呼ばれてしまうので、単体テストでもそれは回避できないのだ。

@PolymerRegister("main-app")
class MainApp extends PolymerElement {
  void ready() {
    _localizeUI();
    // Set a handler for some events
    window.onBeforeUnload.listen((e) => Logger.root.info("Page was unloaded!"));
    _getSomeData();
  }

  /// Localize the UI
  void _localizeUI() {
    ...
  }

  /// Get some data from the server
  Future _getSomeData() async {
    var info = await HttpRequest.request("myserver.com/api/data", method:"GET");
    ...
  }
}

そこでreadyハンドラではあくまでelementとしての初期化コードのみにして、アプリの初期化コードは別のinit関数でやるようにする。

  void ready() {
    _localizeUI();
  }

  /// Localize the UI
  void _localizeUI() {
    ...
  }

  /// Initialize 
  Future init() async {
    // Set a handler for some events
    window.onBeforeUnload.listen((e) => Logger.root.info("Page was unloaded!"));
    var info = await HttpRequest.request("myserver.com/api/data", method:"GET");
    ...
  }

こうすると単体テスト時にアプリ初期化コードは自動的に呼ばれなくなるのでテストがやりやすくなる。 では実際のアプリではどのようにinit関数を呼ぶかというと、ページのエントリポイントから直接呼ぶことができる。

Future main() async {
  await initPolymer();
  MainApp ma = querySelector('main-app');
  ma.init();
}

これでアプリの初期化コードはウェブ上では必ず呼ばれるし、単体テストでは呼びたい場合とそうでない場合でわけることができる。

サービス相当の機能はラッパークラスを作って外から渡す

先程の例ではinit関数内でwindow変数にハンドラを登録したり外部へHttpRequestを送ったりしていた。ウェブ上ではこのやり方で問題ないのだが、やはり単体テスト時には都合が悪いためラッパークラスを作成してクラスごと外部から渡すように変えた方がいい。 こうしておくと単体テストのときにmockしたクラスを代わりに差し込んで想定した処理が呼ばれているかチェックできるからだ。

実際に他のアプリフレームワーク、例えばAngular JSではこういったwindowとやり取りする処理やHTTP Request処理はすべてサービスというラッパーを経由して実行するようになっている。

ラッパークラスを経由するように変えるとinit関数で言えば下記の形になる。

/// Window wrapper class
class MyWindow {
  void subscribeOnBeforeUnload(void onData(Event e))
  ...
}

/// Class that communicates with the server
class ServerChannel {
  Future<Info> getInfo() async {
    var res = await HttpRequest.request("myserver.com/api/data", method:"GET");
    return new Info(res);
  }
}
...

  /// Initialize 
  Future init(MyWindow window, ServerChannel channel) async {
    // Set a handler for some events
    window.subscribeOnBeforeUnload((e) => Logger.root.info("Page was unloaded!"));
    var info = await channel.getInfo();
    ...
  }

こうしておくと単体テスト時はmockしたクラスをinit関数に渡して適切にAPIが呼ばれたかを確認することができる

class MockMyWindow extends Mock implements MyWindow {
}

class MockServerChannel extends Mock implements ServerChannel {
}

Future main() async {
  await initPolymer();
  test("check init", () async {
    MainApp ma = fixture("init");
    var mmw = new MockMyWindow();
    var msc = new MockServerChannel();
    await ma.init(mmw, msc);
    verify(msc.getInfo()).called(1);
  });
}

まとめ

こうして書くとわりと普通のことのように感じるのだけど、Polymer.dartの場合どうすればいいんだ…?と少し悩んだのでまとめておいた。

0 件のコメント:

コメントを投稿