Angular8 ユニットテストが動かねぇ！

この記事は [Angular #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/angular-2) 二日目の記事です。

こんにちは。[カルテットコミュニケーションズ](https://quartetcom.co.jp/recruit/engineer/) で働いている [@ringtail003](https://twitter.com/ringtail003) です。プロダクションコードがきちんと動作しているのにユニットテストが書けない、動かないって事ありませんか？そんなハマりポイントを記事にしてみました。

この記事を書いた環境：

- node v12.13
- @angular/cli 8.3.12

# ngOnChange 動かないんですけど？

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/ng-on-changes

コンポーネントクラスの `ngOnChanges()` でメンバ変数を更新しているケースです。
ブラウザでは `ngOnChanges` が実行されるのに、テスト環境では無言を貫き `message` は更新されず「何でだ何でだ、テストできねぇ！」になりがちです。

```typescript
@Component({
  selector: 'some',
  template: '{{message}}'
})
export class SomeComponent implements OnInit, OnChanges {
  @Input() target: string = null;
  message: string;

  ngOnChanges(changes: { target?: SimpleChange }) {
    if (changes.target) {
      this.message = this.greet();
    }
  }

  greet() {
    return`Hello ${this.target}`;
  }
}
```

## Bad

テスト環境で直接メンバ変数を変更しても `ngOnChange()` は呼び出されません。

```typescript
it('Bad', () => {
  component.target = 'Angular';
  expect(fixture.debugElement.query(By.css('span')).nativeElement.textContent).toBe('Hello Angular');
  // Error: Expected 'Hello null' to be 'Hello Angular'.
});
```

## Good (1)

ひとつの解決策は `ngOnChanges()` を自分で呼び出す方法です。

```typescript
it('Good', () => {
  component.target = 'Angular';
  component.ngOnChanges({
    target: new SimpleChange(null, component.target, false),
  });
  fixture.detectChanges();

  expect(fixture.debugElement.nativeElement.textContent).toBe('Hello Angular');
});
```

## Good (2)

または、ラッパーコンポーネント越しに `ngOnChanges()` を呼び出します。

```typescript
@Component({
  selector: 'wrapped',
  template: '<some [target]="target"></some>'
})
class WrappedComponent {
  target = '';
}

describe('SomeComponent', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ SomeComponent, WrappedComponent ]
    })
    .compileComponents();

    fixture = TestBed.createComponent(WrappedComponent);
    ...
  }));

  it('Good', () => {
    wrapped.target = 'Angular';
    fixture.detectChanges();
    expect(fixture.debugElement.nativeElement.textContent).toBe('Hello Angular');
  });
});
```

# ビューが更新されないんですけど？

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/detect-changes

コンポーネントクラスのメンバ変数をビューに出力しているケースです。
テスト環境でメンバ変数を変更してもビューは何も変わらず「何でだ何でだ、テストできねぇ！」になりがちです。

```typescript
@Component({
  selector: 'some',
  template: '<span>{{count}}</span>'
})
export class SomeComponent implements OnInit {
  @Input() count: number = 0;
}
```

## Bad

メンバ変数を更新しただけではビューは更新されません。

```typescript
it('Bad', () => {
  component.count = 10;
  expect(fixture.debugElement.query(By.css('span')).nativeElement.textContent).toBe('10');
  // Error: Expected '0' to be '10'.
});
```

## Good

`detectChanges()` を使いましょう。

```typescript
it('Good', () => {
  component.count = 10;
  fixture.detectChanges();
  expect(fixture.debugElement.query(By.css('span')).nativeElement.textContent).toBe('10');
});
```

# HTTP リクエストのテストどう書くんだっけ？

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/http

HTTP クライアントを利用したサービスのケースです。適当なテストダブルを注入すればざっくりしたテストは書けるものの、リクエストヘッダなど細かい検証が必要になった時に「テスト追加できねぇ！」になりがちです。

```typescript
@Injectable({...})
export class SomeService {
  private config = {} as Config;

  constructor(
    private httpClient: HttpClient,
  ) { }

  getConfig(): Observable<Config> {
    return this.httpClient.get<Config>('/config');
  }
}
```

## Bad

HTTP クライアント自体をテストダブルに置き換えれば検証はできますが、URLやコール回数は検証できていません。

```typescript
describe('Bad', () => {
  let service: SomeService;
  const response = { id: 1, name: 'aaa' };

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        { provide: HttpClient, useValue: { get: () => Rx.of(response) } },
      ]
    });
  });

  it('should be return config', () => {
    service = TestBed.get(SomeService);

    service.getConfig().subscribe((config) => {
      expect(config).toBe(response);
    });
  });
});
```

## Good

[公式ドキュメント](https://angular.jp/guide/http#http%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%82%92%E3%83%86%E3%82%B9%E3%83%88%E3%81%99%E3%82%8B) そのままです。
リクエストヘッダの検証や、複数回のリクエストにも対応できます。

```typescript
describe('Good', () => {
  let httpTestingController: HttpTestingController;
  let service: SomeService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ HttpClientTestingModule ],
    });
    httpTestingController = TestBed.get(HttpTestingController);
    service = TestBed.get(SomeService);
  });

  it('should be return config', () => {
    const response = { id: 1, name: 'aaa' };

    service.getConfig().subscribe((config) => {
      expect(config).toBe(response);
    });

    const req = httpTestingController.expectOne('/config');
    expect(req.request.method).toEqual('GET');
    req.flush(response);

    httpTestingController.verify();
  });
});
```

# 依存が注入されない？！

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/wront-provide

コンポーネントメタデータでサービスを注入しているケースです。テストダブルに置き換える方法が分からず「しょうがないからテストダブルは諦めるかぁ...」となりがちです。

```typescript
@Injectable({...})
export class SomeService {
  count() {
    return 1;
  }
}
```

```typescript
@Component({
  selector: 'some',
  template: '',
  providers: [ SomeService ],
})
export class SomeComponent implements OnInit {
  count: number = null;
  
  ngOnInit() {
    this.count = this.someService.count();
  }
}
```

## Bad (1)

コンポーネントメタデータでサービスを注入している場合 `TestBed.configureTestingModule` の `providers` でテストダブルに置き換える事はできません。

```typescript
describe('Bad', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ SomeComponent ],
      providers: [
        { provide: SomeService, useValue: { count: () => 100 } }
      ],
    })
    .compileComponents();
  }));
  ...

  it('count should return fake value', () => {
    expect(component.count).toBe(100);
    // Error: Expected 1 to be 100.
  });
});
```

## Bad (2)

`TestBed.get` でもテストダブルに置き換える事はできません。

```typescript
describe('Bad', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ SomeComponent ],
    })
    .compileComponents();

    spyOn(TestBed.get(SomeService), 'count').and.returnValue(200);
  }));
  ...

  it('count should return fake value', () => {
    expect(component.count).toBe(200);
    // Error: Expected 1 to be 100.
  });
});
```

## Good

コンポーネントメタデータの置き換えは `overrideProvider` を使いましょう。

```typescript
describe('Good', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ SomeComponent ],
    })
    .overrideProvider(SomeService, { useValue: { count: () => 100 } })
    .compileComponents();
  }));
  ...

  it('count should return fake value', () => {
    expect(component.count).toBe(100);
  });
});
```

# 子コンポーネントの依存注入が大変

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/dependence-child

テスト対象のコンポーネントが子コンポーネントを所有していて、かつ子コンポーネントが依存の注入を要求しているケースです。

ブラウザでは問題なく動作し、いざテストを書こうと思ったら、テスト環境では要求された依存が見つからない、子が所有する子孫コンポーネントが見つからない、あれもこれも見つからない...。大量のエラーメッセージに悩まされる時があります。

```typescript
@Component({
  selector: 'some',
  template: '<child></child>'
})
export class SomeComponent implements OnInit {}
```

```typescript
@Component({
  selector: 'child'
})
export class ChildComponent implements OnInit {
  constructor(
    private httpClient: HttpClient,
  ) { }
```

## Bad

子コンポーネントの要求に合わせて親コンポーネントのテストで依存解決するのは面倒です。

- 子コンポーネントが増えるたびに新たな依存が増えていく
- 子コンポーネントの依存注入がコンポーネントメタデータに変わるとテストが影響を受ける
  - 前項の「依存が注入されない？！」を参照

```typescript
describe('Bad', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      providers: [{
        provide: HttpClient, useValue: { get: () => Rx.of('dummy') }
      }],
      declarations: [
        SomeComponent,
        ChildComponent,
      ],
    })
    .compileComponents();
  }));
  ...
});
```

## Good

いっそ子コンポーネント自体をテストダブルにしてしまいましょう。

```typescript
describe('Good', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        SomeComponent,
        ChildComponent,
      ],
    })
    .overrideComponent(ChildComponent, {})
    .compileComponents();
  }));
  ...
});

// Fake component
@Component({
  selector: 'child',
  template: '',
})
class ChildComponent {}
```

# アレを注入してコレを注入して...

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/feature-module

`FormsModule` `OtherComponent` など依存の多いコンポーネントのケースです。アプリケーションに必要なモジュール読み込みやコンポーネント群の宣言を `app.module.ts` に集約していると、コンポーネント単体でテストをする時になって「このコンポーネントはどのモジュールを読み込めば動くんだっけ？？えーとコレとアレと...」になりがちです。

```typescript
@Component({
  selector: 'some',
  template: '<input [(ngModel)]="value"><other></other>'
})
export class SomeComponent implements OnInit {}
```

## Bad

読み込みが必要なモジュール、子コンポーネントの宣言、依存の解決...。コンポーネントに新たな依存が追加されるたびにテストをメンテするのは面倒です。

```typescript
describe('Bad', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        SomeComponent,
        OtherComponent,
      ],
      imports: [
        FormsModule,
      ],
      providers: [...]
    })
    .compileComponents();
  }));
  ...
});
```

## Good

[フィーチャーモジュール](https://angular.jp/guide/feature-modules) でコンポーネント群とそれらが必要とする依存をまとめておきましょう。
機能ごとにモジュール分割する事によって、アプリケーションの構造化や遅延ロードのハンドリングに役立つ他、テストもその恩恵を受けられます。

```typescript
@NgModule({
  declarations: [
    SomeComponent,
    OtherComponent,
  ],
  imports: [
    FormsModule,
  ],
  exports: [
    SomeComponent,
    OtherComponent,
  ],
})
export class SomeModule {}
```

テストはフィーチャーモジュールを読み込むだけで、アレコレと依存をメンテする面倒な作業から解放されます。

```typescript
describe('Good', () => {
  let component: SomeComponent;
  let fixture: ComponentFixture<SomeComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        SomeModule,
      ],
    })
    .compileComponents();
  }));
  ...
});
```

# 設置場所が強制されるコンポーネント

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/parts-of-reactive-form

[リアクティブフォーム](https://angular.jp/guide/reactive-forms) の要素を単独のコンポーネントとして宣言したケースです。属性に `formControlName` を宣言した場合、そのコンポーネントは `formGroup` 配下に設置する事が強制されます。単体でコンポーネント生成する事ができず、まさに「ユニットテストが動かねぇ！」の状況にハマりました。

```typescript
@Component({
  selector: 'some',
  template: '<input type="text" formControlName="{{name}}">',
  viewProviders:[{
    provide: ControlContainer,
    useExisting: FormGroupDirective,
  }],
})
export class SomeComponent implements OnInit {
  @Input() name: string = null;
}
```

```html
<!-- このように設置する -->
<form formGroup="myForm">
  <some></some>
</form>
```

## Bad

単独で存在する事ができないため、そもそもテスト環境でのコンポーネント生成に失敗します。

```typescript
describe('Bad', () => {
  let component: SomeComponent;
  let fixture: ComponentFixture<SomeComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ SomeComponent ],
      imports: [ ReactiveFormsModule ],
    })
    .compileComponents();
  }));
  ...

  it('should create the component', () => {
    component.name = 'foo';
    expect(component).toBeTruthy();
    // NullInjectorError: StaticInjectorError(DynamicTestModule)[ControlContainer -> FormGroupDirective]: 
  });
});
```

## Good

テストを実行するにはラッパーコンポーネントを作成し、ラッパーコンポーネントが `formGroup` の要件を満たすようにします。

```typescript
describe('Good', () => {
  let component: WrapComponent;
  let fixture: ComponentFixture<WrapComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        SomeComponent,
        WrapComponent,
      ],
      imports: [
        ReactiveFormsModule,
      ],
    })
    .compileComponents();
  }));
  ...

  it('should render value', () => {
    expect(fixture.debugElement.query(By.css('input')).nativeElement.value).toBe('123');
  });
});

@Component({
  template: `
    <form [formGroup]="$form">
      <some [name]="'foo'"></some>
    </form>
  `
})
class WrapComponent implements OnInit {
  $form: FormGroup = null;

  ngOnInit() {
    this.$form = new FormGroup({
      foo: new FormControl('123'),
    });
  }
}
```

# 実行されないテスト

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/no-executed

特定の箇所のテストがスキップされていた事に気づかず、バグの発見が遅れた事はないでしょうか？
このケースは非同期処理で発生しがちなので、HTTP クライアントを利用したサービスのケースを紹介します。

```typescript
@Injectable({...})
export class UserService {
  constructor(
    private httpClient: HttpClient,
  ) { }

  getUser(id: number): Rx.Observable<User> {
    return this.httpClient.get<User>(`/users/${id}`);
  }
}
```

## Bad

下記の例では `expect` は実行されません。実行されれば検証が失敗するはずなのに、スキップしてしまうと検証内容が間違っている事に気づけません。

```typescript
describe('Bad', () => {
  let service: UserService = null;
  const subject$ = new Rx.Subject();
  const response = { id: 1, name: 'foo' };

  beforeEach(() => {
    ...
    spyOn(TestBed.get(HttpClient), 'get').and.returnValue(subject$);
    service = TestBed.get(UserService);
  });

  it('should be return response', () => {
    service.getUser(1).subscribe((user) => {
      // 実は検証内容が間違っているが気づけない
      expect(user.id).toBe(10000000000);
      expect(user.name).toBe('fooooooooooooooo');
    });
  });
});
```

## Good

非同期のコールバックには [Jasmine の done](https://jasmine.github.io/tutorials/async#callbacks) を使用して、実行される事を保証しておきましょう。

```typescript
describe('Good', () => {
  let service: UserService = null;
  const subject$ = new Rx.Subject();
  const response = { id: 1, name: 'foo' };

  beforeEach(() => {
    ...
    spyOn(TestBed.get(HttpClient), 'get').and.returnValue(subject$);
    service = TestBed.get(UserService);
  });

  it('should be return response', (done) => {
    service.getUser(1).subscribe((user) => {
      expect(user.id).toBe(1);
      expect(user.name).toBe('foo');
      // done が呼ばれない場合、テストはタイムアウトでエラーになる
      done();
    });
    subject$.next(response);
  });
});
```

# 現在日時ってどうテストするんだっけ？

> **サンプルコード**
> https://github.com/ringtail003/angular-failed-test/tree/fake-timer

現在日時に左右されるレンダリングのケースです。[Luxon](https://moment.github.io/luxon/) を使用して現在時刻をビューに出力しています。

```typescript
import { DateTime } from 'luxon';

@Component({
  selector: 'some',
  template: '{{message}}'
})
export class SomeComponent implements OnInit {
  message = '';

  ngOnInit() {
    this.message = DateTime.local().toFormat('yyyy/MM/dd hh:mm なう');
  }
}
``` 

## Bad

このテストは日時取得のユーティリティが Luxon である事に依存しています。
またプロダクションコードから呼び出している `local` や `toFormat` を別のものに変えた時、テストが影響を受けます。

```typescript
describe('Bad', () => {
  ...
  beforeEach(async(() => {
    ...
    spyOn(DateTime, 'local').and.callFake(() => {
      return <any>{ toFormat: () => '2019/11/12 13:15' };
    });
  }));
  ...

  it('should be renderd now', () => {
    expect(fixture.debugElement.nativeElement.textContent).toBe('2019/11/12 13:15 なう');
  });
});
```

## Good

Jasmine の [Clock](https://jasmine.github.io/api/edge/Clock.html) を使いましょう。
Clock はビルトインの Date オブジェクトをテストダブルに置き換えるため、ユーティリティやメソッドの変更に影響を受けません。

```typescript
describe('Good', () => {
  ...
  beforeEach(() => {
    jasmine.clock().install();
    jasmine.clock().mockDate(DateTime.fromISO('2019-12-02T09:00').toJSDate());
  });
  afterEach(() => jasmine.clock().uninstall());
  ...

  it('should be renderd now', () => {
    expect(fixture.debugElement.nativeElement.textContent).toBe('2019/12/02 09:00 なう');
  });
});
```

# おわり

以上です。
今後また「ユニットテストが動かねぇ！！」な状況に陥った時は、解決策を追記しようと思います。

それでは明日の ~~[@luncheon](https://qiita.com/luncheon) さんにバトンを託します！~~
エントリが変わったようです。[@okunokentaro](https://qiita.com/okunokentaro) さんにバトンを託します！よろしくお願いします！