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

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

Angular のひとつのサービスクラスから生成できる異なるインスタンスをそれぞれ注入できるようにする

はじめに

Angular アプリケーションで、ある class A で別の class B をインスタンス化して使いたい場合、class B に @Injectable() のデコレータをつけて注入可能にする。 オプションに providedIn: 'root' をつけることでどこからでもインスタンスを要求することができるようになりとても便利である。

しかし、このままでは class B をインスタンス化する際にコンストラクタ引数を渡すことができない。

Angular の DI の仕組みがわかれば、インスタンス化する際の引数指定もできるようになるため、今回はそれについて説明する。

Angular の DI の仕組み

コンシューマーとプロバイダーの 2 つの役割が存在する。名前のままだが、コンシューマーが要求する側でプロバイダーが提供する側である。 この 2 つをやり取りする層として、Angular にはインジェクターというものが存在する。つまり class A から class B を DI の仕組みでインスタンス化して使う場合には class B をインジェクターに登録できればよい、ということになる。

インジェクターには環境インジェクターと要素インジェクターの 2 種類があるが、今回は環境インジェクターを使って説明をする。 余談だが DI の仕組みを使って作られたインスタンスインジェクターの階層に対してシングルトンとなる。シングルトンである範囲をコントロールするには、目的に合わせて要素インジェクターも使う必要がある。

プロバイダーの設定

@Injectable({ providedIn: 'root' }) を使う

サンプルとしてコンシューマーに AppComponent をプロバイダーに Logger を用意する。

// logger.ts

@Injectable({ providedIn: 'root' })
export class Logger {
  log(message: string) {
    console.log('LOG: ' + message);
  }
}
// app.component.ts

@Component({ selector: 'app-root', standalone: true, template: `<div>Hello world</div>` })
export class AppComponent implements OnInit {
  #logger = inject(Logger);

  ngOnInit() {
    this.#logger.log('----- AppComponent initialized -----');
  }
}

Logger class には @Injectable() をつけている。providedIn: 'root' を指定することで暗黙的に環境インジェクターに登録されることになる。

クラスプロバイダー useClass を使う

providedIn: 'root' を指定せず、自分で環境インジェクターに登録する場合は次のようになる。

// logger.ts

@Injectable()
export class Logger {
  log(message: string) {
    console.log('LOG: ' + message);
  }
}
// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [Logger],
};

上記のように bootstrapApplication() の第 2 引数となる appConfig に自分で登録する必要がある。 またこれはシンタックスシュガーとなっており、次のように解釈される。

// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [{ provide: Logger, useClass: Logger }],
};

useClass を使うと依存オブジェクトが要求されたときに指定した class をインスタンス化してくれる。 このとき new 演算子を使ったインスタンス化がデフォルト挙動である。

ファクトリープロバイダー useFactory を使う

useClass を使った場合、インスタンス化は Angular が暗黙的におこなっている。そのため、useClass ではコンストラクタ引数を渡すことはできない。 インスタンスを作る方法を明示したい場合は、useFactory を使う。書き換えると次のようになる。

// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [{ provide: Logger, useFactory: () => new Logger() }],
};

これでインスタンス化する方法もコントロールすることができる。

InjectionToken を用いて 1 つの class からコンストラクタ引数違いの 2 つのインスタンスを作る

ここでコンストラクタ引数により、インスタンスの振る舞いが変化する場合を考える。 たとえば上記の Logger class がコンストラクタ引数で渡した log level によって出力できる console の種類が変わるとする。

// logger.ts

type LogLevel = 'LOG' | 'ERROR';
export class Logger {
  logLevel: LogLevel = 'LOG';

  constructor(level: LogLevel) {
    console.log('Logger initialized with log level: ' + level);
    this.logLevel = level;
  }

  log(message: string) {
    if (['LOG'].includes(this.logLevel)) {
      console.log('LOG: ' + message);
    }
  }

  error(message: string) {
    if (['LOG', 'ERROR'].includes(this.logLevel)) {
      console.error('ERROR: ' + message);
    }
  }
}

この Logger class のコンストラクタ引数違いのインスタンスをそれぞれインジェクターに登録する。 まずそれぞれの InjectionToken 型のトークンを用意する。

// app.config.ts

export const LOG_LOGGER = new InjectionToken<Logger>('Log Level が Log の Logger');
export const ERROR_LOGGER = new InjectionToken<Logger>('Log Level が ERROR の Logger');

そしてこれらのトークンに対応するファクトリー関数をそれぞれインジェクターに登録する。

// app.config.ts

