とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

Cypress と reg-cli で Visual regression test を試す

はじめに

ウェブフロントエンドアプリケーションを開発しているといくらユニットテストを書いていても、変更に不安を覚えるものがある。代表的なものがスタイルの変更かと思う。 このスタイルの変更にも安心感を与えてくれるテストの一つが Visual regression test と考え、今回は Cypress と reg-cli を使って試してみた。

アプリケーションの用意

Angular v16.0.x 系でアプリケーションの用意をする。これを試したときには v16.1.x で Cypress が動かない不具合があったのでこのバージョンにしている。 下記がリリースされれば、最新バージョンで問題ないと思う。

feat: support Angular 16.1 by leosvelperez · Pull Request #27030 · cypress-io/cypress · GitHub

Cypress の導入

自動テストを実行し、スクリーンショットを取るためのツールとして Cypress を導入する。 Cypress を選択したのにはあまり大きな意味はないが、公式ドキュメントに Angular 専用のページがあったりするため親和性が高いだろうと判断した。

導入は下記ページを参考におこなった。

Angular Component Testing | Cypress Documentation

  • 下記コマンドで Cypress をインストール
    • npm i cypress -D
  • npx cypress open を実行し GUI で設定ファイルを生成
    • Component Testing の方を選択し、設定を進めた

app.component.cy.ts を以下のように用意し、テストを実行してみる。

// app.component.cy.ts

import { AppComponent } from "../src/app/app.component";

describe('app.component.cy.ts', () => {
  it('playground', () => {
    cy.mount(AppComponent)
    cy.screenshot();
  })
})

テスト実行の際に、デフォルトだと E2E のテストを探してしまうので --component オプションを付けて実行する。

npx cypress run --component

これで対象のコンポーネントスクリーンショットが撮れる。

reg-cli の導入

続いて画像比較をするために reg-cli を導入する。

GitHub - reg-viz/reg-cli: 📷 Visual regression test tool.

  • 下記コマンドで reg-cli をインストール
    • npm i -D reg-cli

README にあるように reg-cli /path/to/actual-dir /path/to/expected-dir /path/to/diff-dir -R ./report.html で画像比較ができるので、これにあうように Cypress で撮ったスクリーンショットを配置する。 今回は以下のコマンドで画像比較できるようにそれぞれのディレクトリの役割を決めた。

npx reg-cli cypress/screenshots/actual/ cypress/screenshots/expected cypress/screenshots/diff -R cypress/reports/index.html -J cypress/reports/reg.json

結果の確認

cypress/reports/index.html に比較結果を出力するように指定したので、このファイルを開いてみる。 このような感じで actual と expected の比較ができている。

テストの準備は整ったので、実際にスタイルが変わったときに差分を検出するかを確認する。 まずは、以下のファイルに変更を加える。

