JestとAngularで始めるテストの第一歩

# Angular のテスト環境

Angular のテストでは、Jasmine と Karma を使うのが一般的です。
Angularの公式ドキュメントにも、その組み合わせでテスト環境を構築する手順が記載されています。

* Jasmine
    * JavaScript のテストフレームワーク
    * テストの記述（構文やアサーション）を提供する
* Karma
    *  Google が開発した JavaScript のテストランナー
    *  Jasmine や Mocha などのテストフレームワークと併用される

https://angular.jp/guide/testing

ところが、 現在 Karma は以下の理由により非推奨となっています。
> The web testing space has evolved significantly in the 10+ years since Karma's creation. The web landscape looks very different today and new patterns and tools have emerged in the ecosystem. New test runners offer more performant alternatives, and Karma no longer provides clear unique value.
> Based on the current state of the web testing ecosystem, we have made the hard decision to deprecate Karma.
> We know Karma is used particularly commonly in the Angular ecosystem, so Angular is adding Jest and Web Test Runner support to provide a migration path off of Karma. See the Angular blog for more details.
> 
> 引用：https://github.com/karma-runner/karma?tab=readme-ov-file#karma-is-deprecated-and-is-not-accepting-new-features-or-general-bug-fixes

どうやらウェブテスト環境の進化によって Karma 独自の価値が失われたことが理由みたいです。
そこで Karma の代わりとして推奨されているのが Jest です。

## Jest
Jest は、Facebook（現 Meta）が開発した JavaScript のテストライブラリです。

https://jestjs.io/ja/

次のような特徴があります。
* 軽量
* Karmaと違いブラウザを起動する必要がない
* 初期設定が容易
* 充実したモック機能
* Angular, React, Vue など幅広いサポート


Jest は、Angular 16 から試験的にサポートされるようになりましたが、18の時点でもまだ正式なサポートには至っていません。

https://blog.angular.dev/moving-angular-cli-to-jest-and-web-test-runner-ef85ef69ceca

とはいえ現時点でも十分使用できますので、今後のことを考えて Jest を選ぶのは全然有りだと思います。


# Angualr で Jest を使うには
Karma を使用している場合は Karma を削除したり、angular.json の設定を変更したりする必要があります。
詳細は以下の記事をご参照ください。

https://medium.com/ngconf/configure-jest-in-angular-18-79765fdb0fae

https://qiita.com/ringtail003/items/c396838476056792253d

↑こちらの記事では Angular Testing Library というテストを書きやすくするライブラリも合わせて導入しています。
このライブラリは Jasmine や Jest に依存していないため、どちらでも気軽に使うことができます。
非常に便利なため、導入することを強くおすすめします。
本稿でも Angular Testing Library を使います。

https://testing-library.com/docs/angular-testing-library/intro/


# Jest を使ってみる
以下のコンポーネントを例にして、 Jest を使ったテストを作成してみましょう。
これ自体は2つの数値を入力してボタンをクリックすると足し算した結果が表示されるだけのものです。

```typescript:calculator.component.ts
import { Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  standalone: true,
  imports: [
    FormsModule,
  ],
  selector: 'calc',
  template: `
    <div class="container">
        <h1>Calculator</h1>
        <input type="number" [(ngModel)]="val1" placeholder="1つ目の数を入力" />
        <span>+</span>
        <input type="number" [(ngModel)]="val2" placeholder="2つ目の数を入力" />
        <button (click)="sum()" [disabled]="disabled">計算する</button>
        <div id="result">結果: {{result}}</div>
    </div>
  `
})
export class CalculatorComponent {
  @Input() disabled: boolean;

  val1: number;
  val2: number;
  result: number;

  sum() {
    this.result = this.val1 + this.val2;
  }
}
```

まずはテストファイル（`.spec.ts`）を作成します。


```typescript:calculator.component.spec.ts
import { render, screen } from '@testing-library/angular';
import { CalculatorComponent } from 'app-ng/src/app/common/components/calculator.component';

// 1
describe('CalculatorComponent', () => {
  // 2
  test('should pass this basic test', async () => {
    const result = 1 + 2;
    // 3
    expect(result).toBe(3);
  });
});
```

`describe` `test` `exect` は Jest の関数です。

:::note info
テストファイルでは、Jest はそれぞれのメソッドとオブジェクトをグローバル環境に配置します。 それらを使用するために require または import する必要はありません。
:::



#### 1. `describe(name, fn)`
いくつかの関連するテストをまとめるのに使用します。
必須ではないため、省略することもできます。

https://jestjs.io/ja/docs/next/api#describename-fn

