getHarness()でFixtureを指定するとなぜエラーになるのか
この記事は Angular Advent Calendar 2020 の 20 日目の記事です。
はじめに
Component Harnessを使ったテストを書いているときに次のようなエラーを見たことは無いだろうか。
Error: Failed to find element matching one of the following queries:
(HogeHarness with host element matching selector: "app-hoge")
これはHarnessを作成するメソッドを間違って使ったときに表示されるエラー[1]だ。自分は初めてHarnessでテストを書いたときにこのエラーに悩まされた。 hostSelector
の指定間違っていないし何がだめなんだろうと再度ドキュメントを読むと、どうやらFixtureのHarnessを getHarness()
で作成しようとしていたのが間違いらしい。ただ理由をよく読んでもわからず、よく分かっていないのに「どうやらこれを使うらしい」というのにモヤモヤした。
そこで今回は実際に getHarness()
の実装を読んで、なぜ上記のエラーが出るのかとついでにそれぞれどのようなときに使用するのかを共有しようと思う。
Harnessを作成するには
Component Harnessには、Harnessを生成するためのLoaderを生成するメソッドおよび直接Harnessを作成するメソッドが計3つ用意されている。
-
TestbedHarnessEnvironment.loader
のloader.getHarness()
-
TestbedHarnessEnvironment.documentRootLoader
のdocumentRootLoader.getHarness()
TestbedHarnessEnvironment.harnessForFixture
Using TestbedHarnessEnvironment and ProtractorHarnessEnvironment
Harnessを作成するのに適当に loader.getHarness
を使えばいいかと言われるとそうではない。Componentによって使用するloader, メソッドが決まっているのだ。
loader.getHarness()
Gets a HarnessLoader instance for the given fixture, rooted at the fixture's root element. Should be used to create harnesses for elements contained inside the fixture
documentRootLoader.getHarness()
Gets a HarnessLoader instance for the given fixture, rooted at the HTML document's root element. Can be used to create harnesses for elements that fall outside of the fixture
TestbedHarnessEnvironment.harnessForFixture
Used to create a ComponentHarness instance for the fixture's root element directly. This is necessary when bootstrapping the test with the component you plan to load a harness for, because Angular does not set the proper tag name when creating the fixture.
どういうときにどれを使えば良いのか
実装を読む前に先にどういうときにどれを使えば良いのかと言うと、
-
loader.getHarness()
- FixtureとなるComponent内で使用されている子コンポーネントのHarnessを取得する ときに使う。
-
documentRootLoader.getHarness()
-
FixtureとなるComponentの外側に存在するコンポーネントのHarnessを取得するときに使う。すなわち
document.body
配下に存在するコンポーネント。 (例: ダイアログなど)
-
FixtureとなるComponentの外側に存在するコンポーネントのHarnessを取得するときに使う。すなわち
-
TestbedHarnessEnvironment.harnessForFixture
- FixtureとなるComponent自身のHarnessを取得するときに使う。
つまりFixture自身のHarnessを loader.getHarness
で取得しようとすると冒頭のエラーが投げられて取得ができない。
実際の例で見てみよう。
この画面はLogin(Login Buttonの置いてある)のContainer Componentに2つのFormのPresentational Componentがある画面だ。ここで今回2つのFormのバリデーションによってLogin Buttonの活性非活性をテストしたいときに、Fixtureを作成するのはLoginPageComponentになる。
fixture = TestBed.createComponent(LoginPageComponent);
このテストでHarnessを利用する際に、LoginPageComponentのHarnessを作成するには TestbedHarnessEnvironment.harnessForFixture()
を使う必要がある。loader.getHarness()
ではLoginPageComponentのHarnessは取得できない。一方でこのLoginPageComponent内で使用されている2つの子コンポーネントのHarnessは loader.getHarness()
で取得できる。
// OK
loginPageHarness =
await TestbedHarnessEnvironment.harnessForFixture(fixture, LoginPageHarness);
const userIdFormHarness = await loader.getHarness(UserIdFormHarness);
const passwordFormHarness = await loader.getHarness(PasswordFormHarness);
// NG
loginPageHarness = await loader.getHarness(LoginPageHarness);
getHarness()
を追っていく
既に理由が公式ドキュメントに書いてある
エラーになる理由を解明するために実際に getHarness()
の実装を読んでいこう。
とその前に、そもそもなぜFixtureのHarnessを getHarness()
で生成できないのか、実はその理由は公式ドキュメントに書いてある。
Used to create a ComponentHarness instance for the fixture's root element directly. This is necessary when bootstrapping the test with the component you plan to load a harness for, because Angular does not set the proper tag name when creating the fixture.
どうやらAngularはFixtureの作成時に適切なタグを設定しないためらしい。ということはこの「適切なタグが存在しない」ことがキーになりそうだ。この点に注目して読んでいこう。(勘の良い人はここで気づくかもしれない…。)
loader
の生成
まずは loader
の生成が必要だ。
fixture.nativeElement
, fixture自身から TestbedHarnessEnvironment
インスタンスを生成している。
static loader(fixture: ComponentFixture<unknown>, options?: TestbedHarnessEnvironmentOptions): HarnessLoader {
return new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
}
testbed-harness-environment.ts#L112
この中で親クラスである HarnessEnvironment
の constructor()
に fixture.nativeElement
を渡して最終的に自身のプロパティに UnitTestElement
クラスのインスタンスを格納している。
protected constructor(protected rawRootElement: E) {
this.rootElement = this.createTestElement(rawRootElement);
}
protected createTestElement(element: Element): TestElement {
return new UnitTestElement(element, () => this.forceStabilize());
}
testbed-harness-environment.ts#L180
この UnitTestElement
クラスは click()
, blur()
などのイベントを発火させたり、 textContent
等のデータを取得したりするメソッドを提供するクラスだ。unit-test-element.ts#L67
ちなみに constructor()
の第2引数に渡している () => this.forceStabilize()
だが、これは ChangeDetectionを走らせる関数 であり、これは各イベントメソッドの最後に呼ばれる。
すなわち このUnitTestElementクラスが各イベントのたびに自動的にChangeDetectionを走らせてくれている のだ。testbed-harness-environment.ts#L148
getHarness()
さて、 loader
の生成 = TestbedHarnessEnvironment
インスタンスの生成ができたので、ここから getHarness()
を追っていく。
getHarness()
は親クラスの HarnessEnvironment
クラスで実装されている。 このメソッドを呼び出すと同クラス内の locatorFor()
を呼び出す。
getHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T> {
return this.locatorFor(query)();
}
locatorFor<T extends (HarnessQuery<any> | string)[]>(...queries: T):
AsyncFactoryFn<LocatorFnResult<T>> {
return () => _assertResultFound(
this._getAllHarnessesAndTestElements(queries),
_getDescriptionForLocatorForQueries(queries));
}
まずは _getAllHarnessesAndTestElements()
から見ていこう。
harness-environment#_getAllHarnssesAndTestElements
private async _getAllHarnessesAndTestElements<T extends (HarnessQuery<any> | string)[]>(
queries: T): Promise<LocatorFnResult<T>[]> {
const {allQueries, harnessQueries, elementQueries, harnessTypes} = _parseQueries(queries);
// Combine all of the queries into one large comma-delimited selector and use it to get all raw
// elements matching any of the individual queries.
const rawElements = await this.getAllRawElements(
[...elementQueries, ...harnessQueries.map(predicate => predicate.getSelector())].join(','));
// If every query is searching for the same harness subclass, we know every result corresponds
// to an instance of that subclass. Likewise, if every query is for a `TestElement`, we know
// every result corresponds to a `TestElement`. Otherwise we need to verify which result was
// found by which selector so it can be matched to the appropriate instance.
const skipSelectorCheck = (elementQueries.length === 0 && harnessTypes.size === 1) ||
harnessQueries.length === 0;
const perElementMatches = await parallel(() => rawElements.map(async rawElement => {
const testElement = this.createTestElement(rawElement);
const allResultsForElement = await parallel(
// For each query, get `null` if it doesn't match, or a `TestElement` or
// `ComponentHarness` as appropriate if it does match. This gives us everything that
// matches the current raw element, but it may contain duplicate entries (e.g.
// multiple `TestElement` or multiple `ComponentHarness` of the same type).
() => allQueries.map(query => this._getQueryResultForElement(
query, rawElement, testElement, skipSelectorCheck)));
return _removeDuplicateQueryResults(allResultsForElement);
}));
return ([] as any).concat(...perElementMatches);
}
このメソッドは処理が割と長いが、やっていることとしては
- 引数であるQueryをそれぞれのオブジェクトにParse
- 各Queryから対象となる要素を取得
をしている。
この処理の中で重要なのがこの部分。
const rawElements = await this.getAllRawElements(
[...elementQueries, ...harnessQueries.map(predicate => predicate.getSelector())].join(','));
この predicate.getSelector()
と this.getAllRawElements()
が今回のキモだ。
まず predicate.getSelector()
だが実装はこのようになっている。
getSelector() {
return this._ancestor.split(',')
.map(part => `${part.trim()} ${this.harnessType.hostSelector}`.trim())
.join(',');
}
component-harness.ts#L522
this.harnessType.hostSelector
に注目してほしい。どこかで見たことないだろうか?
これはHarnessを実装したときに定義する static hostSelector
部分である。
class ExampleHarness extends ComponentHarness {
static hostSelector = 'my-example';
}
この部分でHarnessに定義された hostSelector
を取得しているのだ。
そしてこのあと取得した hostSelector
を引数に this.getAllRawElements()
に進む。
このメソッドは TestbedHarnessEnvironment
に実装されている。
protected async getAllRawElements(selector: string): Promise<Element[]> {
await this.forceStabilize();
return Array.from(this._options.queryFn(selector, this.rawRootElement));
}
testbed-harness-environment.ts#L188
ChangeDetectionを走らせたあとに、何やら取得した hostSelector
と一番はじめに格納した rawRootElement
を引数に this._options.queryFn()
を実行している。
これは基本的にOptionsを設定しなければデフォルトの関数が使用され、その実装はこうなっている。
const defaultEnvironmentOptions: TestbedHarnessEnvironmentOptions = {
queryFn: (selector: string, root: Element) => root.querySelectorAll(selector)
};
testbed-harness-environment.ts#L31
設定した rawRootElement
から hostSeletor
にマッチする要素を取得している。[2]
要素を取得したら最後 _assertResultFound()
を呼び出す。(※その前に _getDescriptionForLocatorForQueries()
があるが今回特に重要ではないで割愛)
この _assertResultFound()
は結果の0番目を取得し、存在すればそれを存在しなければ例外を投げる。
async function _assertResultFound<T>(results: Promise<T[]>, queryDescriptions: string[]):
Promise<T> {
const result = (await results)[0];
if (result == undefined) {
throw Error(`Failed to find element matching one of the following queries:\n` +
queryDescriptions.map(desc => `(${desc})`).join(',\n'));
}
return result;
}
これで getHarness()
の処理は終わり。
getHarness()
でFixtureを指定するとなぜエラーになるのか
改めて getHarness()
での重要な処理は、
- Harnessの
hostSelector
を取得する処理 -
rawRootElement
からhostSelector
の要素を取得する処理 - 最後の結果が取得できたかできなかったかを判定する処理
である。
FixtureのHarnessを getHarness()
に渡した場合は、 1.の hostSelector
にはFixtureのタグ名が入る。
そして2.の「 rawRootElement
から hostSelector
の要素を取得する処理」だが、 Fixtureのテンプレートには、Fixture自身のタグは含まれていないため取得できない 。Fixture自身のタグ名はComponent Classの selector
で指定されるからだ。
@Component({
selector: 'app-example',
templateUrl: './example.component.html',
styleUrls: ['./example.component.scss'],
})
export class ExampleComponent implements OnInit {}
これが公式ドキュメントの
his is necessary when bootstrapping the test with the component you plan to load a harness for, because Angular does not set the proper tag name when creating the fixture.
ということである。
最終的に3.の部分で結果が存在しないため、冒頭の
Error: Failed to find element matching one of the following queries:
(HogeHarness with host element matching selector: "app-hoge")
が表示される。
最後に
いざ調査してみると「公式ドキュメントの言ってる通りのことだ…」と思ってしまったが、実装を読むことはツールの理解を深める上で有用であった。それに加えて自分の中での根拠に自信を持てるようになるのでおすすめ。
今後どれを使ってHarnessを作成すれば良いのかわからないという人がこの記事で減れば幸いだ。
Angular Advent Calendar 2020、
明日は @seapolis さんです!お楽しみに!
おまけ
ソースコードはGitHubで追っていたがどうしても、処理の流れや変数の中身がどうなっているのかわからない部分が出てきたので公式のリポジトリをFork&Cloneして実際にデバッグした。
その際、ローカルでテストコードのデバッグ環境を構築する記事が一切見当たらなかったので、自分が取った方法を共有しようと思う。(Component Harnessなので実行方法がテストコードしかなかった。)
Component Harnessのテストコードをデバッグする
前提として、自分は業務でAngularのテストをするときJestを使用しているため、Karma+Jasmineの環境構築や設定ファイルがわからない。ここで時間をつぶすのはもったいないため、無理やりJestの環境を構築した。
-
必要なパッケージをインストール
$ yarn add jest @types/jest @angular-builders/jest
-
次の3つのファイルをルート直下に配置
jest.config.jsmodule.exports = { moduleNameMapper: { '@core/(.*)': '<rootDir>/src/app/core/$1', '@angular/cdk/(.*)': '<rootDir>/src/cdk/$1' }, preset: 'jest-preset-angular', setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'], };
setup-jest.tsimport 'jest-preset-angular'; /* global mocks for jsdom */ const mock = () => { let storage: { [key: string]: string } = {}; return { getItem: (key: string) => (key in storage ? storage[key] : null), setItem: (key: string, value: string) => (storage[key] = value || ''), removeItem: (key: string) => delete storage[key], clear: () => (storage = {}), }; }; Object.defineProperty(window, 'localStorage', { value: mock() }); Object.defineProperty(window, 'sessionStorage', { value: mock() }); Object.defineProperty(window, 'getComputedStyle', { value: () => ['-webkit-appearance'], }); Object.defineProperty(document.body.style, 'transform', { value: () => { return { enumerable: true, configurable: true, }; }, }); /* output shorter and more meaningful Zone error stack traces */ // Error.stackTraceLimit = 2;
tsconfig.spec.json{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ "jest" ], "emitDecoratorMetadata": true, "allowJs": true }, "files": [ "src/polyfills.ts" ], "include": [ "src/**/*.spec.ts", "src/**/*.d.ts" ] }
-
PhpStormの
Edit Run Configuration
>Configuration file
で2.で作成したjest.config.js
のパスを指定。その後テストコードでデバッグ。
これでテストコードのデバッグができた。
Discussion