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

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

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 に変えていってみるのもいいかもしれない。

GitHub Packages を使って private な npm package を publish するまで

次のふたつを初めて試したので、その時の備忘録です。

  1. registry に GitHub Packages を使う
  2. private で publish する

公開するための Node.js モジュールを用意

まずはモジュールを用意する。ここはあまり重要ではないので、Creating Node.js modules | npm Docs を参考に用意した。スコープを設定したいので npm init 時にオプションで指定する。

npm init --scope=@kasaharu

GitHub Actions で GitHub Packages に publish する

「Publish Node.js Package to GitHub Packages」 という GitHub Packages に publish する workflow が用意されているのでこれを参考にする。

デフォルトでは以下の設定になっている。

# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages

name: Node.js Package

on:
  release:
    types: [created]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16
      - run: npm ci
      - run: npm test

  publish-gpr:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16
          registry-url: https://npm.pkg.github.com/
      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}

workflow はほぼこのままで使用可能だった。 GITHUB_TOKEN も自分で secrets に登録する必要があるのかと思ったが、これはリポジトリの操作権限を持った token として GitHub Actions を使うときに有効化されるものらしい。 詳細については 自動トークン認証 - GitHub Docs で詳しく説明されている。

GitHub Actions が実行されると以下のようにパッケージが公開される。

公開した private package を使う

npmレジストリの利用 - GitHub Docs を参考に使う側の設定をする。

GitHub Packages を registry に使うことがわかるように .npmrc を用意して設定する。

@kasaharu:registry=https://npm.pkg.github.com

また private package をインストールするため認証が必要となる。今回は GitHub の personal access token を用意して指定している。 personal access token の作り方は 個人用アクセス トークンの作成 - GitHub Docs にある。

package のダウンロードができればよいので必要な権限は read:packages となる。 発行した token を環境変数に登録して .npmrc に以下の設定を追加する。

//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

あとは作ったパッケージを npm install で指定することで使えるようになる。

まとめ

GitHub Docs のボリュームが多いので探すのは大変だが、ドキュメントを見つけることができれば問題なく設定できた。無料アカウントでも private で npm package を公開できるので、色々と使いみちがありそう。

参考リンク

Angular で単体テストを実行したときに出るコンソールエラーに気づく方法

はじめに

Angular で単体テストを書いているとき、テストケースは pass するがコンソールエラーが出ていることがしばしばある。

例えば次のようなエラーだ。

ERROR: 'NG0304: 'app-home-child' is not a known element (used in the 'HomeComponent' component template):
1. If 'app-home-child' is an Angular component, then verify that it is a part of an @NgModule where this component is declared.
2. If 'app-home-child' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.'

このようなエラーを解消する方法については、過去にも以下で取り上げている。

NO_ERRORS_SCHEMA を安易に使うのをやめたい話 - とんかつ時々あんどーなつ

しかし、このエラーはテスト実行時のログを見なければ確認できず、CI などでテスト結果のみを見ていると見逃してしまうこともある。

そこで今回はこのエラーを見逃さず検知する方法を紹介する。

結論

先に結論だけ書くと、単体テストの環境設定にプロパティが用意されているのでそれを有効にするとよい。 有効にすることで、このエラーがある場合にテストケース自体が失敗するようになる。

https://angular.io/api/core/testing/TestEnvironmentOptions

設定方法は Jasmine + Karma を使っているか Jest を使っているかで異なるので、順番に説明する。 ちなみにこの設定は Angular CLI v15.x で新規作成したプロジェクトではデフォルトで設定されていることも先に記しておく。

再現環境の準備

上にも書いたように v15.x で始めたプロジェクトでは最初から設定済みなのでまずは v14.x でプロジェクトを作る。

npm create @anuglar@14 ng-sample

このプロジェクトにAngular CLI で HomeComponent と HomeChildComponent を作成する。そして HomeComponent に HomeChildComponent を表示する。 これで準備はできたので、実際にエラーを確認してみる。

エラーの確認

テストを実行してみると最初に見たようなコンソールエラーが表示されている。しかし、テストケースはすべて pass していることが確認できた。 ここからは実際に事前にきづけるように TestEnvironmentOptions を設定をしていく。

TestEnvironmentOptions の設定

Jasmine + Karma の場合

テストの初期化処理が記述されている src/test.ts というファイルがある。このファイルの initTestEnvironment() の第 3 引数に TestEnvironmentOptions を渡すことができるのでそこに追加する。 追加後は以下のようになる。

// src/test.ts

...

getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting(),
  { errorOnUnknownElements: true, errorOnUnknownProperties: true }
);

TestEnvironmentOptions には teardown プロパティもあるが、teardown はデフォルトで true になっているので明示的に設定はしない。 設定は以上である。これで再びテストを実行するとテストケースが失敗することが確認できる。

v15.x で新規にプロジェクトを作った場合はプロジェクト内に src/test.ts 自体が存在しない。これは @angular-devkit/build-angular の中で test.ts の設定を持つようになったためである。

Make karma main file optional by alan-agius4 · Pull Request #23957 · angular/angular-cli · GitHub

この内部で持つ設定の中で TestEnvironmentOptions が有効になっているため、v15.x で始めた場合は今回の設定が不要になる。

angular-cli/index.ts at 15.0.0 · angular/angular-cli · GitHub

あくまで v15.x で始めた場合なので、v14.x 以前のプロジェクトをアップデートして v15.x にしても適用されない。

Jest の場合

Jest で Test Environment を設定する方法は以下の jest-preset-angular のドキュメントに記載されている。

Test environment | jest-preset-angular

teardown は不要なので以下を setup-test.ts に設定する。

// setup-test.ts
globalThis.ngJest = {
  testEnvironmentOptions: {
    errorOnUnknownElements: true,
    errorOnUnknownProperties: true,
  },
};

import 'jest-preset-angular/setup-jest';

noImplicitAny が有効になっているとこの設定だけでは以下のエラーが発生する。

TS7017: Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.

その場合は以下のように設定する。

import { TestEnvironmentOptions } from '@angular/core/testing';

declare global {
  var ngJest: {
    testEnvironmentOptions?: TestEnvironmentOptions;
  };
}

globalThis.ngJest = {
  testEnvironmentOptions: {
    errorOnUnknownElements: true,
    errorOnUnknownProperties: true,
  },
};

import 'jest-preset-angular/setup-jest';

おわりに

とてもニッチではあるが、個人的には困っていた内容でありスマートな解決方法もあったのでまとめてみた。 同じ悩みを持っている人に届くと嬉しい。