❯ git diff
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 2a0fbf1..b918365 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -135,7 +135,7 @@
   }

   .card.highlight-card {
-    background-color: #1976d2;
+    background-color: #101011;
     color: white;
     font-weight: 600;
     border: none;

再度 Cypress でスクリーンショットを取り画像比較を実行する。

npx cypress run --component

npx reg-cli cypress/screenshots/actual/ cypress/screenshots/expected cypress/screenshots/diff -R cypress/reports/index.html -J cypress/reports/reg.json

上記コマンドを実行後に cypress/reports/index.html を開くと差分が確認できた。

まとめ

今回は Cypress と reg-cli を使った Visual regression test を試してみた。期待した通りスタイルの変更を画像比較で検知できることまで確認できた。 Visual regression test をする際のツールの組み合わせは複数パターンあるが、まずは一つの組み合わせを試せたのでテストの幅が広がったと思う。

どのツールを使うのがよりベターか、どの単位で画像比較をしていくべきかなど Visual regression test についての調査は始まったばかりなので色々と試していきたい。

Angular アプリケーションのユニットテストについて考えていること

はじめに

Angular アプリケーションとレイヤードアーキテクチャとディレクトリ構成 - とんかつ時々あんどーなつ に書いたようにアーキテクチャディレクトリ構成を考えるときに、そのメリットとしてテスタビリティの向上も期待の一つとすることが多い。プロダクションコードの責務を分離することで、どのコードにどういうテストを書けばよいか(逆にどこのテストを考えなくてよいか)が明確になると考えている。

この記事では、前の記事で示した構成の Angular アプリケーションに対してどのようなテストを書くとよいか検討したのでまとめてみることとする。なお、ここでいうテストコードはユニットテストを指している。

NOTE: アウトプットしてみるものの実践してはいないため、うまくまとまっていない部分もある

検討対象

今回は、前の記事で示した features/ の中に限定して検討する。

src/app/features/xxxx/
├── containers/
│   ├── container1/
│   │   ├── container1.component.html
│   │   ├── container1.component.scss
│   │   ├── container1.component.spec.ts
│   │   ├── container1.component.ts
│   │   ├── container1.service.spec.ts
│   │   ├── container1.service.ts
│   │   ├── container1.store.spec.ts
│   │   └── container1.store.ts
│   └── container2/
├── pages
│   └── page
│       ├── page.component.html
│       ├── page.component.spec.ts
│       └── page.component.ts
├── routes.ts
└── views
    ├── presentational1/
    │   ├── presentational1.component.html
    │   ├── presentational1.component.scss
    │   ├── presentational1.component.spec.ts
    │   └── presentational1.component.ts
    └── presentational2/

どういうテストコードを用意するか

page component

routed component としての役割をもつ page component は URL path と表示したい component が一致しているかをテストしたい。そのため RouterTestingHarness を使ったテストがマッチする。

describe('DashboardPageComponent', () => {
  let harness: RouterTestingHarness;
  let component: DashboardPageComponent;
  let router: Router;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [DashboardPageComponent],
      providers: [provideRouter(routes)],
    })
      .overrideComponent(DashboardPageComponent, { remove: { imports: [DashboardComponent] }, add: { imports: [MockDashboardComponent] } })
      .compileComponents();

    router = TestBed.inject(Router);
    harness = await RouterTestingHarness.create();
    component = await harness.navigateByUrl('', DashboardPageComponent);

    harness.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

provideRouter() の引数に渡す routes はアプリケーショントップレベルの Route[] にする。これにより / にアクセスしたときに DashboardPageComponent が表示されることをテストすることができる。

DashboardPageComponent には子コンポーネントとして DashboardComponent(container component) を表示しているが、テストではこれを mock に置き換えている。routed component は URL と表示するコンポーネントが一致することや URL パラメータにより振る舞いが変わる場合など、Router に関するテストに集中したいため mock を使い container component より先のことは考えないようにする。

もう少し詳細な内容は standalone な Angular の page component をどうテストするか - とんかつ時々あんどーなつ が参考になるかもしれない。

presentational component

presentational component は基本的に外部のものには依存せず、Input を受け取りそれを画面に表示することが責務となる。そのため Testing Library を使ったテストがマッチする。

Testing Library については Angular アプリケーションに Testing Library を導入する - とんかつ時々あんどーなつ にも書いている。

ロジックのテストというよりは、実際に component をレンダリングし、要素のチェックはイベントの発火などがメインのテストケースとなる。

container component / service / store

残るは container component / service / store となる。container と service は役割が異なるが、どちらも store と強く結びついているという点に注目したい。

すべてを独立させてテストすることもできるが、store と container / service と store の 2 パターンで考えることにした。 container は store の状態に応じたロジックを書くことが多い。service は infrastructure などからデータを取得し、それを store に保存するのが主な仕事になる。それぞれを分離させず、一連の流れとしてテストしたほうが、よりユースケースとしてテストできると感じている。

まとめ

ユニットテストを書くことはあたりまえになってきた一方で、どのようなテストを書くのがより効果的かを悩むことが多くなったので現時点での考えをアウトプットしてみた。 まだまだ検討の余地はあるので、引き続き深ぼっていきたい。

Angular v16 を軽く試す

Angular v16 が出たので公式のリリースブログを見ながら軽く触ってみる。

Angular v16 is here!. Six months ago, we reached a… | by Minko Gechev | May, 2023 | Angular Blog

正直、機能紹介はリリースブログで十分なので、以下は個人的な備忘録である。

新規プロジェクト作成時に standalone で始める

--standalone オプションを渡すことで始められるようになった。以下のコマンドで新規プロジェクトを作成する。

npm create @angular ng-sample-v16 -- --standalone

routing や style オプションと異なり、まだ対話形式での設定はできないので自分で指定する必要がある。 angular-cli/schema.json at 16.0.0 · angular/angular-cli · GitHub

作成されたプロジェクトには src/app/app.config.ts という見慣れないファイルが追加されている。これは { providers: Array<Provider | EnvironmentProviders> } の型をしている ApplicationConfig を宣言するファイルになっている。ここで宣言した値は bootstrapApplication() の第 2 引数に渡され環境 injector に登録される。つまり追加で環境 injector に登録したいものがある場合はこのファイルを編集することになる。

少し脱線するがこの ApplicationConfig を構築する考え方はコンポーネントのテストの setup にも役立ちそうだと思った。

standalone へのマイグレーション

マイグレーションはすでに v15.2 でリリース済みであり以下でも取り上げたので割愛する。

Angular アプリケーションを standalone にマイグレーションする - とんかつ時々あんどーなつ

esbuild を使ったビルドシステムが開発者プレビューに

esbuild を使ったビルドシステムが開発者プレビューになった。設定はとても簡単で angular.json を編集して build architect の builder を変更するだけで良い。

diff --git a/angular.json b/angular.json
index ab0c9cc..fe02768 100644
--- a/angular.json
+++ b/angular.json
@@ -26,7 +26,7 @@
       "prefix": "app",
       "architect": {
         "build": {
-          "builder": "@angular-devkit/build-angular:browser",
+          "builder": "@angular-devkit/build-angular:browser-esbuild",
           "options": {
             "outputPath": "dist/ng-sample-v16",
             "index": "src/index.html",

budgets オプションなど一部サポートされていないオプションがある。budgets や localize などは将来的には追加予定である。ビルド時に warning メッセージは出るが今は設定は無視されるので気にならなければそのままでよい。

実装予定のオプションやその他の制限事項は公式ドキュメントを見るのがよい。 https://angular.io/guide/esbuild

Jest の実験的サポート

単体テストの改善の第一歩として Jest が実験的にサポートされた。まずは angular.json を編集して test architect の builder とオプションを変更する。

diff --git a/angular.json b/angular.json
index fe02768..906662f 100644
--- a/angular.json
+++ b/angular.json
@@ -91,22 +91,13 @@
           }
         },
         "test": {
-          "builder": "@angular-devkit/build-angular:karma",
+          "builder": "@angular-devkit/build-angular:jest",
           "options": {
             "polyfills": [
               "zone.js",
               "zone.js/testing"
             ],
-            "tsConfig": "tsconfig.spec.json",
-            "inlineStyleLanguage": "scss",
-            "assets": [
-              "src/favicon.ico",
-              "src/assets"
-            ],
-            "styles": [
-              "src/styles.scss"
-            ],
-            "scripts": []
+            "tsConfig": "tsconfig.spec.json"
           }
         }
       }

続いて Jest をインストールする。

npm install jest --save-dev

この状態でテストを実行すると以下のメッセージが出たので jest-environment-jsdom もインストールする。

NOTE: The Jest builder is currently EXPERIMENTAL and not ready for production use.
`jest-environment-jsdom` is not installed. Install it with `npm install jest-environment-jsdom --save-dev`.
npm install jest-environment-jsdom --save-dev

これで Jest を使ったテストが実行できるようになった。

Angular: Angular CLI の Jest サポートを試す により詳しい設定についても記載されているので参考にしたい。

takeUntilDestroyed

開発者プレビューだが takeUntilDestroyed() という API が提供された。例えばコンポーネントなどで購読しているストリームをコンポーネント破棄時に購読を解除したいケースがある。

destroyed$ = new Subject<void>();

data$ = myStream$.pipe(takeUntil(this.destroyed$));

ngOnDestroy() {
  this.destroyed$.next();
}

以下はこれを簡略化して記述できる関数であり、特定のライフサイクル結びつけたいときに有効である。

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

data$ = myStream$.pipe(takeUntilDestroyed())

Required inputs

Input プロパティの指定を必須にし、指定されない場合コンパイルエラーになるという機能である。Input プロパティに required というフラグが追加されているのでこれを true にするだけでよい。

@Input({ required: true }) count!: number;

このコンポーネントを表示するときに count を指定しないと error NG8008: Required input 'count' from component MyComponent must be specified. が発生する。エラーコード 8008 は新たに追加されており、MISSING_REQUIRED_INPUTS を意味する。

ずっとあると嬉しいと思っていたので、個人的にはイチオシの新機能になる。

まとめ

Signals と SSR は軽く試すにはボリュームが大きいので扱わなかったが、新機能追加と並行して開発者体験があがるような機能変更も多く入っており、とてもわくわくするバージョンになっている。開発者プレビューや実験的サポートも数多くあり、これからのアップデートにも期待したいと思う。

standalone な Angular の page component をどうテストするか

はじめに

この記事で触れる page component は routed component のこと指している。 component の分類については下記でも少し記載しているので、リンクしておく。

Angular アプリケーションとレイヤードアーキテクチャとディレクトリ構成 - とんかつ時々あんどーなつ

今回は Tour of Heroes のようなアプリケーションをイメージしながら DashboardPageComponent を page component として用意する。

// src/app/routes.ts
import { Routes } from '@angular/router';
import { DashboardPageComponent } from './features/dashboard/pages/dashboard/dashboard.component';

export const routes: Routes = [
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
  { path: 'dashboard', component: DashboardPageComponent },
];


// src/app/features/dashboard/pages/dashboard/dashboard.component.ts
import { Component } from '@angular/core';
import { DashboardComponent } from '../../containers/dashboard/dashboard.component';

@Component({
  standalone: true,
  imports: [DashboardComponent],
  template: `<h2>Top Heroes</h2>
    <app-dashboard></app-dashboard>`,
})
export class DashboardPageComponent {}

template で表示している app-dashboard(DashboardComponent) は container の役割をしており service(DashboardService) → gateway(HeroGateway) → HttpClient へと依存しているものとする。

standalone になって変わったこと

standalone component は NgModule を使わず、直接依存関係を指定できるようになっている。 つまりテストをする際にも TestBed.configureTestingModule()imports: [DashboardPageComponent] をすることですべての依存が指定されたことになる。 テストコードは以下のようになる。

// src/app/features/dashboard/pages/dashboard/dashboard.component.spec.ts
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../../../routes';
import { DashboardPageComponent } from './dashboard.component';

describe('DashboardPageComponent', () => {
  let harness: RouterTestingHarness;
  let component: DashboardPageComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [DashboardPageComponent],
      providers: [provideRouter(routes)],
    }).compileComponents();

    harness = await RouterTestingHarness.create();
    component = await harness.navigateByUrl('', DashboardPageComponent);

    harness.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

routed component は RouterTestingHarness を使うとテストしやすいため、RouterTestingHarness ベースで記述している。 今回の本流ではないため、詳細な説明は ActivatedRouteStub に代わる RouterTestingHarness について - とんかつ時々あんどーなつ に譲る。

これでテストの準備はできた。しかし、テストを実行すると以下のエラーが発生する。

Chrome 112.0.0.0 (Mac OS 10.15.7) DashboardPageComponent should create FAILED
    NullInjectorError: R3InjectorError(Standalone[DashboardPageComponent])[HeroGateway -> HeroGateway -> HttpClient -> HttpClient]:
      NullInjectorError: No provider for HttpClient!
    error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'HeroGateway', 'HeroGateway', 'HttpClient', 'HttpClient' ] })
        at NullInjector.get (node_modules/@angular/core/fesm2020/core.mjs:7509:27)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7930:33)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7930:33)
        at injectInjectorOnly (node_modules/@angular/core/fesm2020/core.mjs:633:33)
        at ɵɵinject (node_modules/@angular/core/fesm2020/core.mjs:637:60)
        at Object.factory (ng:///HeroApi/ɵfac.js:4:35)
        at R3Injector.hydrate (node_modules/@angular/core/fesm2020/core.mjs:8031:35)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7919:33)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7930:33)
        at ChainedInjector.get (node_modules/@angular/core/fesm2020/core.mjs:12100:36)

