AngularJSで各種UIのユニットテストを書く

# はじめに

※Angular 1xです。

通常のロジックのテストは問題なく書けるとして、UIのユニットテストを書くのは難しい。
とはいえ、後回しにしがちだが、AngularJSのモジュールのテストも書きたい。
ControllerやComponent, Service などのテストの書き方を記述していきます。

その他、以下のように若干わかりづらいテストの書き方も記述しています。

- $httpのレスポンスをMockするテストの書き方も記述しています。
- $timeoutを使っているメソッドのテスト
- $qを使っているメソッドのテスト

# 前提

Karma と Jasmine を使ったテストが実行できる環境であること。
今回は以下の GitHub に用意したプロジェクトで行います。
https://github.com/chibi929/angularjs-test-sample

# 実行環境

GitHub の package.json をご参照下さい。
ちなみに Node は `6.9.5` です。

```json:package.json
{
  ...
  "dependencies": {
    "angular": "^1.6.5"
  },
  "devDependencies": {
    "@types/angular": "^1.6.27",
    "@types/angular-mocks": "^1.5.10",
    "@types/jasmine": "^2.5.53",
    "angular-mocks": "^1.6.5",
    "extract-text-webpack-plugin": "^3.0.0",
    "istanbul-instrumenter-loader": "^2.0.0",
    "jasmine": "^2.6.0",
    "karma": "^1.7.0",
    "karma-coverage": "^1.1.1",
    "karma-coverage-istanbul-reporter": "^1.3.0",
    "karma-html-reporter": "^0.2.7",
    "karma-jasmine": "^1.1.0",
    "karma-junit-reporter": "^1.2.0",
    "karma-phantomjs-launcher": "^1.0.4",
    "karma-sourcemap-loader": "^0.3.7",
    "karma-typescript-preprocessor": "^0.3.1",
    "karma-webpack": "^2.0.4",
    "ts-loader": "^2.3.1",
    "typescript": "^2.4.2",
    "webpack": "^3.4.0"
  }
  ...
}
```

# Angular モジュールのテスト

## Controller のテスト

### テスト対象コード

```ts:テスト対象コード.ts
import * as angular from 'angular';

class SampleController implements ng.IController {
  public readonly className = "SampleController";
  private clickCount = 0;

  public click() {
    this.clickCount++;
  }

  public getClickCount() {
    return this.clickCount;
  }
}
angular.module('chibiApp', []).controller('sampleController', SampleController);
```

### テストコード

```ts:テストコード.ts
describe("SampleControllerのテスト", () => {
  let $controller;
  beforeEach(angular.mock.module('chibiApp'));
  beforeEach(angular.mock.inject((_$controller_) => {
    $controller = _$controller_;
  }));

  describe('コンストラクタのテスト', () => {
    it('変数className', () => {
      const controller = $controller('sampleController');
      expect(controller.className).toEqual("SampleController");
    });
  });

  describe('click()のテスト', () => {
    it('clickCountが増えていること', () => {
      const controller = $controller('sampleController');
      expect(controller.getClickCount()).toEqual(0);
      controller.click();
      expect(controller.getClickCount()).toEqual(1);
      controller.click();
      expect(controller.getClickCount()).toEqual(2);
    });
  });
});

```

## Component のテスト

### テスト対象コード

```ts:テスト対象コード.ts
import * as angular from 'angular';

class SampleComponentOptions implements ng.IComponentOptions {
  public controller = ComponentController;
  public bindings = {
    firstName: '@',
    lastName: '@'
  };
}

class ComponentController implements ng.IComponentController {
  public firstName: string;
  public lastName: string;

  public getFullName(): string {
    return this.firstName + " " + this.lastName;
  }
}

angular.module('chibiApp', []).component('sampleComponent', new SampleComponentOptions());
```

### テストコード

