Angularテスト備忘録

Angularの基本的なテスト実装について解説する。

フレームワークは、KarmaとJasmineを使用する。

テストの起動

npm test

npm run <script> で、グローバルの設定に依存せず、開発フォルダのpackage.jsonに記載されているscript欄のコマンドを実行する。

install、test、startは、script欄に無くても実行できる。

テストは、カバレッジの計測など、独自のオプションを付与するため、package.jsonのscript欄に以下のようなコードを書いてカスタマイズしているケースが少なくない。

"test": "npm run test --code-coverage"

このとき、コンソールでは、npm test を打つとデフォルトのテスト、npm run testでは、上記のカスタマイズされたテストが実行される。

この違いが分かるようにしておかないと、テストの品質を意図せず損なう場合があるので注意すること。

ファイル単位でのテストの起動

includeオプションを使用すると、ファイル単体のテストを実行できる。

npm run test -- --include src/[filePath]/….spec.ts

オプション付与がうまくいかない場合は、シンプルなng testを呼びだして直接オプションを付与する以下のコマンドを検討する。

npx ng test --include src/[filePath]/….spec.ts

HTTPクライアントのテスト

公式サイトを見れば基礎的な内容はすべて書かれている。参考:Angular_リクエストのテスト

ポイントは、HttpTestingControllerを使用することで、通常のバックエンドではなく、テスト用のバックエンドにアクセスするようになること。

あとはこのテストコントローラーをどのように扱うか理解していれば実装できる。

flushとは何なのか

テストコードを書く上で一番理解しにくいのが、どのタイミングでHTTPクライアントの通信が実行されるのか?

通常のメソッドであればsubscribeやlastValueFromを呼び出したタイミングで実行されるが、テストの場合は厳密にはそうではない。

テストでは、リクエストの送信は通常通りのタイミングで行われるが、HTTPクライアントがレスポンスを返すのは、flushを呼び出すタイミングとなる。

リクエストをHttpTestingControllerに詰め込んで、flushで一斉にレスポンスをかえすイメージだ。

これがわかると、テストコードが書けるようになる。

テストの実装

HTTPリクエストで取得した文字列を2倍に連結して返すメソッドをテストする。

■service.ts(テストしたいメソッド)

sampleFnc(): Observable {
  const res = this.http.get('API_URL').pipe(
    map((res) => {
      retrurn res + res;
    })
  )
}

■service.spec.ts(テストコード)

flushに着目して、HTTPクライアントテスト時の通信タイミングを整理してみる。

// 事前準備
beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [
      CompNameService,  // テストで使用するサービス
      // その他テストで使用するクラスを記述
    ],
    imports: [HttpClientTestingModule]
  }).compileComponents();
  // DI構成
  service = TestBed.inject(CompNameService);
  httpTesting = TestBed.inject(HttpTestingController);
});

// テスト
it('sampleFnc_SUCCESS', () => {
  // テストするメソッドのレスポンスを構成する
  const res = lastValueFrom(service.sampleFnc()); // …①
  // この時点で、HTTPクライアントに、リクエストは設定されるが、レスポンスはflushするまで返ってこない。
  // HTTPリクエストの検証をするために、expect(検証内容)より前にHTTPクライアントにリクエストを設定しておく必要がある。
  
  // httpTestingとexpectを使用して、検証する内容を構成する
  const req = httpTesting.expectOne({  // HTTPクライアントにリクエストを送信する…②
    url: 'API_URL',
    method: 'GET'
  });
  res.then((resStr) => {  // テストするメソッドから返されたres(…①)はPromiseなので、thenで値を取り出す…③
    expect(resStr).toEqual(resMock + resMock);  // resから取得した文字列が、テストメソッドの処理を通し、resMockを2倍に連結した値になっていることを確認する
  })
  
  // flushを実行することで、HTTPクライアントがレスポンスを返す。
  // flushの引数にオブジェクトを指定すると、 リクエスト構成に対し任意のレスポンスを設定できる
  resMock = 'テストレスポンス';  // "HTTPリクエスト"が返すレスポンスのMockを作成する
  req.flush(resMock); // HTTPリクエスト(…②)の返すレスポンスをここで設定する。③の検証は、このタイミングで実行される。

  httpTesting.verify(); // 未処理のリクエストが無いことを確認する
});

voidのテスト

■service.ts(テストしたいメソッド)

sampleFnc(): Observable {
  return this.http.get('API_URL');
}

■service.spec.ts(テストコード)

it('sampleFnc_SUCCESS', () => {
  const res = lastValueFrom(service.sampleFnc());  //この時点ではリクエストは実行されていない
  
  const req = httpTesting.expectOne({
    url: 'API_URL',
    method: 'GET'
  });
});