HttpClient を使うための provider が登録されておらず、エラーになっている。

ここで一度 NgModule ベースの時を振り返る。NgModule ベースのときは以下のようなコードでテストの準備をすることが多かった。

...
beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [DashboardPageComponent],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
  }).compileComponents();
});
...

これは DashboardPageComponent をテストする際にテスト環境が認識できているものが declarations に設定されている DashboardPageComponent しかない。 DashboardPageComponent の template にある app-dashboard はこのテストケースでは未知の要素だが CUSTOM_ELEMENTS_SCHEMA で無視している。 つまり、テストをしているのは DashboardPageComponent のみであるため、app-dashboard から先の依存が問題にならない。

一方で standalone では、 app-dashboard(DashboardComponent) → service(DashboardService) → gateway(HeroGateway) までをテスト環境は認識しており、HttpClient を使うための provider だけがない状態になっている。

解決方法

provideHttpClient() を追加する

一番簡単な方法は providers に provideHttpClient() を追加することである。これにより、実際のアプリケーションと同じ設定になるので page component からすべてのレイヤーを動かすことができるようになる。

しかし、アーキテクチャによっては page component がインフラストラクチャ層まで意識したくないと考えるかもしれない。 その場合は次に示すような shallow test を検討する。

shallow test

page component の責務を routed component であることに限定するのであれば、app-dashboard 以降を mock することでテストがしやすくなる。 次のように app-dashboard を使うための class を置き換えることで shallow test が可能になる。