#### 2. `test(name,fn, timeout)`
テストメソッドです。
これは必須です。必ず一つは `test` を実装する必要があります。
```typescript
import { render, screen } from '@testing-library/angular';
import { CalculatorComponent } from 'app-ng/src/app/common/components/calculator.component';

// describe を使わずに直接 test を書くこともできます
test('should pass this basic test', async () => {
  ...
});
```

https://jestjs.io/ja/docs/next/api#testname-fn-timeout

#### 3. `expect`
アサーション関数です。
実際の値が期待する値と一致しているかどうかを確認するために使います。

#### よく使うアサーションの一覧
<table>
  <thead>
    <tr>
      <th>関数名</th> <th>説明</th>
    </tr>
  </thead>
  <tr>
    <td> toBe(expected) </td>
    <td>
      <div>厳密な一致を確認する。（値と型が一致している必要がある）</div>
      <div>例：</div>
      <div>expect(1 + 2).toBe(3);</div>
    </td>
  </tr>
  <tr>
    <td>
      <div>toBeNull()</div>
    </td>
    <td>
      <div>値が null であるか確認する。</div>
    </td>
  </tr>
  <tr>
    <td>
      <div>toBeUndefined()</div>
    </td>
    <td>
      <div>値が undefined であるか確認する。</div>
    </td>
  </tr>
  <tr>
    <td> toEqual(expected) </td>
    <td>
      <div>オブジェクトや配列の値が等価であるかを確認する。</div>
      <div>例：</div>
      <div>const data = { name: 'John', age: 30 };</div>
      <div>expect(data).toEqual({ name: 'John', age: 30 });</div>
    </td>
  </tr>
  <tr>
    <td>
      <div>toBeTruthy()</div>
      <div>toBeFalsy()</div>
    </td>
    <td>
      <div>真偽値を確認する。</div>
      <div>例：</div>
      <div>expect(true).toBeTruthy();</div>
      <div>expect(false).toBeFalsy();</div>
    </td>
  </tr>
  <tr>
    <td>
      <div>toBeGreater[Less]Than(expected)</div>
      <div>toBeGreater[Less]ThanOrEqual(expected)</div>
    </td>
    <td>
      <div>数値を比較する。</div>
      <div>例：</div>
      <div>expect(10).toBeGreaterThan(5); // 10 > 5</div>
      <div>expect(10).toBeGreaterThanOrEqual(10); // 10 >= 10</div>
      <div>expect(10).toBeLessThan(15); // 10 < 15</div>
      <div>expect(10).toBeLessThanOrEqual(10); // 10 <= 10</div>
    </td>
  </tr>
  <tr>
    <td>
      <div>toContain(expected)</div>
    </td>
    <td>
      <div>配列や文字列の中身を確認する。</div>
      <div>例：</div>
      <div>const fruits = ['apple', 'banana', 'grape'];</div>
      <div>// 配列に 'banana' が含まれている</div>
      <div>expect(fruits).toContain('banana');</div>
      <div>// 文字列に 'world' が含まれている</div>
      <div>expect('hello world').toContain('world');</div>
    </td>
  </tr>
  <tr>
    <td>
      <div>toThrow</div>
    </td>
    <td>
      <div>エラーであることを確認する。</div>
      <div>例：</div>
      <div>const throwError = () => {</div>
      <div>　throw new Error('This is an error');</div>
      <div>};</div>
      <div>// エラーが投げられることを確認</div>
      <div>expect(throwError).toThrow();</div>
      <div>// エラーメッセージも確認</div>
      <div>expect(throwError).toThrow('This is an error');</div>
    </td>
  </tr>
</table>

https://jestjs.io/ja/docs/expect
    
## DOM のテスト
数値を渡して[計算する]ボタンをクリックし、結果が正常に表示されることを確認します。

```typescript:calculator.component.spec.ts
import { fireEvent, render, screen } from '@testing-library/angular';
import { CalculatorComponent } from 'app-ng/src/app/common/components/calculator.component';

describe('CalculatorComponent', () => {
  test('should render the result as 3', async () => {
    // 1. テストテンプレート上で対象のコンポーネントを呼び出す
    await render(`<calc [a]="1" [b]="2"></calc>`, {
      imports: [
        CalculatorComponent,
      ],
    });
    
    // 2. [計算する]ボタンをクリックする
    fireEvent.click(screen.getByRole('button'));

    // 3. <p>タグに表示される文字列を確認する
    expect(screen.getByRole('paragraph').textContent).toContain('結果: 3');
  });
});
```

実行結果
![スクリーンショット 2024-12-11 15.06.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620651/722ed271-a36a-4bd7-2d2d-095f3599594f.png)


`render` `fireEvent` `screen` は Angular Testing Library のメソッドです。