```ts:テストコード.ts
describe("SampleComponentのテスト", () => {
  let $componentController;
  beforeEach(angular.mock.module('chibiApp'));
  beforeEach(angular.mock.inject((_$componentController_) => {
    $componentController = _$componentController_;
  }));

  describe('インスタンス変数のテスト', () => {
    it('bindしてないとき', () => {
      const component = $componentController('sampleComponent', null);
      expect(component.firstName).toBeUndefined();
      expect(component.lastName).toBeUndefined();
    });

    it('bindしているとき', () => {
      let bindings = {
        firstName: 'chibi',
        lastName: 'kinoko'
      };
      const component = $componentController('sampleComponent', null, bindings);
      expect(component.firstName).toEqual('chibi');
      expect(component.lastName).toEqual('kinoko');
    });
  });

  describe('getFullName()のテスト', () => {
    it('フルネームで返却されること', () => {
      let bindings = {
        firstName: 'chibi',
        lastName: 'kinoko'
      };
      const component = $componentController('sampleComponent', null, bindings);
      expect(component.getFullName()).toEqual('chibi kinoko');
    });
  });
});
```

## Filter のテスト

### テスト対象コード

```ts:テスト対象コード.ts
import * as angular from 'angular';

function replace() {
  return (input, s1, s2) => {
    return input.replace(s1, s2);
  }
}
angular.module('chibiApp', []).filter('replace', replace);
```

### テストコード

```ts:テストコード.ts
describe('replaceのテスト', () => {
  let $filter;
  beforeEach(angular.mock.module('chibiApp'));
  beforeEach(angular.mock.inject((_$filter_) => {
    $filter = _$filter_;
  }));

  it('置換できること', () => {
    const replace = $filter('replace');
    expect(replace('abbccc', 'a', 'z')).toEqual('zbbccc');
    expect(replace('abbccc', 'bb', 'z')).toEqual('azccc');
    expect(replace('abbccc', 'ccc', 'z')).toEqual('abbz');
  });
});
```

## Service のテスト

### テスト対象コード

```ts:テスト対象コード.ts
import * as angular from 'angular';

class CurrentTimeService {
  public readonly className = "CurrentTimeService";
  public now(): Date {
    return new Date();
  }
}
angular.module('chibiApp', []).service('currentTime', CurrentTimeService);
```

### テストコード

```ts:テストコード.ts
describe("CurrentTimeServiceのテスト", () => {
  let currentTime;
  beforeEach(angular.mock.module('chibiApp'));
  beforeEach(angular.mock.inject((_currentTime_) => {
    currentTime = _currentTime_;
  }));

  describe('インスタンス変数のテスト', () => {
    it('変数className', () => {
      expect(currentTime.className).toEqual("CurrentTimeService");
    });
  });

  describe('now()のテスト', () => {
    it('現在時刻が取得できる', () => {
      expect(currentTime.now().getDay()).toEqual(new Date().getDay());
    });
  });
});
```

# HTTP通信のレスポンスをMockしてテスト

### テスト対象コード

```ts:テスト対象コード.ts
import * as angular from 'angular';

class HttpSampleController {
  public readonly className = "HttpSampleController";
  public firstName: string;
  public lastName: string;

  constructor(private $http: ng.IHttpService) {
  }

  public request(): void {
    this.$http.get('/data').then((res: any) => {
      this.firstName = res.data.first;
      this.lastName = res.data.last;
    });
  }
}
angular.module('chibiApp', []).controller('httpSampleController', HttpSampleController);
```

### テストコード

```ts:テストコード.ts
describe('HttpSampleControllerのテスト', () => {
  let $controller;
  let $httpBackend;
  beforeEach(angular.mock.module('chibiApp'));
  beforeEach(angular.mock.inject((_$controller_, _$httpBackend_) => {
    $controller = _$controller_;
    $httpBackend = _$httpBackend_;
    $httpBackend.whenGET('/data').respond(200, {first: 'chibi', last: 'kinoko'});
  }));

  describe('コンストラクタのテスト', () => {
    it('変数className', () => {
      const controller = $controller('httpSampleController');
      expect(controller.className).toEqual("HttpSampleController");
    });
  });

  describe('#getのテスト', () => {
    it('HTTP通信のレスポンスを取得できていること', () => {
      const controller = $controller('httpSampleController');
      controller.request();
      $httpBackend.flush();

      expect(controller.firstName).toEqual('chibi');
      expect(controller.lastName).toEqual('kinoko');
    });
  });
});
```

# $timeoutを使っているメソッドのテスト

### テスト対象コード