...
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: LOG_LOGGER, useFactory: () => new Logger('LOG') },
    { provide: ERROR_LOGGER, useFactory: () => new Logger('ERROR') },
  ],
};

これにより同じ class の異なるインスタンスをコンシューマーが要求できるようになる。

まとめ

今回のようにひとつのクラスから複数のインスタンスを登録したいケースは稀かもしれない。 一方でプロバイダーに登録する際に環境ごとに違うコンストラクタ引数を渡したくなることはあるかもしれない。

InjectionToken の生成方法や useClass 以外のプロバイダー定義方法を知っておくのは有用だと思われる。

参考

2023 年振り返り & 2024 年の抱負

2023 年振り返り

仕事

現職で丸 1 年過ごした。後半は珍しく休日出勤などもして、全体的に仕事の割合が多い一年だったかもしれない。

働くこと自体は嫌いではないが、仕事の割合が多いということは他に時間を割けなくなるということで、そういう意味では働き方は見直したほうがいいと思った。

プライベート

仕事時間の増加によるしわ寄せは当然こちらに来る。

まず読書量である。読書メーターを見る限り 3 冊しか読み切っていない。他にも何冊か読んでいたが、なかなか読み切れていない。 生活リズムを一定にして始業前の時間を確保するようにしたい。

次は運動である。習慣をつけるために chocoZAP に入った。結論としては習慣になっていない。やはり忙しくなった 10 月後半からパタリと行かなくなってしまった。 言い訳をすると最寄り駅と逆方向に 10 分以上歩かないといけない場所にあり、コンビニ感覚で通えない…もっと近くに出来てくれ…

出来てないことばっかりだが、今年できたこともある。

6 月に新婚旅行に行った。ようやく行けたという感じである。 言語の壁が最大の不安要素だったが、文明の利器のお陰でちゃんと楽しめた。ありがとう科学。

あとライブにはすごい行った。どのくらい行ったかは昨日しずかに振り返ったので割愛。

ライブ観戦の記録 2023|kasaharu

ようやくアフターコロナの世界線に来たなという感じの 1 年でもあった。

2024 年の抱負

今年は読書量が少なかったのもあるが技術書以外を読む習慣もないことに気づいたので、活字に強くなりたいと思った。 上にも書いたが始業前という決まった時間に読書をするよう心がけてみる。

あとどこかの勉強会で発表したい。 今年は 1 度だけ 隅田川.dev vol.1 で LT をしたが、ブログのアウトプットと発表のアウトプットは体験がかなり違う。 成果を発表するというのは仕事でも必要なスキルなので意識していきたい。

2023 年に入った Angular のさまざまなアップデート

はじめに

みなさん、Angular ルネサンスしてますか?

最近、ロゴが大きく刷新されたり、新しい公式ドキュメントページが公開されたりと話題になっている Angular ですが、2023 年は 15.1.0 (2023-01-10) から 17.0.4 (2023-11-20) までアップデートされました。

この記事ではこれらのバージョンからいくつかの機能をピックアップして紹介したいと思います。

これは Angular Advent Calendar 2023 1 日目の記事です。

主な変更点

Angular Signal

今年一番話題になったのはやはり Signal ではないだろうか。新しい reactive system として提供され v17 では API が安定版になっている(一部 developer preview あり)。

Signal には writable と read-only の 2 種類があり、writable な Signal を生成するには次のように signal() 関数を使う。

const count = signal(0);

Signal は getter を使って値を参照でき、set() を使って更新ができる。

console.log(count()); // 0

count.set(100);

console.log(count()); // 100

read-only な Signal は computed() 関数を使って作ることができる。これは別の Signal を元に新たな Signal を算出する関数である。

const doubleCount = computed(() => count() * 2);

たとえば、上記のように宣言することで doubleCount は count が変わったときに更新する必要があることを伝えることができる。

これらの Signal API は angular/core から提供されているため、追加のライブラリなしに使うことが可能である。 effect() など一部 developer preview の機能も含め、まだまだ改善されることが予定されているため、2024 年も目が離せない。

standalone by default

去年から提供され始めた standalone API だが、ついに v17 では新規にアプリケーションを作成するとデフォルトで standalone ベースで作られるようになった。一応 --standalone オプションを false にすることで外すことできるが、特にそういうユースケースはないと思う。

また NgModule ベースのアプリケーションを standalone ベースにマイグレーションするコマンドも v16 で提供されている。Angular の機能追加の中でも standalone を前提とした API がどんどん追加されているので、既存のアプリケーションもマイグレーションしていくことが求められている。

