パンダのメモ帳

技術系のネタをゆるゆると

AngularJS で Hello World

AngularJSGoogle が中心になって開発が進められている JavaScript MVC フレームワーク の一種です。もちろんオープンソース(MIT License)。 今回はこの AngularJS を使って Hello World するわけですが、 ただ世界にこんにちはするだけじゃおもしろくないので、次のようなカンジでやってみようと思います。

  • TypeScript で書いてみます。
  • ビルドツールに Grunt を使います。
  • Testacular + Jasmine を使って自動テスト(ユニットテスト、受入テスト)環境を構築します。
  • AngularJS のバージョンは2013年1月現在の最新安定版である 1.0.4 を使用します。

なお、今回のソースコード一式を GitHub で公開しています。 また、こちらで実際に動作を確認することもできます。

View を書く

まずは View となる index.html から。

app/index.html

<!DOCTYPE html>
<html ng-app>
  <head>
    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js"></script>
    <script src="js/main.js"></script>
    <link rel="stylesheet" href="css/main.css">
    <title>Hello World</title>
  </head>
  <body>
    <h1>AngularJS Example: Hello World</h1>
    <div ng-controller="HelloWorld.Controller">
      Input your name →
      <input type="text" ng-model="name" size="20">
      <hr>
      <p>{{greeting}} {{name}}!</p>
      <hr>
      <p><button ng-click="bye()">Bye!</button></p>
    </div>
  </body>
</html>

解説

<html ng-app>

まず html要素に ng-app属性 を指定し、この HTML が AngularJS を使用したアプリケーションであることを宣言します。

ちなみに AngularJS で使用する属性はこの ng-app属性のように ng- というプレフィクスがつきます。 他にも ng:appx-ng-app, data-ng-app などの書き方ができるみたいですが、 今回は公式のチュートリアルなどでも使用されている ng- 形式を使用します。

<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js"></script>

AngularJS は jQuery などと同様に Google の CDN で配信されているので、それを読み込みます。

<script src="js/main.js"></script>

アプリケーション本体となる JavaScript を読み込みます。あとで書きます。

<div ng-controller="HelloWorld.Controller">

使用する Controller を指定します。 ng-controller属性を指定した要素が Controller に対応する View ってことになるのかな?

<input type="text" ng-model="name" size="20">

input要素に ng-model属性を指定して Model に紐づけます。

<p>{{greeting}} {{name}}!</p>

Model の内容を View で表示するためには二重波括弧で囲った Angular Expression を記述します。 単にそのまま表示するだけならこんなカンジ。

<p><button ng-click="bye()">Bye!</button></p>

button要素に ng-click属性を指定してクリック時の動作を指定します。

Controller を書く

TypeScript で Controller を書きます。

app/js/main.ts

/// <reference path="../../lib/angularjs/angular.d.ts" />
module HelloWorld {
  export interface Scope extends ng.IScope {
    name: string;
    greeting: string;
    bye: () => void;
  }
  export class Controller {
    constructor($scope: Scope) {
      $scope.name = 'World';
      $scope.greeting = 'Hello';
      $scope.bye = () => $scope.greeting = 'Good-bye';
    }
  }
}

解説

/// <reference path="../../lib/angularjs/angular.d.ts" />

TypeScript で記述するにあたり AngularJS の型定義ファイル(*.d.ts)が必要になりますが TypeScript用の各種モジュール定義ファイルを GitHub で公開してくれている人がいる ので、ありがたく使わせていただきます。

今回は lib ディレクトリ以下に必要な分だけコピーして使用しています。

export interface Scope extends ng.IScope {
  name: string;
  greeting: string;
  bye: () => void;
}

AngularJS の Controller は Scope と呼ばれるオブジェクトを受け取り、そこに Model となるプロパティを設定する……っぽい。 TypeScript なので Controller が扱う Scope にどんなプロパティがあるのか定義してあげる必要があります。

export class Controller {
  constructor($scope: Scope) {
    $scope.name = 'World';
    $scope.greeting = 'Hello';
    $scope.bye = () => $scope.greeting = 'Good-bye';
  }
}

Controller 本体。各プロパティの初期値を設定します。 ちなみに JavaScript で書くならこんなカンジかな?

var HelloWorld = {
  Controller: function($scope) {
    $scope.name = 'World';
    $scope.greeting = 'Hello';
    $scope.bye = function() {
      $scope.greeting = 'Good-bye';
    };
  }
};

動かしてみる

app/index.html をブラウザで開き、テキストフィールドを変更すると即座に View が更新されます。 ここでも確認できます。 AngularJS が持つパワーの片鱗が感じられると思います。多分。

ユニットテストを書く

AngularJS は自動テストをかなり考慮して設計されており DI (Dependency Injection) をサポートする機能が組み込まれています。

また Testacular という Google 製の JavaScript テストランナーとも仲良しです。 ……というよりも、AngularJS のために Testacular を作った、というのが正しいみたいです。

Testacular は単なるテストランナーなのでユニットテストフレームワークには Jasmine を使います(Mocha を使ったりすることもできるみたいです)。

というわけで AngularJS + Jasmine + TypeScript でユニットテストを書いてみました。

test/unit/HelloWorldSpec.ts