```ts:テスト対象コード.ts
import * as angular from 'angular';

class TimeoutSampleController implements angular.IController {
  public readonly className = "TimeoutSampleController";
  public firstName: string;
  public lastName: string;

  constructor(private $timeout: ng.ITimeoutService) {
  }

  public request(): void {
    this.$timeout(() => {
      this.firstName = "chibi";
      this.lastName = "kinoko";
    }, 1000);
  }
  
  public request2(): ng.IPromise<any> {
    return this.$timeout(() => {
      this.firstName = "chibi";
      this.lastName = "kinoko";
    }, 1000);
  }
}
angular.module('chibiApp', []).controller('timeoutSampleController', TimeoutSampleController);
```

### テストコード

```ts:テストコード.ts
describe('TimeoutSampleControllerのテスト', () => {
  let $controller;
  let $timeout;
  beforeEach(angular.mock.module('chibiApp'));
  beforeEach(angular.mock.inject((_$controller_, _$timeout_) => {
    $controller = _$controller_;
    $timeout = _$timeout_;
  }));

  describe('コンストラクタのテスト', () => {
    it('変数className', () => {
      const controller = $controller('timeoutSampleController');
      expect(controller.className).toEqual("TimeoutSampleController");
    });
  });

  describe('#requestのテスト', () => {
    it('名前が取得できていること', () => {
      const controller = $controller('timeoutSampleController');
      controller.request();
      expect(controller.firstName).toBeUndefined();
      expect(controller.lastName).toBeUndefined();
      $timeout.flush();
      expect(controller.firstName).toEqual('chibi');
      expect(controller.lastName).toEqual('kinoko');
    });
  });

  describe('#request2のテスト', () => {
    it('名前が取得できていること', (done) => {
      const controller = $controller('timeoutSampleController');
      controller.request2().then(() => {
        expect(controller.firstName).toEqual('chibi');
        expect(controller.lastName).toEqual('kinoko');
        done();
      });
      expect(controller.firstName).toBeUndefined();
      expect(controller.lastName).toBeUndefined();
      $timeout.flush();
    });
  });
});
```

# $qを使っているメソッドのテスト

### テスト対象コード

```ts:テスト対象コード.ts
import * as angular from 'angular';

class QSampleController implements ng.IController {
  public readonly className = "QSampleController";

  constructor(private $q: ng.IQService) {
  }

  public request(resolveFlg: boolean): ng.IPromise<any> {
    const deferred = this.$q.defer();
    if (resolveFlg) {
      deferred.resolve({first: "chibi", last: "kinoko"});
    } else {
      deferred.reject({first: "undefined-chibi", last: "undefined-kinoko"});
    }
    return deferred.promise;
  }
}
angular.module('chibiApp', []).controller('qSampleController', QSampleController);
```

### テストコード

```ts:テストコード.ts
describe('QSampleControllerのテスト', () => {
  let $controller;
  let $rootScope;
  let $q;
  beforeEach(angular.mock.module('chibiApp'));
  beforeEach(angular.mock.inject((_$controller_, _$rootScope_, _$q_) => {
    $controller = _$controller_;
    $rootScope = _$rootScope_;
    $q = _$q_;
  }));

  describe('コンストラクタのテスト', () => {
    it('変数className', () => {
      const controller = $controller('qSampleController');
      expect(controller.className).toEqual("QSampleController");
    });
  });

  describe('#requestのテスト', () => {
    it('名前が取得できること: true', (done) => {
      const controller = $controller('qSampleController');
      controller.request(true).then((res) => {
        expect(res.first).toEqual('chibi');
        expect(res.last).toEqual('kinoko');
        done();
      });
      $rootScope.$apply();
    });

    it('名前が取得できないこと: false', (done) => {
      const controller = $controller('qSampleController');
      const deferred = $q.defer();
      controller.request(false).then((res) => {
        fail("Not come here.");
        done();
      }, (err) => {
        expect(err.first).toEqual('undefined-chibi');
        expect(err.last).toEqual('undefined-kinoko');
        done();
      });
      $rootScope.$apply();
    });
  });
});
```

# まとめ

自分用のチートシートとして、
コピペしてからテストコードを成長させていく感じで使えたらいいなぁ...