#### 1. `render`
テストアプリのセットアップを行うための関数です。
上記の例では、第一引数にテンプレート文字列を渡しており、そこでテスト対象のコンポーネントを呼び出しています。
イメージとしてはテストメソッドごとに専用のコンポーネントを作成するような感じです。
ここでは `<calc>` を実行するだけのコンポーネントを作成しています。
この方法だと実際の使われ方と同じようにテストできるため、テンプレート方式を推奨しています。
```typescript
await render(`<calc [a]="1" [b]="2"></calc>`, {
      imports: [
        CalculatorComponent,
      ],
    });
```

第二引数にはオプションを設定します。
例では、テスト対象のコンポーネントは standalone のため、`render`関数の第二引数で `imports` プロパティにテスト対象を追加しています。

https://testing-library.com/docs/qwik-testing-library/api/#render


#### 2. `fireEvent`
DOMイベントを発火させるための関数です。
上記の例では、テスト対象のコンポーネントの[計算する]ボタンのクリックイベントを発火しています。

https://testing-library.com/docs/dom-testing-library/api-events/#fireevent

#### 3. `screen`
レンダリングされたテスト対象のDOMツリーにアクセスするためのグローバルオブジェクトです。
DOMにクエリ（要素を検索する等）を実行する際に使います。
上記の例では、`screen.getByRole('button')` と `creen.getByRole('paragraph')` でそれぞれ[計算する]ボタンと結果を表示する `<p>` タグへアクセスしています。
クエリの詳細は公式ドキュメントをご参照ください。

https://testing-library.com/docs/queries/about/#screen

 
基本的にこの３つの関数を使用してテストを作成していきます。

## `@Input` のテスト
先ほどのテストは `@Input` のテストも兼ねていました。
`render` の第一引数にテンプレートを渡すと、そのまま実際の使い方と同じように `@Input` に値を設定できます。

```typescript
describe('CalculatorComponent', () => {
  test('should render the result as 3', async () => {
    // 1. テストテンプレート上で対象のコンポーネントを呼び出す
    await render(`<calc [a]="1" [b]="2"></calc>`, {
      imports: [
        CalculatorComponent,
      ],
    });
    ...
  });
});
```

## `@Output` のテスト
`CalculatorComponent` に計算結果を返すアウトプットを追加して、テストを書いてみます。

```typescript:calculator.component.ts
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  standalone: true,
  imports: [
    FormsModule,
  ],
  selector: 'calc',
  template: `
    <div class="container">
        <h1>Calculator</h1>
        <input type="number" [(ngModel)]="val1" placeholder="1つ目の数を入力" />
        <span>+</span>
        <input type="number" [(ngModel)]="val2" placeholder="2つ目の数を入力" />
        <button (click)="sum()" [disabled]="disabled">計算する</button>
        <p>結果: {{result}}</p>
    </div>
  `
})
export class CalculatorComponent implements OnInit {
  @Input() a: number;
  @Input() b: number;
  @Input() disabled: boolean;
  @Output() calculated = new EventEmitter<number>();

  val1: number;
  val2: number;
  result: number;

  ngOnInit() {
    this.val1 = this.a;
    this.val2 = this.b;
  }

  sum() {
    this.result = this.val1 + this.val2;
    this.calculated.emit(this.result);
  }
}
```

```typescript:calculator.component.spec.ts
  test('should emit (calculated) on "Calculate" button click', async () => {
    // モック関数を作成する
    const onCalculatedSpy = jest.fn();
    await render(CalculatorComponent, {
      inputs: {
        a: 1,
        b: 9,
      },
      on: {
        calculated: onCalculatedSpy
      }
    });

    // [計算する]ボタンをクリックする
    fireEvent.click(screen.getByRole('button'));

    // モック関数が正しい引数で呼び出されたことを確認
    expect(onCalculatedSpy).toHaveBeenCalledWith(10);
    // モック関数の呼び出し回数をを確認
    expect(onCalculatedSpy).toHaveBeenCalledTimes(1);
  });
```

### モック関数 `jest.fn()`
Output に設定した関数の振る舞いを監視するには、モック関数を使います。
モック関数を作成するには、`jest.fn()` を使用します。

まずは[計算する]ボタンのクリック後に実行される関数をモック化します。
```typescript
const onCalculatedSpy = jest.fn();    
```
作成したモック関数をコンポーネントに設定します。
```typescript
await render(CalculatorComponent, {
  inputs: {
    a: 1,
    b: 9,
  },
  on: {
    calculated: onCalculatedSpy
  }
}); 
```

