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の場合どうすればいいんだ…?と少し悩んだのでまとめておいた。

2016年3月1日火曜日

Using webfont.js from Dart (via package:js)

webfont.jsをDartから呼ぶ必要があって、今まではdart:jsを使っていたのだけどdart:jsが非推奨になってしまったので新しいpackage:jsへ書き換えることにした。ドキュメントがあまり整備されてなくて少し手間取ったので参考として残しておく。なお使用したバージョンは下記の通り。

  • Dart : 1.14.2
  • package:js : 0.6.0
  • webfont.js : 1.6.16

JavaScript版

var param = {
  google: {
    families: ['Droid Sans', 'Droid Serif']
  }
};
WebFont.load(param);

Dart(package:js)版

@JS("WebFont")
library web_font;

import "package:js/js.dart";

@JS()
external load(Config config);

@JS()
@anonymous
class GoogleGroup {
  external List<String> get families;
  external factory GoogleGroup({List<String> families});
}

@JS()
@anonymous
class Config {
  external GoogleGroup get google;
  external Function get active;
  external void set active(Function f);
  external factory Config({GoogleGroup google});
}

/// Initialize web font 
void init(List<String> fonts, {Function onActive: null}) {
  var gg = new GoogleGroup(families:fonts);
  var c = new Config(google: gg);
  c.active = allowInterop(onActive);
  load(c);
}

使い方はたとえばこんな感じ。

import 'package:web_font/web_font.dart' as WebFont;
...
var fontNames = ["Krona One", "Atomic Age"];
var f = () => Logger.root.info("Loaded font!");
WebFont.init(fontNames, onActive: f);
...

基本的にはJavaScript側へ渡すオブジェクトに対してクラス定義して修飾子つけてアノテーションつけて…という風に機械的にやってくことになる。自動的にラッパーを生成することもできるだろうし、実際Polymer.dartではPolymer.jsからcustom_element_apigenというツールを使ってAPIを自動的に生成している。

感想としては、こうして並べてみるとDartからJavaScript使うのエラい面倒だなという印象を受ける。自動的にAPIを生成してくれるものがあれば(SWIGみたいな?)それを使うのが楽かもしれない。

2016/3/13追記

Dart SDK 1.15リリースではさっきのコードで次のエラーが表示されるようになった。

Uncaught Unhandled exception:
Unhandled exception:
Unhandled exception:
Invalid argument (Expandos are not allowed on strings, numbers, booleans or null): null
#0      Expando._checkType (dart:core-patch/expando_patch.dart:134)
#1      Expando.[] (dart:core-patch/expando_patch.dart:14)
#2      allowInterop (dart:js:1532)
#3      init (package:xclamm/web_font/web_font.dart:29:14)
...

これを直すにはコードを下記のようにする必要がある

...
/// Initialize web font 
void init(List<String> fonts, {Function onActive: null}) {
  var gg = new GoogleGroup(families:fonts);
  var c = new Config(google: gg);
  if (onActive != null) {
    c.active = allowInterop(onActive);
  }
  load(c);
}