AngularJSコンポーネントのユニットテスト

よく忘れるので整理。
以下のようなFooComponentをテストする場合。

/**
 * 初期化されたらステータスをactiveにし、タイトルを描画する
 * タイトル下部に外部から渡されたテキストを描画する
 */ 
class FooController {

  private active: boolean = false;

  /*@ngInject*/
  constructor(private $element) {
  }

  $onInit() {
    this.active = true;
    this.render();
  }

  render() {
    const elm = this.$element.find('h1');
    elm.text('foo');
  }

  doSomething() {
    return 'Do something';
  }
}

const FooComponent: ng.IComponentOptions = {
  template: '<h1></h1><div>{{ $ctrl.bar }}</div>',
  controller: FooController,
  bindings: {
    bar: '<'
  }
};

export default FooComponent;

$componentControllerを利用

describe('FooComponent', () => {

  let controller;

  beforeEach(angular.mock.module('app'));
  beforeEach(inject(($componentController, $rootScope) => {
    const locals = {
      $scope: $rootScope.$new(),

      /**
       * Componentが$elementを利用している場合、localsに追加する必要がある
       * 
       * https://docs.angularjs.org/api/ngMock/service/$componentController
       *   > If you are using $element or $attrs in the controller,
       *   > make sure to provide them as locals.
       */
      $element: angular.element('<div></div>')
    };
    const bindings = {
      bar: 'bar'
    };
    controller = $componentController('foo', locals, bindings);
  }));

  it('should be active', () => {
    expect(controller.active).toBeFalsy();

    // $onInitは直接実行
    controller.$onInit();

    expect(controller.active).toBeTruthy();
  });

  it('doSomethingを実行', () => {
    expect(controller.doSomething).toBeDefined();
    expect(controller.doSomething()).toBe('Do something');
  });
});

コントローラーのテストのみ可能。
https://docs.angularjs.org/guide/component

$compileを利用

describe('FooComponent', () => {

  let element;
  let parentScope;

  beforeEach(angular.mock.module('app'));
  beforeEach(inject(($compile, $rootScope) => {
    parentScope = $rootScope.$new();

    // element = angular.element('<foo bar="bar"></foo>');
    // element = $compile(element)(parentScope);

    // こっちでも問題ない?
    element = $compile('<foo bar="bar"></foo>')(parentScope);

    parentScope.bar = 'bar';
    parentScope.$digest();
  }));

  it('bindingsされたbarがhtmlに反映される', () => {
    expect(element.find('div').text()).toBe('bar');
  });

  it('$onInit -> render -> タイトルが変更される', () => {

    // 実際のComponent lifecycleと同じで、$onInitは自動実行される
    expect(element.find('h1').text()).toBe('foo');
  });

  it('doSomethingを実行', () => {

    // controllerを取得
    const controller = element.controller('foo');

    expect(controller.doSomething).toBeDefined();
    expect(controller.doSomething()).toBe('Do something');
  });
});

実際の挙動に近い形でコンポーネントのテストが可能。
https://docs.angularjs.org/guide/unit-testing

参考

bundlerとCocoaPodsすら知らない

  • bundler
    • gemを管理するツール
    • 自身もgem
    • Gemfileを使う
    • Gemfileはpackage.jsonのようなもの
  • CocoaPods
    • iOSのパッケージを管理するツール
    • これもgem
    • Podfileを使う
    • Podfileはpackage.jsonのようなもの

このようなプロジェクトファイルが既にある場合、

├── Gemfile
├── Gemfile.lock
├── Podfile
├── Podfile.lock
├── README.md
├── foo
├── foo.xcodeproj
└── foo.xcworkspace

以下のように始める。

# bundlerをインストール
gem install bundler

# bundlerを使い、gemをインストール
bundle install --path vendor/bundle

# CocoaPodsを使い、iOSのパッケージをインストール
bundle exec pod install

HTML Element の幅と高さ、座標。

幅と高さ

Determining the dimensions of elements | MDN

補足

https://www.w3.org/TR/CSS22/box.html#box-dimensions f:id:ryotah:20170124143422p:plain

座標

補足

// https://github.com/oneuijs/You-Dont-Need-jQuery

function getOffset (el) {
  const box = el.getBoundingClientRect();
  
  return {
    top: box.top + window.pageYOffset - document.documentElement.clientTop,
    left: box.left + window.pageXOffset - document.documentElement.clientLeft
  };
}

参考

