🐴

getHarness()でFixtureを指定するとなぜエラーになるのか

2020/12/20に公開

この記事は 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.loaderloader.getHarness()
  • TestbedHarnessEnvironment.documentRootLoaderdocumentRootLoader.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 配下に存在するコンポーネント。 (例: ダイアログなど)
  • TestbedHarnessEnvironment.harnessForFixture
    • FixtureとなるComponent自身のHarnessを取得するときに使う。

つまりFixture自身のHarnessを loader.getHarness で取得しようとすると冒頭のエラーが投げられて取得ができない。
実際の例で見てみよう。
sample-page
この画面は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 インスタンスを生成している。

testbed-harness-environment.ts
static loader(fixture: ComponentFixture<unknown>, options?: TestbedHarnessEnvironmentOptions): HarnessLoader {
    return new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options);
}

testbed-harness-environment.ts#L112

この中で親クラスである HarnessEnvironmentconstructor()fixture.nativeElement を渡して最終的に自身のプロパティに UnitTestElement クラスのインスタンスを格納している。

harness-environment.ts
protected constructor(protected rawRootElement: E) {
    this.rootElement = this.createTestElement(rawRootElement);
}

harness-environment.ts#L50

testbed-harness-environment.ts
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() を呼び出す。

harness-environment.ts
getHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T> {
    return this.locatorFor(query)();
}

harness-environment.ts#L103

harness-environment.ts
locatorFor<T extends (HarnessQuery<any> | string)[]>(...queries: T):
  AsyncFactoryFn<LocatorFnResult<T>> {
    return () => _assertResultFound(
        this._getAllHarnessesAndTestElements(queries),
        _getDescriptionForLocatorForQueries(queries));
}

harness-environment.ts#L60

まずは _getAllHarnessesAndTestElements() から見ていこう。

harness-environment#_getAllHarnssesAndTestElements
harness-environment.ts
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);
}

harness-environment.ts#L153

このメソッドは処理が割と長いが、やっていることとしては

  1. 引数であるQueryをそれぞれのオブジェクトにParse
  2. 各Queryから対象となる要素を取得

をしている。
この処理の中で重要なのがこの部分。

harness-environment.ts
const rawElements = await this.getAllRawElements(
    [...elementQueries, ...harnessQueries.map(predicate => predicate.getSelector())].join(','));

この predicate.getSelector()this.getAllRawElements() が今回のキモだ。
まず predicate.getSelector() だが実装はこのようになっている。

harness-environment.ts
getSelector() {
    return this._ancestor.split(',')
        .map(part => `${part.trim()} ${this.harnessType.hostSelector}`.trim())
        .join(',');
}

component-harness.ts#L522
this.harnessType.hostSelector に注目してほしい。どこかで見たことないだろうか?
これはHarnessを実装したときに定義する static hostSelector 部分である。

example-harness.ts
class ExampleHarness extends ComponentHarness {
    static hostSelector = 'my-example';
}

この部分でHarnessに定義された hostSelector を取得しているのだ。

そしてこのあと取得した hostSelector を引数に this.getAllRawElements() に進む。
このメソッドは TestbedHarnessEnvironment に実装されている。

testbed-harness-environment.ts
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を設定しなければデフォルトの関数が使用され、その実装はこうなっている。

testbed-harness-environment.ts
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;
}

harness-environment.ts#L257

これで getHarness() の処理は終わり。

getHarness() でFixtureを指定するとなぜエラーになるのか

改めて getHarness() での重要な処理は、

  1. Harnessの hostSelector を取得する処理
  2. rawRootElement から hostSelector の要素を取得する処理
  3. 最後の結果が取得できたかできなかったかを判定する処理

である。
FixtureのHarnessを getHarness() に渡した場合は、 1.の hostSelector にはFixtureのタグ名が入る。
そして2.の「 rawRootElement から hostSelector の要素を取得する処理」だが、 Fixtureのテンプレートには、Fixture自身のタグは含まれていないため取得できない 。Fixture自身のタグ名はComponent Classの selector で指定されるからだ。

example.component.ts
@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の環境を構築した。

  1. 必要なパッケージをインストール

    $ yarn add jest @types/jest @angular-builders/jest
    
  2. 次の3つのファイルをルート直下に配置

    jest.config.js
    module.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.ts
    import '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"
      ]
    }
    
  3. PhpStormの Edit Run Configuration > Configuration file で2.で作成した jest.config.js のパスを指定。その後テストコードでデバッグ。

これでテストコードのデバッグができた。

脚注
  1. メソッドを間違った場合の他に hostSelector の指定が間違っている場合にも表示される。 ↩︎

  2. ちなみに documentRootLoader.getHarness() ではこの rawRootElementdocument.body になっている。 ↩︎

Discussion