この記事は Angular #2 Advent Calendar 2019 二日目の記事です。
こんにちは。カルテットコミュニケーションズ で働いている @ringtail003 です。プロダクションコードがきちんと動作しているのにユニットテストが書けない、動かないって事ありませんか?そんなハマりポイントを記事にしてみました。
この記事を書いた環境:
- node v12.13
- @angular/cli 8.3.12
ngOnChange 動かないんですけど?
サンプルコード
https://github.com/ringtail003/angular-failed-test/tree/ng-on-changes
コンポーネントクラスの ngOnChanges()
でメンバ変数を更新しているケースです。
ブラウザでは ngOnChanges
が実行されるのに、テスト環境では無言を貫き message
は更新されず「何でだ何でだ、テストできねぇ!」になりがちです。
@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()
は呼び出されません。
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()
を自分で呼び出す方法です。
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()
を呼び出します。
@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
コンポーネントクラスのメンバ変数をビューに出力しているケースです。
テスト環境でメンバ変数を変更してもビューは何も変わらず「何でだ何でだ、テストできねぇ!」になりがちです。
@Component({
selector: 'some',
template: '<span>{{count}}</span>'
})
export class SomeComponent implements OnInit {
@Input() count: number = 0;
}
Bad
メンバ変数を更新しただけではビューは更新されません。
it('Bad', () => {
component.count = 10;
expect(fixture.debugElement.query(By.css('span')).nativeElement.textContent).toBe('10');
// Error: Expected '0' to be '10'.
});
Good
detectChanges()
を使いましょう。
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 クライアントを利用したサービスのケースです。適当なテストダブルを注入すればざっくりしたテストは書けるものの、リクエストヘッダなど細かい検証が必要になった時に「テスト追加できねぇ!」になりがちです。
@Injectable({...})
export class SomeService {
private config = {} as Config;
constructor(
private httpClient: HttpClient,
) { }
getConfig(): Observable<Config> {
return this.httpClient.get<Config>('/config');
}
}
Bad
HTTP クライアント自体をテストダブルに置き換えれば検証はできますが、URLやコール回数は検証できていません。
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
公式ドキュメント そのままです。
リクエストヘッダの検証や、複数回のリクエストにも対応できます。
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
コンポーネントメタデータでサービスを注入しているケースです。テストダブルに置き換える方法が分からず「しょうがないからテストダブルは諦めるかぁ...」となりがちです。
@Injectable({...})
export class SomeService {
count() {
return 1;
}
}
@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
でテストダブルに置き換える事はできません。
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
でもテストダブルに置き換える事はできません。
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
を使いましょう。
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
テスト対象のコンポーネントが子コンポーネントを所有していて、かつ子コンポーネントが依存の注入を要求しているケースです。
ブラウザでは問題なく動作し、いざテストを書こうと思ったら、テスト環境では要求された依存が見つからない、子が所有する子孫コンポーネントが見つからない、あれもこれも見つからない...。大量のエラーメッセージに悩まされる時があります。
@Component({
selector: 'some',
template: '<child></child>'
})
export class SomeComponent implements OnInit {}
@Component({
selector: 'child'
})
export class ChildComponent implements OnInit {
constructor(
private httpClient: HttpClient,
) { }
Bad
子コンポーネントの要求に合わせて親コンポーネントのテストで依存解決するのは面倒です。
- 子コンポーネントが増えるたびに新たな依存が増えていく
- 子コンポーネントの依存注入がコンポーネントメタデータに変わるとテストが影響を受ける
- 前項の「依存が注入されない?!」を参照
describe('Bad', () => {
...
beforeEach(async(() => {
TestBed.configureTestingModule({
providers: [{
provide: HttpClient, useValue: { get: () => Rx.of('dummy') }
}],
declarations: [
SomeComponent,
ChildComponent,
],
})
.compileComponents();
}));
...
});
Good
いっそ子コンポーネント自体をテストダブルにしてしまいましょう。
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
に集約していると、コンポーネント単体でテストをする時になって「このコンポーネントはどのモジュールを読み込めば動くんだっけ??えーとコレとアレと...」になりがちです。
@Component({
selector: 'some',
template: '<input [(ngModel)]="value"><other></other>'
})
export class SomeComponent implements OnInit {}
Bad
読み込みが必要なモジュール、子コンポーネントの宣言、依存の解決...。コンポーネントに新たな依存が追加されるたびにテストをメンテするのは面倒です。
describe('Bad', () => {
...
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
SomeComponent,
OtherComponent,
],
imports: [
FormsModule,
],
providers: [...]
})
.compileComponents();
}));
...
});
Good
フィーチャーモジュール でコンポーネント群とそれらが必要とする依存をまとめておきましょう。
機能ごとにモジュール分割する事によって、アプリケーションの構造化や遅延ロードのハンドリングに役立つ他、テストもその恩恵を受けられます。
@NgModule({
declarations: [
SomeComponent,
OtherComponent,
],
imports: [
FormsModule,
],
exports: [
SomeComponent,
OtherComponent,
],
})
export class SomeModule {}
テストはフィーチャーモジュールを読み込むだけで、アレコレと依存をメンテする面倒な作業から解放されます。
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
リアクティブフォーム の要素を単独のコンポーネントとして宣言したケースです。属性に formControlName
を宣言した場合、そのコンポーネントは formGroup
配下に設置する事が強制されます。単体でコンポーネント生成する事ができず、まさに「ユニットテストが動かねぇ!」の状況にハマりました。
@Component({
selector: 'some',
template: '<input type="text" formControlName="{{name}}">',
viewProviders:[{
provide: ControlContainer,
useExisting: FormGroupDirective,
}],
})
export class SomeComponent implements OnInit {
@Input() name: string = null;
}
<!-- このように設置する -->
<form formGroup="myForm">
<some></some>
</form>
Bad
単独で存在する事ができないため、そもそもテスト環境でのコンポーネント生成に失敗します。
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
の要件を満たすようにします。
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 クライアントを利用したサービスのケースを紹介します。
@Injectable({...})
export class UserService {
constructor(
private httpClient: HttpClient,
) { }
getUser(id: number): Rx.Observable<User> {
return this.httpClient.get<User>(`/users/${id}`);
}
}
Bad
下記の例では expect
は実行されません。実行されれば検証が失敗するはずなのに、スキップしてしまうと検証内容が間違っている事に気づけません。
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 を使用して、実行される事を保証しておきましょう。
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 を使用して現在時刻をビューに出力しています。
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
を別のものに変えた時、テストが影響を受けます。
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 を使いましょう。
Clock はビルトインの Date オブジェクトをテストダブルに置き換えるため、ユーティリティやメソッドの変更に影響を受けません。
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 さんにバトンを託します!
エントリが変わったようです。@okunokentaro さんにバトンを託します!よろしくお願いします!