@Component({ selector: 'app-dashboard', standalone: true, template: '' })
class MockDashboardComponent {}
...
await TestBed.configureTestingModule({
  imports: [DashboardPageComponent],
  providers: [provideRouter(routes)],
})
  .overrideComponent(DashboardPageComponent, { remove: { imports: [DashboardComponent] }, add: { imports: [MockDashboardComponent] } })
  .compileComponents();
...

上記のように selector が 'app-dashboard' の MockDashboardComponent を用意し、DashboardComponent と入れ替える形である。 これで page component のみを薄くテストできるようになる。

まとめ

page component の責務が明確で薄くテストしたのであれば有用だろう。一方ですべてを知った上でアプリケーションと同じ状態でテストしたい場合もあると思うので、自分たちがテストしたいものは何かを確認しながら適切な選択肢が取れるとよさそうである。

参考リンク

Testing Angular Standalone Components - ANGULARarchitects

Angular アプリケーションとレイヤードアーキテクチャとディレクトリ構成

はじめに

ある程度の規模のアプリケーションのアーキテクチャを考えるときに開発時の影響範囲を限定的にしたりテスタビリティ向上のためにレイヤードアーキテクチャを適用することが多い。一方でどのレイヤーにどういう処理を置くのかや具体的なディレクトリ構成に悩むことがあるので整理してみる。