[計算する]ボタンを押下後にモックの振る舞いを確認します。
```typescript
// モック関数が正しい引数で呼び出されたことを確認
expect(onCalculatedSpy).toHaveBeenCalledWith(10);
// モック関数の呼び出し回数をを確認
expect(onCalculatedSpy).toHaveBeenCalledTimes(1);
```

Jestは、モック関数を容易に作成できることが特徴のひとつです。
そのほかの詳細は公式ドキュメントをご参照ください。

https://jestjs.io/ja/docs/next/mock-function-api

## スナップショットテスト
> スナップショットのテストはUI が予期せず変更されていないかを確かめるのに非常に有用なツールです。
>
> 引用：https://jestjs.io/ja/docs/snapshot-testing

Jest ではスナップショットのテストを簡単に書くことができます。

```typescript
  test('renders correctly', async () => {
    const { container } = await render(`<calc [a]="1" [b]="2"></calc>`, {
      imports: [CalculatorComponent]
    });

    expect(container).toMatchSnapshot();
  });
```

このように `toMatchSnapshot()` を実行すると、同じフォルダに `__snapshots__`フォルダが作成されて、その中にスナップショットが作成されます。

```typescript:calculator.component.spec.ts.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CalculatorComponent renders correctly 1`] = `
<div
  id="root2"
>
  <calc>
    <div
      class="container"
    >
      <h1>
        Calculator
      </h1>
      <input
        class="ng-untouched ng-pristine ng-valid"
        placeholder="1つ目の数を入力"
        type="number"
      />
      <span>
        +
      </span>
      <input
        class="ng-untouched ng-pristine ng-valid"
        placeholder="2つ目の数を入力"
        type="number"
      />
      <button>
        計算する
      </button>
      <p>
        結果: 
      </p>
    </div>
  </calc>
</div>
`;

```
これでUIに予期せぬ変更が発生した場合、スナップショットのテストでエラーになります。

:::note info
生成されるスナップショットはコードの変更に追随し、コミットしてコードレビューの対象に含めましょう。
:::


### スナップショットを更新する
バグによりスナップショットテストが失敗したときは、原因箇所を修正し、再びスナップショットテストが成功することを確認しましょう。
以下の画像はスナップショットテストに失敗した結果です。

![snapshot_failed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620651/dae8014a-dc82-6cfa-753a-3e976fa20460.png)



バグではなく、意図的な仕様変更によりスナップショットテストが失敗する場合はスナップショットを更新する必要があります。

```terminal
jest --updateSnapshot
```
上記のコマンドを実行すると、失敗するすべてのスナップショットが再生成されます。
特定のスナップショットだけ再生成する場合は以下のコマンドを実行します。
```terminal
jest --updateSnapshot --testNamePattern=[regex]
```

実行結果

![udpate_snapshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620651/32053623-3a3f-2493-22af-cadbb7e2e6a6.png)

スナップショットファイルが更新されます。

![updated_snapshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620651/bf7a8ff5-5aa3-4029-4aa4-6d58747906a5.png)

### 注意点
1. スナップショットの乱用を避ける
スナップショットテストは便利ですが、すべてのケースで使用するのは適切ではありません。大規模なスナップショットは、コードレビュー時に変更内容を理解しづらくする可能性があります。スナップショットは小規模で簡潔に保つべきです。
　
2. アサーションベースのテストとのバランス
スナップショットテストは従来のアサーションベースのテストを完全に置き換えるものではありません。特定のビジネスロジックや動的な動作を確認する場合は、従来のテストが適しています。
　
3. スナップショットの更新が簡単すぎる
スナップショットを更新するプロセスが簡単であるため、開発者が十分な検証を行わずに更新してしまうリスクがあります。このため、スナップショットの更新が意図的かつ正当であるかをコードレビューで確認することが重要です。
　
4. 変更の意図を明確にする
スナップショットに変化が生じた場合、それが意図的な変更なのか、あるいは意図せずに生じたものなのかを見極める必要があります。これにより、予期しないバグを防ぐことができます。

# おわりに
概要は簡単なものになってしまいましたが、少しでも興味を持っていただけるきっかけになれば幸いです。
Jestは日本語ドキュメントも充実しているので、詳細を知りたい方はぜひリンク先をご参照ください。
今回書ききれなかった非同期やもっと複雑なケースについても、いつかまとめたいと思います。

<br>
<br>

参考
Setup Jest in Angular 18
https://medium.com/ngconf/configure-jest-in-angular-18-79765fdb0fae
Angular After Tutorial#3-1.コンポーネントのテスト
https://zenn.dev/lacolaco/books/angular-after-tutorial/viewer/testing-component
Angular 18 Testing
https://v18.angular.dev/guide/testing
Jest
https://jestjs.io/ja/docs/getting-started
angular-testing-library
https://testing-library.com/docs/