voidの場合、リクエストの検証ができればよいということであれば、flushする必要すら無い。

service.sampleFnc()を記述したタイミングで、httpTestingにリクエストがセットされているためである。

flushを記述するのは、レスポンスを検証したいから。

ただし、このとき、httpTesting.verify()を最後に記述するとエラーになるため注意する。

このメソッドはHTTPクライアントに、未完了のリクエストが無いかを検証しているので、レスポンスを返していない状態ではエラーとなる。

voidのレスポンスを検証したい場合は、リクエストの返却は以下のようにする。

req.flush({});

catchErrorのテスト

■service.ts(テストしたいメソッド)

sampleFnc(): Observable {
  return this.http.get('API_URL').pipe(
    catchError((err) => {
      return of(err);
    })
  )
}

■service.spec.ts(テストコード)

catchError内の処理が実行されているかを検証するには、flushで、レスポンスにエラーを設定する。

it('sampleFnc_SUCCESS', () => {
  const res = lastValueFrom(service.sampleFnc()).catch;  // エラーをキャッチする
  
  const req = httpTesting.expectOne({
    url: 'API_URL',
    method: 'GET'
  });
  
  // flushの引数で、レスポンスにエラーを設定する
  req.flush('Failed!', {status: 500, statusText: 'Internal Server Error'});
  expect(res).toThrowError;  // エラーをスローしていることを検証する
});
httpTesting.verify();

ストア・クエリのテスト

■service.ts(テストしたいメソッド)

ストアに正しく値を設定できているかを確認したい。

setParam(val :string): Observable { 
  this.store.update({
    storeParam: val
  })
}

■service.spec.ts(テストコード)

describe('テストグループ名', () => {
  let serviceName: ServiceName;
  let serviceName: QueryName;
  let serviceName: StoreName;
  
  // 事前処理
  beforeEach(() => {
    TestBed.configureTestringModule({
      providers: [
        ServiceName,
        QueryName,
        StoreName
      ],
      imports: [HttpClient TestingModule]
    });
    service = TestBed.inject(ServiceName);  // サービスをテストモジュールに設定
    query = TestBed.inject(QueryName);  // クエリをテストモジュールに設定
    store = TestBed.inject(StoreName);  // ストアをテストモジュールに設定
    // デフォルトとして用意しているテストデータがあれば以下記述
    const masterMock = TestBed.inject(MasterDataMockService);
    masterMock.initMockData();
  });
  
  // テスト実施
  it('テスト名', () => {
    const inputParam = new InputParam();
    inputParam.param = 'sample'; // テストしたいデータを設定
    serviceName.setParam(input Param); // テストしたいメソッド(setParam)を呼び出し>storeに格納されるので、queryから取得できるようになる
    expect(query.getValue().inputParam.param).toBe('sample'); // クエリから取得したいが期待値と一致するか確認する
  });
}

Cookieのテスト

ブラウザに設定されている’sample’という名前のcookieを取得し、リクエストヘッダーに設定するリクエストをテストしたい。

■service.ts(テストしたいメソッド)

sampleFnc(): Observable {
  const httpOptions = {
    headers: new HttpHeaders({
      cookieSample: this.cookieService.get('sample');
    })
  }
  return this.http.get('API_URL')
}

■service.spec.ts(テストコード)

// 事前にCookieを設定する
beforeEach(() => {
  TestBed.configureTestingModule({
      providers: [
        CookieService,
        …
      ],
      …
  }).compileComponents();
  …
  cookieService = TestBed.inject(CookieService);
  cookieService.set('sampleCookie', 'sampleCookieVal');
})

it('sampleFnc_getCookie_SUCCESS', () => {
  lastValueFrom(service.sampleFnc());
  
  const req = httpTesting.expectOne({
    url: 'API_URL',
    method: 'GET'
  });

  // beforEachで設定したCookieを、ヘッダーに設定できているかを確認する
  expect(req.request.headers.get('sample')).toEqual('sampleCookieVal');
});

インシデント

テスト起動時エラー

エラーメッセージ

ChromiumHeadless has not captured

OSがChromeHeadlessを認識できていない。

ChromiumHeadlessとは?

ChromiumHeadlessは、Chromeのインターフェースを起動せずに、バックグラウンドでブラウザを実行する機能。

Chromeに同梱されている。

その分処理も軽くなるので、npm testなど、テストでは基本的にこちらがデフォルトの設定となっている。

エラーの原因調査

2パターンあって、Chromeのパス(環境変数)が通っていないだけのケースと、ChromeHeadlessの不具合。

まず、切り分けのために、karma.conf.jsの以下のプロパティを切り替えて、ChromeHeadlessではなく、Chromeで起動するように変更する。

変更前)browsers: ['ChromeHeadless']
変更後)browsers: ['Chrome']