あくまで個人的な整理であり、これがベストプラクティスであるというものではない。

レイヤードアーキテクチャ

レイヤードアーキテクチャとは

まずはレイヤードアーキテクチャについてまとめる。

例えば PoEAA では 3 つのレイヤーが示されている。プレゼンテーション / ドメイン / データソースである。エヴァンスの DDD 本ではプレゼンテーション / アプリケーション / ドメイン / インフラストラクチャ の 4 層に分けられている。

レイヤーの数に違いはあるが、どちらも共通してプレゼンテーションとドメインは区別されている。マーチン・ファウラーが「One of the most useful design principles」というくらいなので「プレゼンテーションとドメインの分離」は外せないのだろう。

プレゼンテーションとドメインの分離 - Martin Fowler's Bliki (ja)

先に DDD におけるアプリケーション層について軽く触れると、このレイヤーは薄く保たれることを期待しており、ビジネスルールや知識を含まない。また、プロジェクトによってはプレゼンテーション層とアプリケーション層を厳密に区別しないこともあるとしている。

つまり、3 層と 4 層をマッピングするなら以下になるという理解をしている。

これを踏まえて上でまずは 3 層レイヤードアーキテクチャで Angular アプリケーションの役割を整理する。Angular アプリケーションに当てはめるため、前提として DB とやり取りをするバックエンドサーバーが存在し、バックエンドサーバーとは HTTP で通信をするものとする。