/// <reference path="../../lib/jasmine/jasmine.d.ts" />
/// <reference path="../../lib/angularjs/angular.d.ts" />
/// <reference path="../../lib/angularjs/angular-mocks.d.ts" />
/// <reference path="../../app/js/main.d.ts" />
describe('HelloWorld', () => {
  var $rootScope: ng.IRootScopeService,
      $controller: ng.IControllerService,
      controller: HelloWorld.Controller,
      scope: HelloWorld.Scope;

  beforeEach(inject( ($injector: ng.auto.IInjectorService) => {
    $rootScope = $injector.get('$rootScope');
    $controller = $injector.get('$controller');
    scope = <HelloWorld.Scope>$rootScope.$new();
    controller = $controller(HelloWorld.Controller, {'$scope': scope});
  }));

  describe('Controller', () => {
    it('initialize scope', () => {
      expect(scope.greeting).toEqual('Hello');
      expect(scope.name).toEqual('World');
      expect(typeof scope.bye).toBe('function');
    });
  });

  describe('Scope', () => {
    it('.greeting should be "Good-bye" when called bye()', () => {
      scope.bye();
      expect(scope.greeting).toEqual('Good-bye');
    });
  });
});

解説

/// <reference path="../../lib/jasmine/jasmine.d.ts" />
/// <reference path="../../lib/angularjs/angular.d.ts" />
/// <reference path="../../lib/angularjs/angular-mocks.d.ts" />
/// <reference path="../../app/js/main.d.ts" />

TypeScript なので依存するモジュールの定義ファイルを指定します。 ここでは Jasmine と AngularJS 本体、さらに angular-mocks.js の定義を読み込んでいます。

beforeEach(inject( ($injector: ng.auto.IInjectorService) => {
  $rootScope = $injector.get('$rootScope');
  $controller = $injector.get('$controller');
  scope = <HelloWorld.Scope>$rootScope.$new();
  controller = $controller(HelloWorld.Controller, {'$scope': scope});
}));

↑の部分で Controller が依存する Scope のモックを注入しています。

ここまで抑えておけばあとは普通にテストを書くだけなので残りは割愛。 ちなみにテスト結果はこんな感じ

Testacular を使ったコマンドラインでのテスト実行方法は後ほど。

受入テストを書く

AngularJS には受入テスト(End-to-End Test)用のフレームワークが組み込まれています。 書き方が Jasmine っぽいけど Jasmine じゃないのが落とし穴なので注意。

受入テストも TypeScript で……と言いたいところですが 使用する angular-scenario.js のモジュール定義ファイルが見当たらなかったので今回は諦めて JavaScript で書きました。

test/e2e/scenarios.js

describe('HelloWorld', function() {
  beforeEach(function() {
    browser().navigateTo('../../app/');
  });
  it('should change the binding when user enters text', function() {
    expect(binding('name')).toEqual('World');
    input('name').enter('AngularJS');
    expect(binding('name')).toEqual('AngularJS');
  });
});

test/e2e/runner.html

<!doctype html>
<html lang="en">
  <head>
    <title>End2end Test Runner</title>
    <script src="../../lib/angularjs/angular-scenario.js" ng-autotest></script>
    <script src="scenarios.js"></script>
  </head>
  <body>
  </body>
</html>

解説

beforeEach(function() {
  browser().navigateTo('../../app/');
});

各テストケースの実行前に、対象のページを開くようにします。

it('should change the binding when user enters text', function() {
  expect(binding('name')).toEqual('World');
  input('name').enter('AngularJS');
  expect(binding('name')).toEqual('AngularJS');
});

input要素の入力前後で {{name}} の部分が変更されることを確認します。

その他、End-to-End Test に関するドキュメントはこちらを参照してください。

ちなみに、End-to-End Test を実行するためには実際に http で対象のページにアクセスする必要があります。 今回のサンプルでは Node.js を使ってテスト用の HTTPサーバーを立ち上げることができます。

$ cd /path/to/project
$ npm install            # 初回のみ
$ npm start

> angularjs-example-helloworld@0.0.1 start /path/to/project
> node server.js start

server.js daemon successfully started

サーバーが立ち上がるとhttp://localhost:8000/test/e2e/runner.htmlでテストを実行できます。 ちなみにテスト結果はこんな感じ

Grunt について

今回は TypeScript のコンパイルと、コマンドラインからテストを実行するために Grunt を使用しています。 grunt.js はこんなカンジです。 全部説明すると長くなるので、使用したプラグインだけ紹介します。

  • grunt-contrib-clean
    • 一時ファイルなどを消してくれる clean タスクが使えるようになります。
  • grunt-typescript
    • TypeScript をコンパイルする typescript タスクが使えるようになります。
  • gruntacular
    • Testacular によるテストを実行する testacular タスクが使えるようになります。

npm install でプラグインがインストールされるようになっているので、↓こんなカンジで使ってください。

$ cd /path/to/project
$ npm install            # 初回のみ
$ grunt                  # TypeScript のコンパイル
$ grunt unit-test        # TypeScript のコンパイル → 自動ユニットテスト実行
$ grunt e2e-test         # TypeScript のコンパイル → 自動受け入れテスト実行
$ grunt test             # TypeScript のコンパイル → 全テスト実行

テストのブラウザには PhantomJS を使用しているのでインストールするか test/config 以下にある各 conf.js ファイルの下記の部分を編集してください。

browsers = ['PhantomJS'];

PhantomJS の他には Chrome とか ChromeCanary とか Firefox とか使えるみたいです。 複数指定もできます。

最後に

TypeScript 使おうとか、テスト周りまでちゃんと調べてたらすごく疲れた。 AngularJS はまだ日本語の情報があんまりないイメージなので、みんなどんどん使ってどんどんアウトプットすればいいと思う。

参考URL