この設定をすることで、ブラウザが起動してテストをコントロールする仕様に切り替わる。

改めてテストを実行し、ブラウザが立ち上がるのであれば、Chromeを認識できている、つまり、環境変数の問題ではない。

ブラウザが立ち上がらない場合は、そもそももChromeを認識できていないので、環境変数が通っていない。

環境変数が通ってないだけのケース

Google Chromeを右クリック>プロパティ

リンク先の実行ファイルがどこにあるか確認。

Windowsの設定を開く。

環境変数>「Path」を編集

先ほどのChromeのパスを追加する。

ChromeHeadlessの不具合の場合

こちらはなかなか厄介。

2023年以降、Chromeはheadlessモードの実装を刷新しており、nodeやkarmaを古いバージョンで使用している場合、特にWindowsでは、セキュリティソフトの関係もあり、不具合が起こりやすい。

(僕の場合は、クライアントの指定で古いバージョンのnodeを使用していたため、不具合が起きたと思われる)

以下のように、ChromeHeadlessへのブラウズ方法をカスタムすることで解消できる。

module.exports = function (config) {
  config.set({
    browsers: ['ChromeHeadlessCustom'],
    customLaunchers: {
      ChromeHeadlessCustom: {
        base: 'ChromeHeadless',
        flags: [
          '--no-sandbox',
          '--disable-gpu',
          '--disable-dev-shm-usage'
        ]
      }
    }
  });
};
  • –no-sandbox → Windows や権限制限のある環境で必須になることが多いです。
  • –disable-gpu → GPU 絡みの初期化失敗を防ぎます。
  • –disable-dev-shm-usage → Linux 系での共有メモリ不足対策ですが、Windows でも安定性が増すことがあります。

Chrome は普通のブラウザ起動なので、sandbox や headless 特有の制約を受けない。

Appendix

テストの設定

karma.conf.jsで設定する。

--code-coverage: カバレッジも出力

singleRunとautoWatch

テストが起動された際、1回実行するだけか、起動し続けて変更を視し、変更があった場合に再テストする状態にするかを設定する。

この2つは機能が似ていて、正直違いを詳しく知る必要もないので、組み合わせで覚える。

  • 1回実行するのみ:singleRun: true、 autoWatch: false
  • 継続して監視&自動的に再テスト(デフォルト): singleRun: false, autoWatch: true

小規模開発では、テストを常に動かしたまま開発することが多い。

外部API・フレームワークのMock

郵便番号からの住所の取得や、QRコードの生成など、テストできない外部機能はMockを事前に作成してレスポンスを指定しておく。

以下、①はActivatedRouteをMock化してDI、②はHTMLCanvasElementが呼ばれたときにMockを返す方法。

beforeEach(() => {
  const routerSpyObj = jasmine.createSpyObj('ActivatedRoute', ['snapshot']); // …①
  const mockCanvas = jasmine.createSpyObj('HTMLCanvasElement', ['getContext']); // …②ダミーのHTMLCanvasElementクラスを作成。このクラスはgetContextメソッドを持つ
  …
  TestBed.configureTestingModule({
    providers: [
      { provide: ActivatedRoute, useValue: routerSpyObj }, …①
      …
    ]
  }).compileComponents();
  routerSpy = TestBed.inject(ActivatedRoute) as jasmine.SpyObj; // …①ActivatedRouteはMockとしてDIしておく
  sptyOn(document, 'createElement').and.returnValue(mockCanvas);  // …②documentクラスのcreateElementメソッドが呼ばれたときには、mockCanvasを返却する
})

テストのデバッグ方法

以下の設定を加える。

■karma.conf.js

module.exports=function(config){
  config.set({
    …
    autoWatch: false,
    browsers: ['Chrome'],
    singleRun: false
  })
}

■package.json

{
  "scripts": {
    "test": "--source-map=true"
  }
}

コンソールで以下コマンドでテストを起動すると、ChromeブラウザでKarmaが起動する。

npm run test

あとは通常のデバッグと同様に、Chromeの開発者コンソールでtestファイルを検索し、ブレークポイントを仕掛ける。

意図的に例外を発生させる

以下のコードを仕込めば、その行でエラーを発生させてデバッグできる。

throw new Error("エクセプション");

IEモードで開発者ツールを使用する

参考:EdgeのIEモードの画面に対して開発者ツール(F12)を使う方法

用語集

  • Jasmine:テストコードを効率よく記述するためのフレームワーク。Seleniumもこのレイヤーに位置する。
  • Karma:テストランナー。テストコードをブラウザで起動して(この理由から、テスト中はchrome.exeのコンソールが開く)、レポートする。

 

 

コメント

タイトルとURLをコピーしました