Angular アプリケーションでの各レイヤーの役割

プレゼンテーション層

ユーザーに情報を表示したり、ユーザーからのイベントを受け付ける役割を担う。つまり ≒ コンポーネントがプレゼンテーション層となる。コンポーネントは container component と presentational component に分けることが多いが、ここではそのどちらもプレゼンテーション層とする。

container component は 1 つ以上の presentational component を持つ。presentational component は親コンポーネントから受け取ったデータを表示し、ユーザーからのイベントを親に渡す。

データソース(インフラストラクチャ)層

代表的な役割は永続的なデータの保存である。他にも外部システムとの通信を担ったりする。Web フロントエンドの開発において直接 RDB などのデータベースとやりとりすることはないので、主な役割はバックエンドの API を呼び出すクラスを定義することとなる。ブラウザの storage とやり取りする処理を書くこともある。PoEAA ではデータソース層と呼ぶがフロントエンドではほぼバックエンドのゲートウェイとしての役割しかないため、以降はインフラストラクチャ層に統一する。

ドメイン

ビジネスルールを表現するレイヤーとなる。プレゼンテーションから渡されたデータの妥当性確認をおこなったり、ユーザーのイベントに応じて必要なデータソース層の処理の呼び出しをおこなう。フォームのバリデーションはユーザーに素早くフィードバックするための仕組みであると捉え、プレゼンテーション層の役割とする。

PoEAA ではドメインロジックを構築するのに 3 つのパターンが示されている。ここでは 3 つの中で最もシンプルとされるトランザクションスクリプトドメイン層を考える。 トランザクションスクリプトはプレゼンテーションから来るユーザーのアクションに対して手続き的に処理をしていく service class となる。 他にもバックエンドとのインターフェイスになる型情報などをドメイン層として管理する。

ここまでを図にすると以下のようになる。

アプリケーション層の追加

プレゼンテーション層とひとくくりにしても container component と presentational component はかなり役割が異なるので別のレイヤーとして分類する。presentational component はプレゼンテーション層で container component はアプリケーション層とする。

また状態管理が必要になってきたら store を用意することが多いが、Global Store ではなく ComponentStore で作る。ページをまたいで状態を管理したいことは少なく、container component 単位でよければ ComponentStore の方が影響範囲やライフサイクルを管理しやすいためである。

図にすると以下のようになる。

実際のアプリケーションのディレクトリ構造はどうするか

ここまでを踏まえて、実際のアプリケーションのディレクトリ構成に落とし込む。src/app/ の下は以下のようにすることが多い。なお standalone であることを前提にしているので NgModule は含まれていない。

features/shared/https://scrapbox.io/lacolaco/Feature_Pages_パターン の考え方が元となっている。

src/app/
├── app.component.html
├── app.component.scss
├── app.component.spec.ts
├── app.component.ts
├── core/
├── domain/
├── features/
├── infrastructures/gateways/
├── routes.ts
└── shared/

機能やページの関心事を features/ の下に置き、feature をまたぐような共通処理を shared/ に置く。

core/ にはそれらがないとアプリケーションが起動しないような処理を集める。例えば、ユーザー情報周りの処理や CustomErrorHandler などを置く。

domain/ には上で説明したドメイン層のバックエンドとのインターフェイスになる型情報を置く。これらは feature をまたいで必要とすることが多いので features/ の横に用意する。一方でトランザクションスクリプトfeatures/ ごとに必要となるのでここには配置しない。

infrastructures/ にはインフラストラクチャ層のバックエンドの API ゲートウェイを置く。

次に features/ を考える。

src/app/features/xxxx/
├── containers/
│   ├── container1/
│   │   ├── container1.component.html
│   │   ├── container1.component.scss
│   │   ├── container1.component.spec.ts
│   │   ├── container1.component.ts
│   │   ├── container1.service.spec.ts
│   │   ├── container1.service.ts
│   │   ├── container1.store.spec.ts
│   │   └── container1.store.ts
│   └── container2/
├── pages
│   └── page
│       ├── page.component.html
│       ├── page.component.spec.ts
│       └── page.component.ts
├── routes.ts
└── views
    ├── presentational1/
    │   ├── presentational1.component.html
    │   ├── presentational1.component.scss
    │   ├── presentational1.component.spec.ts
    │   └── presentational1.component.ts
    └── presentational2/