細かいマイグレーション手順については過去に紹介したこともあるのでリンクを貼っておく。

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

Built-in control flow

v17 で新しい制御フロー構文が developer preview として提供された。これは構造ディレクティブで提供されていた *ngIf, *ngSwitch, *ngFor を置き換えるものである。 それぞれ @if, @switch, @for という構文になる。

@switch@case の間では適切に型の絞り込みがされるようになるため開発者体験が向上するだろう。また @fortrack が必須になるため、つけ忘れによるパフォーマンス劣化を防ぐことができる。

さらに、NgIf などの構造ディレクティブが不要になるためバンドルサイズが削減されるなど、書き方だけでなくユーザーへ提供できる価値としても効果がある。

すでにマイグレーションコマンドも用意されているため、さっそく置き換えることもできる。

ng generate @angular/core:control-flow

developer preview ではあるが、Angular チームも安定性には自信を持っていると言っているので積極的に使っていきたい。

Deferrable views

制御フロー構文と同じタイミングで新しく提供されたブロック構文である。これは遅延ロードの新しい仕組みであり @defer を使って次のように書くことで実現できる。

@defer {
  <hello-lazy />
}

またロードタイミングの設定も自由度が高く、on viewporton idle, on hover などトリガーと呼ばれるものを指定するだけで柔軟に設定できる。

@defer (on viewport) {
  <hello-lazy />
}

こちらもまだ developer preview でありどのくらい安定しているは不明だが、かなり体験が良さそうなので使えるところから使ってみたい。

Vite and esbuild

v17 で新しく @angular-devkit/build-angular:application という builder が提供された。今までの webpack ベースの @angular-devkit/build-angular:browser を置き換えるもので Vite, esbuild ベースになっている。

v17 で新規にアプリケーションを作成するとデフォルトで @angular-devkit/build-angular:application を使うようになっている。既存のアプリケーションからのアップデートでは今のところ自分で angular.json を書き換える必要がある。

これを書き換えるだけで build 時間が短縮されるので、積極的に置き換えていきたい。browser のオプションのうち buildOptimizervendorChunk などは application では使えないため、置き換えた場合はこれらのオプションは合わせて削除する必要があることだけ注意である。

CLI のマイナーバージョンアップでマイグレーションコマンドが提供されそうなので、この変更が入ったバージョンを待ってからでも遅くはない。

Input value transforms and Required Inputs

ここでは Input プロパティに関する変更を 2 つ紹介する。

1 つ目は Input value transforms である。これは親コンポーネントから受け取った値を自身のプロパティに代入する前に変換用の関数を通すことができる仕組みである。 例えば次の例である。

@Component({
  standalone: true,
  selector: 'my-button',
  template: `…`
})
export class MyButton {
  @Input() disabled: boolean = false;
}
<my-button disabled />

Input プロパティは boolean を期待しているが、文字列の 'false' や undefined が来てしまうことがある。この差異を次のように書くことで吸収できる仕組みになっている。

@Component({
  standalone: true,
  selector: 'my-button',
  template: `…`
})
export class MyButton {
  @Input({ transform: booleanAttribute }) disabled: boolean = false;
}

色々なことがやりたくなってしまうところではあるが小さい純粋関数を通す、くらいに留めておいたほうがよさそうである。

もう 1 つは Required Inputs である。その名の通り Input プロパティに必須のフラグを付けることができる。

@Component(...)
export class MyHeading {
  @Input({ required: true }) text: string = '';
}

required が true の Input プロパティはテンプレート側で指定していないとコンパイルエラーになる。 v16 で導入されたため、個人的にはすでに積極的に使っている機能の一つである。実行時ではなくコンパイル時に気付けることが増えるのはとても嬉しい。

takeUntilDestroyed

コンポーネントやディレクティブが破棄されたときに Subscription の購読を解除する必要があるが、この処理についておそらく誰しもが同じようなコードを書いているはずである。これを簡単におこなうための API@angular/core/rxjs-interop から提供されている。現時点では developer preview ではあるが、この API に置き換えることで多くのボイラープレートが削除できると思う。

例えば次のように書くことができる。

data$ = this.http.get('...').pipe(takeUntilDestroyed());

デフォルトでは inject context でしか呼び出すことができない。それ以外の場所で呼び出す場合は、明示的に DestroyRef を渡す必要がある。

readonly #destroyRef = inject(DestroyRef);

ngOnInit(): void {
    this.http.get('...').pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((data) => this.data = data);
  }