Promiseの配列を順に処理

reduceを使い新しいpromiseを作成していく。

const tasks = [
  () => $q(resolve => doneAsync(resolve)),
  () => $q(resolve => doneAsync(resolve))
];

function doneAsync(resolve) {
  setTimeout(() => resolve(), 1000);
}

tasks.reduce((promise, task) => {
  return promise.then(task);
}, $q.resolve()) // $q.resolve()で最初のPromiseを生成
.then(() => {
  console.log('done');
});

実行結果を保存するrecord処理を追加。

const tasks = [
  (value) => $q(resolve => doneAsync(resolve, value)),
  (value) => $q(resolve => doneAsync(resolve, value))
];

const record = (results, value) => {
  results.push(value);
  return results;
};

// [] に実行結果を保存する
const _record = record.bind(null, []);

tasks.reduce((promise, task) => { 
  return promise.then(task).then(_record)
}, $q.resolve(0))
.then(value => {
  console.log(value);
  // -> [1, 2]
});

function doneAsync(resolve, value) {
  setTimeout(() => resolve(++value), 1000);
}

背景(領域外)をクリックしたら閉じる、とか

ドロップダウンなどを表示したときに、領域外をクリックしたら閉じたい。 HTMLだと、どのように実装するのが一般的なのかわからなかったのでAngularのUI Bootstrapを参考にしてみたよ。

Dropdown
documentにイベントハンドラを登録。

// https://github.com/angular-ui/bootstrap/blob/v1.1.0/src/dropdown/dropdown.js#L13
$document.on('click', closeDropdown);

// ...

var closeDropdown = function(evt) {
  // ...
  // dropdown内をクリックかつ'outsideClick'が設定されている場合は閉じない
  var dropdownElement = openScope.getDropdownElement();
  if (evt && openScope.getAutoClose() === 'outsideClick' && 
  dropdownElement && dropdownElement[0].contains(evt.target)) {
    return;
  }
  // ...
  openScope.isOpen = false;
  // ...
};

補足: dropdownは複数開くことができないようになっている。uibDropdownServiceが管理している。

Modal
自身(全領域カバーしている)にイベントハンドラを登録。

// https://github.com/angular-ui/bootstrap/blob/v1.1.0/src/modal/modal.js#L165
element.on('click', scope.close);

documentに登録する方法だとこんな感じでしょうか。

class Controller {

  closeHandler: () => void;

  /*@ngInject*/
  constructor(
    private $document: ng.IDocumentService,
    private $element: ng.IAugmentedJQuery,
    private $scope: ng.IScope
    ) {
  }

  $onInit() {
    this.closeHandler = () => {
      this.close();
      this.$scope.$apply();
    };
    this.$element.on('click', ($event) => {

      // this.$document.on を無効にするため
      $event.stopPropagation();
    });
  }

  $onDestroy() {
    this.close();
  }

  open() {

    // 何かの処理
    // ...

    this.$document.on('click', this.closeHandler);
  }

  close() {

    // 何かの処理
    // ...

    this.$document.off('click', this.closeHandler);
  }
}

const components: ng.IComponentOptions = {
  templateUrl: 'foo.html',
  controller: Controller,
  bindings: {}
};

export default components;

Angularを1.5から1.6に移行

1.5.6から1.6.1に移行。参考にしたのは

など。

対応箇所

$onInit

初期化のロジックが変更。(bindingsされた変数がconstructor内では利用できなくなった。)
全てのDirectiveとComponentに$onInitを用意し、初期化時の処理を$onInit内に記述。

(この変更は、書き方が統一されるので嬉しい。)

The reasoning behind this change is that it is not idiomatic JavaScript to bind properties to an Object before its constructor has been called ...

$http

$httpのsuccess()error()がなくなった。then()catch()に置き換え。

hash-prefix

hash-prefixが!に変更。

$q

PromiseのRejectionが未処理の場合、警告が出るようになった。

Possibly unhandled rejection:

$uibModalの利用している場合、backdropクリックでモーダルを閉じると警告がでるので、一応以下のように対応。

this.$uibModal
.open({
  templateUrl: 'demo.html',
})
.result.catch(angular.noop);

メモ

errorOnUnhandledRejectionsを利用する。

(この方法だとエラーが捕捉できない)

javascript - Possibly unhandled rejection in Angular 1.6 - Stack Overflow

app.config($qProvider => {
  $qProvider.errorOnUnhandledRejections(false);
})