pages/ には routed component を置く。ここまで routed component については触れていなかったが、Router ナビゲーションの行き先となる component である。つまり container component や presentational component と役割が異なるため pages/ として分類する。

containers/ には container component とその状態を管理する ComponentStore を配置する。またレイヤーをまたぐがトランザクションスクリプトの役割を持つ service も一緒に置く。これは co-location の観点から複数の container component の各 service を一箇所に置くより、関連する container component のそばにあったほうがメリットが多いという判断である。

最後に views/ である。ここには presentational component を配置する。UI のための加工処理をプレゼンテーションロジックとして別ファイルにする場合はそれも同様にここで管理する。例えばこの feature の UI 専用の pipe などを指している。

以上がおおまかなディレクトリ構成である。

まとめ

Angular アプリケーションでレイヤードアーキテクチャと実際のディレクトリ構成を整理してみた。今の自分なりの整理なので解釈が間違っている部分もあるかもしれない。 今のところこの形がしっくりきているが、こういうのはこれで完璧、ということはないので試行錯誤していきたい。

ActivatedRouteStub に代わる RouterTestingHarness について

はじめに

Angular v15.2 で RouterTestingHarness という API が追加された。これはルーテッドコンポーネントをテストするための Test Harness として提供されている。

この API は以下のブログで知ったので参考文献として貼っておく。

What’s new in Angular 15.2? | Ninja Squad

ルーテッドコンポーネントとそのテスト

ルーテッドコンポーネント

ルーテッドコンポーネントはルーティングのパラメータによって振る舞いが変わることがありテストが複雑になることがある。ルーテッドコンポーネントがどういうものかは公式ドキュメントを見るのが早いが、今回イメージしているのは Tour of Heroes の HeroDetailComponent である。

https://angular.io/guide/testing-components-scenarios#routed-components

ルーテッドコンポーネントのテスト

ルーテッドコンポーネントのテストの複雑さはルーティングパラメータの有無に左右される。つまり ActivatedRoute の扱いである。

これも公式ドキュメントに頼るが、ルーテッドコンポーネントのテストをする際には ActivatedRoute のテストダブルとして ActivatedRouteStub を用意すること多かった(と言いつつルーテッドコンポーネントのテストについて誰かと語ったことはないので個人的な経験としてという意味に留める)。

https://angular.io/guide/testing-components-scenarios#activatedroutestub

どういう class を作ればいいかなどはドキュメントに書いてあるので特に問題はないのだが、新しいプロジェクトのたびにテスト用の class を開発者が用意する必要があった。そんなちょっとした手間を解消する API が今回 v15.2 で追加されたというわけである。

RouterTestingHarness

API ドキュメントはすでに用意されている。

https://angular.io/api/router/testing/RouterTestingHarness

まず対象のテストケースの TestBed.configureTestingModule() で routes の情報を設定する必要がある。設定方法は NgModule を使っているか standalone API を使っているかで変わるが、以下のどちらかがあればよい。

// NgModule を使っている場合
imports: [RouterTestingModule.withRoutes(routes)]

// standalone API を使っている場合
providers: [provideRouter(routes)],

routesRoutes 型の配列である。対象のテストケース内でも用意できるが AppRouting または類似の場所で用意している実際の routes をテストでも使うことで path 自体のテストもできるのでこちらを推奨する。

準備ができたら Harness を使っていく。RouterTestingHarness が提供している static メソッドの create() を使ってインスタンス化する。続けてインスタンスメソッドの navigateByUrl() を呼ぶことで component が手に入るので、あとはいつもどおりテストを書くことができる。

import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { RouterTestingHarness, RouterTestingModule } from '@angular/router/testing';
import { routes } from '../app-routing.module';
import { HeroDetailComponent } from './hero-detail.component';