ちなみに inject context 以外で呼び出した場合は NG0203 のエラーが発生するのでその時は DestroyRef を思い出すとよい。

Jest の実験的サポート

v16 で angular-devkit から Jest 用の builder が提供されている。experimental だが使い方は簡単で builder を @angular-devkit/build-angular:jest に置き換え、使用できないオプションを消していく。

diff --git a/angular.json b/angular.json
index f9f85de..9fe2129 100644
--- a/angular.json
+++ b/angular.json
@@ -75,14 +75,10 @@
           }
         },
         "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"
           }
         }
       }

いくつかパッケージのインストールは必要だが、この状態で npm run test を実行すると足りていないパッケージを教えてくれる。最終的に必要になるのは jest と jest-environment-jsdom になる。

しかしまだ NOTE: The Jest builder is currently EXPERIMENTAL and not ready for production use. のログが出るため、本格的に使用できる段階ではなさそうである。

Reading the route parameters

パスパラメータやクエリパラメータなどのルーティングパラメータをコンポーネントの Input プロパティとして参照できるようにする機能が v16 で提供されている。 この機能を使うには withComponentInputBinding() を有効にする必要がある。設定方法は provideRoute() の第 2 引数に渡すだけである。

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes, withComponentInputBinding())],
})

この設定をすることで次のようなルーティングがあったときに id を Input プロパティとして受け取ることができる。

// routes.ts
const routes: Routes = [
  ....
  { path: 'detail/:id', component: HeroDetailComponent },
];

// hero-detail.component.ts
@Component({...})
export class HeroDetailComponent implements OnInit {
  @Input() id!: string;
}

これによりルーティングパラメータを取得するために ActivatedRoute を使う必要がなくなる。

アプリケーションが大きくなっていき、コンポーネントの中でも責務を分割していくと ActivatedRoute を inject できるのは routed component だけというルールを作ることもあった。しかし withComponentInputBinding を有効にすることでこのルールが強制されるため、個人的には積極的に置き換えていきたいと思っている。

まとめ

15.1.0 から 17.0.4 までの更新で記憶に残っているもの、積極的に使っていきたいものを中心にピックアップしてみました。

その他 SSR / SSG 周りも大きく改善された 1 年でしたが、今回は取り上げていないのでこのあとの Advent Calendar で誰かが書く記事を待ちたいと思います(笑)

また、今年の変更の多くは Angular 日本ユーザー会が配信している ng-japan OnAir でも触れられているので、下記リンクの動画を見ることで deep dive できるかもしれません。 www.youtube.com

明日は carimatics さんです!

参考

Cloudflare Workers を始める

はじめに

これは Cloudflare のアカウント作成から Cloudflare Workers にアプリケーションをデプロイするまでの備忘録となる。

今回試したアプリケーション

今回試したアプリケーションは https://hello-clw.kasaharu.workers.dev にデプロイされている。

kasaharu.workers.dev の部分はアカウントに対して一意に割り当てられたサブドメインとなる。デフォルトだとメールアドレスのアカウントの部分が設定されるがあとから変更も可能である。 また hello-clw の部分はアプリケーションの名前になっている。

アカウント作成

アカウントもなかったので https://workers.cloudflare.com/ から作成する。作成の細かいステップは忘れてしまったが、そんなに難しいことはしていない。

アプリケーションの用意

公式ドキュメント にあるように CLI を使ってアプリケーションを用意した。最初に npm create cloudflare@latest を実行するだけで、あとはプロンプトの質問に答える形で設定した。 設定をピックアップするとアプリケーション名は hello-clw とし、アプリケーションタイプは "Hello World" Worker とした。これでデプロイまで実行される。

事前にサブドメインの変更ができなかったので、アプリケーションをデプロイ後に Workers & Pages から変更した。 作成されたプロジェクトは GitHub repo に push した。

GitHub Actions によるデプロイパイプライン

すでに初回のデプロイは終わっているが、2 回目以降は package.json に書かれた npm script を手動で実行してデプロイすることになる。

最初のうちに GitHub Actions 経由でデプロイできるようしておけば、手動でコマンドを実行しなくて良いので設定しておく。 デプロイには Deploy to Cloudflare Workers with Wrangler · Actions · GitHub Marketplace · GitHub を使用した。

API token が必要になるので Cloudflare 側で作成する。https://dash.cloudflare.com/profile/api-tokens から「Edit Cloudflare Workers」のテンプレートを使い作成する。 token を発行したら CF_API_TOKEN という名前で対象の GitHub repo の Secrets に登録する。

