Angular のテスト ライブラリ
Angular フレームワークには、組み込みのツールと構成の膨大なセットがあり、他のフレームワーク/ライブラリよりもはるかに速く、より組織化された方法で速度を上げることができます。ツールのセットの 1 つは Jasmine と Karma で、単体テストを作成するためのツールを提供します。しかし、ビジネスの方向転換のためにリファクタリングが必要になる可能性がある機能や、実装の詳細に依存しすぎる機能を構築する必要がある開発者の観点からは、単体テストの作成は退屈で時間がかかるようになり始めています。
これを念頭に置いて、開発者は、単純な UI の変更ごとに単体テストのリファクタリングを回避し、よりクリーンでユーザー中心のテストを作成するための代替手段とベスト プラクティスを探し始めます。Testing Libraryは、この問題に役立ちます。このライブラリは React の世界で非常に有名で、他のフレームワークでも使用できるように拡張されており、Angular 開発者の間で人気が高まっています。
この記事の目的は、テスト ライブラリを使用するために Angular アプリケーションをゼロから構成する方法に関するガイドを提示することです。また、プログラムや実装の詳細に依存する従来の単体テストではなく、DOM テストに焦点を当て、ユーザーがアプリを操作する方法を目的とした単純なテスト ケースの単体テストを作成する方法も示します。
Angular でライブラリをテストすることの長所と短所
長所
- 優れた保守可能な単体テストを作成するための、クリーンでシンプルな哲学。
- 実装の詳細がテストされていないため、コードのリファクタリングや新機能の追加の際のオーバーヘッドが削減されます*。
- ユーザーが見たり、やり取りしたり、パブリック API* をテストしたりするものだけをテストする必要があります。
- ユーザー イベントは、クリーンでシンプルな API を使用して適切にシミュレートされます。
- 「ユーザーの視点」でクエリを実行するだけで、DOM 要素を簡単に参照できます。
- 良いエスケープ ハッチ: data-testid。
- 実装の詳細であるランダムな ID と CSS クラスの代わりに、ロールと正しいセマンティックに基づいて DOM 要素をクエリするように強制することで、開発者がアクセシビリティに準拠した UI を作成できるようにします。
- Angular で使用されるデフォルトの Jasmine ライブラリと技術的に互換性があります。
- 優れた単体テストを作成するには、別の考え方が必要です
- 既存の大規模プロジェクトに実装するのは難しいかもしれませんが、長期的には少し努力するだけの価値があることが証明されるかもしれません.
インストールと構成
Jasmine ライブラリと Karma ライブラリを削除する
- package.json で以下のエントリを削除してから、npm installを実行します。
- 「@types/ジャスミン」
- 「ジャスミンコア」
- "カルマ"
- 「カルマクロームランチャー」
- 「カルマカバレッジ」
- 「カルマジャスミン」
- 「カルマ-ジャスミン-html-レポーター」
- jasmine または karma に関連する他のパッケージがあるかどうかを確認し、それらのエントリも削除します。
Karma と Angular のテスト構成に関連するファイルを削除します
- カルマ.conf.js
- app フォルダー内のtest.ts。
- angular.jsonファイルで、「architect」セクション内の「test」構成を削除します。
必要なパッケージをインストールする
- コマンドnpm install --save-devを使用して、次のパッケージをインストールします。
- 「@testing-library/angular」
- 「@testing-library/jest-dom」
- 「@testing-library/user-event」
- 「@types/jest」
- "冗談"
- 「ジェストプリセット角度」
- また、Angular はそれらを無視し、プロダクション バンドルに含めません。
- インストール後、Jest を構成してテストを実行し、npm スクリプトを設定する必要があります。
- まず、プロジェクトのルートにsetup-jest.tsという名前のファイルを次の内容で作成します。
import '@testing-library/jest-dom';
import 'jest-preset-angular/setup-jest';
"jest": {
"preset": "jest-preset-angular",
"setupFilesAfterEnv": [
"<rootDir>/setup-jest.ts"
]
}
"scripts": {
// ... other scripts used to run
"test": "jest",
"test:watch": "jest --watch"
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jest"
]
},
"files": [
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
このガイドでは、テスト アプリケーションが必要です。アプリケーションには、1 つのコンポーネントと 1 つのテキスト入力しかありません。入力にはバリデーターがあり、無効でタッチされた場合は検証メッセージが表示されます。目標は、次のような基本的な単体テストを作成する方法を示すことです。
- 要素の存在について DOM をクエリします。
- ユーザー アクションを実行し、DOM で結果を評価します。
- コンポーネントの状態が正しいかどうかを確認します。
<section class="page-content">
<form>
<div class="control-group">
<label for="username">Username <span class="danger-color">*</span></label>
<div class="control">
<input type="text" id="username" [formControl]="usernameControl">
<div
class="messages"
*ngIf="usernameControl.invalid && usernameControl.touched">
<ng-container *ngIf="usernameControl.hasError('required')">
Username is required.
</ng-container>
</div>
</div>
</div>
</form>
</section>
.page-content {
width: 64rem;
margin: 0 auto;
padding: 1.5rem;
}
.control-group {
display: flex;
flex-direction: column;
}
.control-group label {
display: block;
font-weight: bold;
font-size: 0.85rem;
margin-bottom: 0.25rem;
white-space: nowrap;
}
.control-group .control {
width: 100%;
margin-bottom: 1.1rem;
}
.control-group .control input {
display: block;
width: 100%;
height: 2rem;
font-size: 1rem;
border: Max(1px, 0.0625rem) solid #9E9E9E;
border-radius: Max(3px, 0.1875rem);
padding: 0.25rem 0.5rem;
}
.control-group .control input:hover {
border-color: #1976D2;
}
.control-group .control input:focus {
border-color: #1976D2;
outline: none;
box-shadow: 0 0 6px #1976D2;
}
.control-group .messages {
position: absolute;
color: #D32F2F;
font-size: 0.85rem;
margin-top: 0.25rem;
}
.danger-color {
color: #D32F2F;
}
import { Component, OnInit } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
usernameControl!: FormControl<string>;
ngOnInit(): void {
this.usernameControl = new FormControl<string>(
'',
{
nonNullable: true,
validators: [Validators.required]
}
)
}
}
それをテストするには、コミュニティの慣習に従って、テストするファイルと一緒にファイルを作成し、次の名前を付けます: file-to-test-name.spec.ts。テスト ファイルを特定のフォルダーに分けることもできますが、最初はより整理されているように見えるためお勧めしませんが、アプリケーションが大きくなるにつれて、それらのファイルを別のフォルダーに置くと 2 つの主な問題が発生します。
- これらのファイルを見つけて開くのは困難です。
- ソース ファイルが削除されやすく、テスト ファイルが「生きている」ままになり、問題が発生します。
import '@testing-library/jest-dom';
describe('AppComponent', () => {
it('should render an input with a label', async () => {
});
});
骨格を崩す
注: 既に単体テストを作成した経験がある場合は、このトピックをスキップしてもかまいません。
describe関数の呼び出しにより、 AppComponentのいわゆるテスト スイートが作成されます。ここの文字列は、テスト スイートを説明するもので、何でもかまいません。私の好みは、テストしているクラスまたはものと同じ名前にすることです。次に、コールバック関数を渡します。ここにテスト ケースを記述します。
スイートのコールバック内で、テスト ケースの説明を指定してit関数を呼び出しました。単体テストの説明を記述する推奨される方法は、「<特定のシナリオでテストするために何かまたは動作を行う>」ことです。この形式で書くと、テストレポートで物事が一目瞭然になり、理解しやすくなります。
it関数には、コールバックも渡します。コールバック内では、AAA パターン (Arrange、Act、Assert) に従う単体テスト ロジックが記述されます。テストが同期の場合、コールバックの前にあるasyncキーワードは技術的には必要ありませんが、テスト ライブラリに記述されたほとんどすべてのテストには、awaitの使用を必要とする非同期呼び出しがあります。
テスト用のコンポーネントの構成とインスタンス化
多くのガイドは、最初はまとまりのない方法で物事を行い、後でより良い方法でリファクタリングするように教えています。このガイドでは、ポイントに直接進み、単体テスト ファイルを適切に整理する方法を示します。
最初のテストに関しては、ユーザー名の入力が画面に正しくレンダリングされているかどうか、およびそのタイプがテキストであるかどうかを確認したいだけです。
import '@testing-library/jest-dom';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { render, RenderResult, screen } from '@testing-library/angular';
import userEvent from "@testing-library/user-event";
import { AppComponent } from './app.component';
describe('AppComponent', () => {
const controlLabel: string = 'Username';
// this is not necessary since we are using 'screen', but you can use this variable instead of
// 'screen' to query DOM nodes and access the component instance
let renderResult: RenderResult<AppComponent, AppComponent>;
// Arrange, instead of writing this for each test, we use the hook
beforeEach(async () => {
renderResult = await render(
AppComponent,
{
imports: [
CommonModule,
ReactiveFormsModule
]
}
);
});
it('should render an input with a label', async () => {
// Arrange is done by the beforeEach hook, but sometimes you need additional things
// specific to the test to be arranged, so you include them here as well
// Act
// This test doesn't have an act (e.g. trigger event).
// Assert
const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});
expect(input).toBeInstanceOf(HTMLInputElement);
expect(input).toHaveAttribute('type', 'text');
});
});
テストコードの分解
AAA パターン:
- 配置: この部分では、DI (依存性注入) コンテナーの構成などを初期化するボイラープレートを作成し、関数の入力や引数など、テストの一部として使用される変数と定数を設定します。コンポーネントの入力と出力をテストしています。ボイラープレートと DI またはコンポーネントの構成/初期化は通常beforeEachフックに抽出されるため、テスト自体はより無駄がなくなり、ユース ケースに焦点を当てます。
- Act: これは、コンポーネントまたはオブジェクトに反応させたい部分であり、後で出力状態が正しいかどうかをテストします。ここで、クリック、データ入力、http リクエストの送信などのイベントを発生させます。
- アサート: 名前が示すように、これは、結果のコンポーネントの状態または関数呼び出しの結果が正しいかどうかを検証する部分です。これは期待エラーがスローされる部分であり、結果が期待どおりでない場合、テストは失敗します。
- テストに慣れていない場合は、テストを強化してコードをより適切に編成するために使用できるフック (ライフサイクル メソッドなど) がいくつかあります。
- beforeEach :テスト スイート ( describeで定義) で各it が実行される前に実行されます。コンポーネントと DI の初期化に使用されます。
- beforeAll : すべてが機能する前に、スイートに対して 1 回実行されます。
- afterEach : beforeEach の反対。各itの実行後に実行されます。クリーンアップ タスクに使用して、スイートでテストする残りのメモリ リークやガベージを回避します。1 つの使用例は、localstorage、sessionstorage、indexeddbなどを消去することです。
- afterAll : beforeAll の反対。すべてが機能した後、スイートに対して 1 回実行します。
テスト スーツの変数と定数:
- 1 つの定数と 1 つの変数を作成しました。
- 通常、テスト スイートで定数を使用して、スイートで何度も参照するものを格納します。1 つの例は、入力のラベル名です。その入力に対してさらにテストを作成すると、毎回ラベル テキストをコピー アンド ペーストする必要があることがわかります。そのため、ラベル テキストを定数に格納し、代わりに定数を参照します。ラベルが変更された場合、後でテキストを 1 か所で簡単に置き換えることができます。
- スイート レベルの変数は、 beforeEach で実行する初期化データを格納し、itおよびafterEachで参照するために使用されます。たとえば、コードで述べたように使用していないにもかかわらず、 renderResultを保存しました。
- DRYの原則を思い出してください。マジック ストリングや数字をコピー アンド ペーストする場合は、スイートの先頭に定数を作成します。
- ここで、Testing Library に、コンポーネントAppComponentをレンダリングするように指示します。
- 隔離された環境で実行されるため、構成されたNgModuleはありません。
- コンポーネントは「匿名」モジュール内で実行されるため、renderOptionsで構成する必要があります。
- AppComponentが必要とする依存関係 (主にReactiveFormsModuleとCommonModule ) を含むimports配列を作成したことに注意してください。
- 説明は、前述の推奨事項に従います。
- 内部には、追加の Arrange や Act はありません (後で例を示します)。
- Assert は非常に単純です。@testing-library/angularからインポートされたグローバルscreen変数を使用し、 getByLabelTextを呼び出してラベルに基づいて何かを検索するように要求するだけです。
- 画面上に同様のテキストのラベルがある場合 (オプションの exact: false により)、 for属性でラベルが指す HTML 要素を取得します。結果は入力要素です。
- このアプローチを使用すると、Testing Library は、意味的に正しい HTML を記述し、要素に正しいロールと ID 構成を使用することを強制します。高度な HTML カスタム コンポーネントでは、Aria ロールを使用してカスタム選択 (ドロップダウンとも呼ばれます) を定義できます。正しい仕様に従えば、それがネイティブ選択であるかのように扱われます。
- getで始まる画面オブジェクトのメソッドはすべて同期的であり、要素が見つからない場合はテストに失敗します。一方、接頭辞がfindの付いたメソッドは非同期で、promise を返します。
- また、 getByLabelTextで返された入力要素が実際にtext型であるかどうかをチェックすることで、テストをより厳密にしました。
基本をテストしたので、ユーザーが何らかのアクションを実行したときの入力の動作に関して、実際に実際のテストを行う必要があります。この単純なアプリケーションでは、少なくとも 4 つのテストを行う必要があります。
- FormControlが<input>タグに正しくバインドされているかどうかを確認します。
- FormControlバリデーターを正しく構成したかどうかを確認します。
- 入力が無効でタッチされたときに検証メッセージが表示されているかどうかを確認します (つまり、ユーザーが無効な入力を入力したか、この場合は何も入力しなかった後、フォーカスを別のものに変更しました —ぼかしイベント)。
- ユーザーがぼかしなしで有効な入力を入力したときに、検証メッセージがすぐに消えるかどうかを確認します。
describe('Username control', () => {
it.todo('should be correctly bind to the HTML input tag');
it.todo('should not accept empty values - required validator');
it.todo('should show the validation message if the input is invalid and was touched');
it.todo('should remove the validation message immediately upon user entered a not empty value');
});
次に、 appComponentスイート レベルでcomponentという新しい変数を導入します。これは、コンポーネント インスタンスへのアクセス方法を短縮するための単なる便利な変数です。
describe('AppComponent', () => {
const controlLabel: string = 'Username';
let renderResult: RenderResult<AppComponent, AppComponent>;
let component: AppComponent; // holds the component instance
beforeEach(async () => {
renderResult = await render(
AppComponent,
{
imports: [
CommonModule,
ReactiveFormsModule
]
}
)
// store the component instance
component = renderResult.debugElement.componentInstance;
});
// ...
});
.todoをit関数呼び出しに追加すると、Jest にテストを実行するつもりであることを伝えますが、今は実行しないので、テストを実行するたびに Jest は私たちを記憶します。to-do を実装したい場合は、.todoを削除し、コールバックの実装を追加する必要があります。そうしないと、エラーが発生します。
最初のテスト シナリオ
バインドを確認するために、入力にデータを挿入し、FormControl値が入力されたものに対応するかどうかを確認する簡単なテストを作成します。
it('should be correctly bind to the HTML input tag', async () => {
// Arrange
const user = userEvent.setup();
// Act
const userInputValue = 'Prophet_95';
const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});
await user.type(input, userInputValue);
// Assert
expect(component.usernameControl.value).toBe(userInputValue);
});
このテストでは、ユーザーの入力をシミュレートし、入力をクリアします。最後に、invalidとhasErrorが目的の状態にあるかどうかを確認します。
it('should not accept empty values - required validator', async () => {
// Arrange
const user = userEvent.setup();
// Act
const userInputValue = 'Prophet_95';
const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});
await user.type(input, userInputValue);
await user.clear(input);
// Assert
expect(component.usernameControl.invalid).toBe(true);
expect(component.usernameControl.hasError('required')).toBe(true);
});]
注: Validators.required の動作方法により、スペース (つまり ' ') の入力は有効な値として受け入れられます。それらを無効として扱うには、この記事の範囲外のカスタム バリデーターが必要です。詳細については、 Angular のドキュメントを参照してください。
3 番目のテスト シナリオ:
非常に単純です。ユーザーが入力にフォーカスするようにシミュレートし、値を挿入せずにそのまま (ぼかし) ます。後で画面にクエリを実行して、検証メッセージがあるかどうかを確認します。
it('should show the validation message if the input is invalid and was touched', async () => {
// Arrange
const user = userEvent.setup();
// Act
const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});
await user.click(input);
await user.tab();
// Assert
screen.getByText('Username is required.');
});
メッセージが消えるかどうかを確認するために、前のテスト ロジックを繰り返しますが、ユーザーは入力にフォーカスしてデータを入力します。
it('should remove the validation message immediately upon user entered a not empty value', async () => {
// Arrange
const user = userEvent.setup();
const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});
// Act 1
await user.click(input);
await user.tab();
// Assert 1
screen.getByText('Username is required.');
// Act 2
const userInputValue = 'Prophet_95';
await user.type(input, userInputValue);
// Assert 2
expect(() => screen.getByText('Username is required.')).toThrow();
});
注: 通常、同じitに複数の Act または Assert はありませんが、テスト コンテキストが適切な状態にあることを確認して、偽陰性を防ぐことができる場合があります。
テストのロジックを分解する
Testing Library を使用して DOM インタラクションの単体テストを作成する際に留意すべき非常に重要なことの 1 つは、ユーザーを念頭に置くことです。ユーザーとそのアクションをシミュレートするための特別なオブジェクトであるuserEvent (またはsetup呼び出しの後の結果) を使用しました。
userEvent は、実際のユーザーが実行できる一連のアクションを公開し、非常に直感的な命名スキームを備えています。ほとんどのテストで type メソッドを使用し、実際にユーザーが何かを入力することをシミュレートするキーボードの各キーストロークをシミュレートします。これが発生すると、実際の DOM が発行するすべての通常のイベントが発行されるため、他の多くのことをテストできます。また、clickとtabを使用しました。名前が示すように、HTML 要素をクリックしてキーボードの「tab」キーを押します。
最後に行ったのは、要素が見つからない場合にエラーがスローされることを期待することでした。そのために、およびカスタム エラーまたは例外がスローされることを予期するユニット テストでは、コールバック内にメソッド呼び出しをカプセル化する必要があります。 .
いつ、何をテストするか?
経験則として、テストしてはいけないものがあります。主に、単体テストを作成しない 3 つのケースがあります。
- 私たちが自分で実装しなかったものなので、サードパーティの動作はテストされていません。1 つの例は、ユーザーがデータを入力したときにユーザー名FormControlに設定した必要なバリデーターが呼び出されるかどうかをテストすることです。これは、Angular チームによって既にテストされたサードパーティのコードと動作です。
- 自分で実装したにもかかわらず、他のファイルまたは他のクラス、コンポーネント、パイプなどに存在するもの。テストは現在のコンテキストのみに焦点を当て、必要に応じて依存関係をモックする必要があります。
- プライベート メンバーまたは実装の詳細。これらのテストは、80% の確率で役に立ちません。実装が変更された場合は、将来的にリファクタリングする必要があります。パブリック API とユーザーが操作するもののみをテストする必要があります。
- 例外 1: 構築したものが、基盤として機能するコード、または抽象クラスなどの API または SDK の一部であり、ロジックを使用して保護されたメソッドが含まれている場合。これらの保護されたメソッドをテストして、予想される動作が正しいかどうかを確認する必要があります。今後のリリースでは、不要な破壊的変更を導入しません。
- 例外 2: 重要な計算やビジネス ルールなど、非常に複雑なロジックを実行するプライベート メソッド (実装の詳細) があるシナリオ。次に、ロジックが正しいことを保証するために、いくつかの単体テストを書きたいと思うかもしれません。これは、何が複雑/重要であるかどうかを判断するために慎重に行う必要があります。
- ユーザーが通常 UI で実行するクリティカル パス (DOM テスト)。
- コンポーネント、サービス、パイプ、インターセプターなどからのパブリック API またはメソッド。
- UI であれ API であれ、一般的なエッジ ケース。
結論
Testing Library は多くのボイラープレート ロジックを抽象化し、UI テストを記述するためのよりクリーンな方法を提供すると同時に、考え方をプログラムによるテストからユーザーの視点にシフトします。API (サービス、パイプなど) の通常の単体テストでは、Jest を使用しているため、開発者は Jasmine とは対照的に優れたサポートの恩恵を受け、Karma よりも優れたレポートを得ることができます。
これで、Angular アプリを使用したライブラリのテスト シリーズの最初の記事は終了です。次の記事では、入力/出力をカバーする複雑なユース ケースについて説明し、アクセシビリティ ロールを使用して構築された小さな UI ライブラリをテストします。また、将来的には、非同期コード (つまり、Observables) と http リクエストのテストについての特別な記事が取り上げられる予定です。
貢献したいことがある場合、間違いを見つけた場合、またはこの記事に書かれていることについて議論したい場合は、お気軽にコメントを共有するか、私に連絡してください。喜んで話し合います。

![とにかく、リンクリストとは何ですか?[パート1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































