49
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Angular #2Advent Calendar 2019

Day 2

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

Last updated at Posted at 2019-12-01

この記事は 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.configureTestingModuleproviders でテストダブルに置き換える事はできません。

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 である事に依存しています。
またプロダクションコードから呼び出している localtoFormat を別のものに変えた時、テストが影響を受けます。

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 さんにバトンを託します!よろしくお願いします!

49
35
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?