設定が終わったらデプロイ用の workflow を用意する。今回は .github/workflows/deploy.yml を作成し、以下の設定をした。

name: Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    name: Deploy
    steps:
      - uses: actions/checkout@v3
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}

これで main ブランチに push すると同時に Worker へのデプロイが動くようになった。

まとめ

Worker が動く環境が無料で手に入った。フロントエンドに置きたくないロジックを Worker に任せる選択肢がでてきそうである。

参考リンク

Angular Signals を使ったときの component と service の分離

Angular Signalsとコンポーネント間通信 を読んで Signals を使ったコンポーネントの実装パターンを学んだ。component が複雑になるとロジックや状態管理を service に分離したくなる。ここで Angular Signals を使ったときの component と service の分離について考えてみた。

signals の導入

まずは signals を使うところから始める。ベースは Tour of Heroesdashboard.component.ts を使う。参考までに最初のコードを示すとおおよそ次のようになっている。

@Component({
  selector: 'app-dashboard',
  template: `
  <h2>Top Heroes</h2>
  <div class="heroes-menu">
    <a *ngFor="let hero of heroes"
        routerLink="/detail/{{hero.id}}">
        {{hero.name}}
    </a>
  </div>
  <app-hero-search></app-hero-search>`,
  styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
  heroes: Hero[] = [];

  constructor(private heroService: HeroService) { }

  ngOnInit(): void {
    this.getHeroes();
  }

  getHeroes(): void {
    this.heroService.getHeroes()
      .subscribe(heroes => this.heroes = heroes.slice(1, 5));
  }
}

heroes を signal 化すると次のようになる。

@Component({
  selector: 'app-dashboard',
  template: `
  <h2>Top Heroes</h2>
  <div class="heroes-menu">
    <a *ngFor="let hero of $heroes()"
        routerLink="/detail/{{hero.id}}">
        {{hero.name}}
    </a>
  </div>
  <app-hero-search></app-hero-search>`,
  styleUrls: ['./dashboard.component.css'],
})
export class DashboardComponent implements OnInit {
  $heroes = signal<Hero[]>([]);

  constructor(private heroService: HeroService) {}

  ngOnInit(): void {
    this.getHeroes();
  }

  getHeroes(): void {
    this.heroService
      .getHeroes()
      .subscribe((heroes) => this.$heroes.set(heroes.slice(1, 5)));
  }
}

signal の更新に set() を使い、値の読み取りは getter を使うようになるのが主な変更点である。 このくらいのコード量ならこれで困ることもないが、コードが増えていくと仮定して hero の取得ロジックと取得したデータの管理を別の class に分けることにする。

service への切り出し

service は DashboardComponent 用の service として DashboardService という名前で作成する。

interface DashboardState {
  heroes: Hero[];
}

@Injectable()
class DashboardService {
  private readonly heroService = inject(HeroService);
  $state = signal<DashboardState>({ heroes: [] });

  getHeroes(): void {
    this.heroService
      .getHeroes()
      .subscribe((heroes) => this.$state.set({ heroes: heroes.slice(1, 5) }));
  }
}

HeroService との依存はこの DashboardService に閉じ込めるようにし component が意識しない形にする。もし DashboardComponent で loading の状態が必要になったときは DashboardState にプロパティを追加すればよい。 この service を component から参照すると component は次のようになる。

@Component({
  selector: 'app-dashboard',
  template: `
  <h2>Top Heroes</h2>
  <div class="heroes-menu">
    <a *ngFor="let hero of $heroes()"
        routerLink="/detail/{{hero.id}}">
        {{hero.name}}
    </a>
  </div>
  <app-hero-search></app-hero-search>`,
  styleUrls: ['./dashboard.component.css'],
  providers: [DashboardService],
})
export class DashboardComponent implements OnInit {
  private readonly service = inject(DashboardService);
  $heroes = computed(() => this.service.$state().heroes);

  ngOnInit(): void {
    this.service.getHeroes();
  }
}

$heroescomputed() を使うことで service で管理している signal から派生した signal になり WritableSignal ではなく readonly で扱うことができる。つまり state の更新の管理も service に閉じ込められる。

なんとなく component と service の役割は分けられたように思える。

まとめ

Angular Signals を自分で触ったことがなかったので、お試しで素振りしてみた。 API 自体も開発者プレビューでまだどうなるかわからないが、最初の一歩としての備忘録とする。

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 に保存するのが主な仕事になる。それぞれを分離させず、一連の流れとしてテストしたほうが、よりユースケースとしてテストできると感じている。

まとめ

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