describe('HeroDetailComponent', () => {
  let harness: RouterTestingHarness;
  let component: HeroDetailComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [RouterTestingModule.withRoutes(routes), HttpClientTestingModule],
      declarations: [HeroDetailComponent],
    }).compileComponents();

    harness = await RouterTestingHarness.create();
    component = await harness.navigateByUrl('detail/1', HeroDetailComponent);

    harness.detectChanges();
  });

  it('should create the app', () => {
    expect(component).toBeTruthy();
  });
});

これによって ActivatedRouteStub が不要になる。

まとめ

今回は v15.2 で追加された RouterTestingHarness について調べてみた。個人的には単体テストへの関心が高いので、テスト周りの API が充実するのは嬉しい。

Angular アプリケーションを standalone にマイグレーションする

はじめに

standalone API は v14 で開発者プレビューとして提供され v15.0 で正式にリリースされた機能である。

そもそも standalone とは、という点については今回は触れない。過去記事のリンクを載せておく。

既存のコンポーネントを standalone にしたいと思った場合 standalone: true のフラグを立てるだけで簡単にできる。しかし、1 つや 2 つならよいが、既存のアプリケーションをすべて standalone に置き換えていくのはなかなかの作業量だと思っていた。

しかし、そんな悩みを吹き飛ばすマイグレーションコマンドが v15.2 で提供された。CLI によってどのくらいお手軽にマイグレーションできるか試してみたので備忘録として記事にした。

Migrate to standalone

マイグレションをするには v15.2 である必要があるので @angular/core および @angular/cli を v15.2 以上にアップデートする。 マイグレーションの手順は、すでにドキュメントが用意されていたので基本的にはこの通り進む。

https://angular.io/guide/standalone-migration

マイグレーションコマンドは以下の一つである。

ng generate @angular/core:standalone

しかし、マイグレーションタイプが 3 つ用意されており、これらを順に実行する必要がある。

$ ng generate @angular/core:standalone
? Choose the type of migration: (Use arrow keys)

❯ Convert all components, directives and pipes to standalone
  Remove unnecessary NgModule classes
  Bootstrap the application using standalone APIs

Convert all components, directives and pipes to standalone

まず、1 つ目のタイプはコンポーネントとディレクティブとパイプを standalone に変換する。コマンド実行時にマイグレーション対象の path を指定できる。複数プロジェクトを管理しているモノレポや 1 つのプロジェクトでもアプリケーションの規模が大きいときは部分的にマイグレーションできるようになっている。

今回は特に部分指定はせず、デフォルト(./)で進む。コマンドの実行が終わると各コンポーネントstandalone: true とそれぞれ必要な imports が挿入される。このステップだけでもアプリケーションは動作可能である。

Remove unnecessary NgModule classes

2 つ目のタイプは不要になった NgModule を削除する。ここでは AppComponent は standalone になっていないが、 AppModule に変更が入る場合があるのでこのステップ終了時には build エラーになる場合がある。その場合は気にせず、次のステップに行く。

Bootstrap the application using standalone APIs

最後に standalone タイプの bootstrap に切り替える。app.component.ts と main.ts が変更され app.module.ts が削除される。ここまでやって(あくまで今回の場合だが)以下の 2 点でエラーが出たので、手動で対応した。

  • app.component.html で表示していたコンポーネントが app.component.ts で imports に登録されていなかった
  • main.ts でマイグレーション前に使っていた platformBrowserDynamic() の import 文が残っていた

あとはフォーマットなどをかけ、start したら無事アプリケーションの起動を確認できた。

その他

マイグレーションコマンドのすべてのステップが終わった時点で残った NgModule がある。RoutingModule である。また spec の更新もおこなわれていない。 これらは今のところ CLI ではマイグレーションできないので手動で更新していく必要がある。

まとめ

先日リリースされた v15.2 でさっそく standalone へのマイグレーションを実施してみた。すでに公式ドキュメントも用意されており、100% 自動でというわけにはいかないが、それでもあまり負担なくマイグレーションできる印象を受けた。

コンポーネント、ディレクティブ、パイプの部分的なマイグレーションから始められるので、少しずつ standalone に変えていってみるのもいいかもしれない。