Anglar Standalone Component 與 NgModule 測試 Mock 技巧

Eric Li
hefemk
Published in
15 min readSep 14, 2024

跟著 Angular 一路成長的朋友,相信對 NgModule 並不陌生,它相當重要,但其複雜性通常隨著軟體規模增長。

Angular 14 提出 Angular Standalone Components 預覽版本 (ref.),隨後在 Angular 15.2 提供遷移指令 (ref.),Angular 團隊持續讓 Standalone 變得可行。

Standalone 讓一個元件的依賴關係明確揭露,透過在 @Component.imports 定義所需的引用,用什麼、引什麼。若想要使用其他的 Standalone 元件,也僅需直接 import 即可,省去 NgModule 體制下,可能需要爬梳依賴關係,尋找目標元件究竟被包裝在哪一個 NgModule 當中。

由於本文重點放在 Standalone 與 NgModule 兩者在測試撰寫上的差異,因此關於 Standalone 更多的細節,可閱讀「Angular Standalone Components: Complete Guide」,如裡面提到 lazy-loading 的進步也令人為之一亮。

放眼預計於 2024/11推出的 Angular 19,更是預設啟用 Standalone Components (ref.),儘管 NgModule 有它的好處,但整體框架的發展方向如此,若還未試過 Standalone Components 的讀者不妨一試。

本文要說明的挑戰

在撰寫 Standalone Component 測試時,比起過去在 NgModule 時代更容易遇到 Child component 需要 Mock、需要明確中斷依賴關係,避免龐大的 Mock 作業、特別要處理來自 NgModule 的 Service 等。

(本文實驗於 Angular 17)

Standalone Component 測試

Standalone 帶來的便利性是可見的,同時 Angular 讓 Standalone 與 NgModule 之間保有相容,兩者是可以混用的,但撰寫測試時,會因為 Standalone 本身的特性,產生一些需要注意的地方。

我們先看個簡單的例子,簡化自官方 ng13 的範例 (ref.):

TestBed.configureTestingModule({
declarations: [ WelcomeComponent ],
providers: [ { provide: UserService, useValue: userServiceStub } ],
});

我們可以看到被測對象為 WelcomeComponent 它被定義在 declarations 當中,並且在 providers 定義了UserService 要被抽換為何 Stub 物件。

直到 ng17,官方文件出現 Standalone 的測試範例 (ref.),一樣摘錄並簡化如下例:

TestBed.configureTestingModule({
imports: [ WelcomeComponent ],
providers: [ { provide: UserService, useValue: userServiceStub } ],
});

變動相當小,只有原本放在 declarations 裡面,現在變成在 imports 裡面。

這似乎沒什麼大不了,測試運作起來符合預期,UserService 也會被替換為 Stub。但這樣的「正常」其實是有前提的

  1. WelcomeComponent 是個 Standalone Component。
  2. UserService 是直接 provideIn: root,並未存在於 NgModule

那… 如果有NgModule的參與呢?

來自 NgModule 的 Service

如前所述,Standalone 與 NgModule 是相容的,對於既存的 Angular 專案而言,逐步遷移是合理的選擇,因此您可能會遇到兩者混用的情況。

作為複習,所謂來自 NgModule 的 Service 具體是這樣的:

@NgModule({
providers: [ UserService ]
})
export class UserModule { }

於是 Standalone Component 可以從 imports 把整個 UserModule 載入,借此來取得 UserSservice

@Component({
standalone: true,
imports: [ UserModule ], // 載入整個 NgModule,用以取得 UserService
selector: 'app-welcome',
template: '...',
})
export class WelcomeComponent {
private userService: UserService = inject(UserService);
}

但是,這樣的組合會使得 TestBed 當中,無法透過定義 providers 完成 userServiceStub 的置換,Standalone 的 WelcomeComponent 始終會要求「真的」 UserService 實體,從而引發一連串依賴問題 (如該 Service 又依賴其他 Service) ,更別說在單元測試的場景下,您可能也不想拿到真正的實作。

TestBed.configureTestingModule({
imports: [ WelcomeComponent ],
  // 無法替換為這邊的 userServiceStub,WelcomeComponent 還是會去拿真實的 UserService
providers: [ { provide: UserService, useValue: userServiceStub } ],
});

對於 Service 的情況,若您的 Service 不存在於 NgModule 當中,這邊的問題自然不存在;反之,可以繼續閱讀,逐步展開可能的應對方案。

引用其他 Component

看完前面的 Service,得益於 provideIn: root,Service 很容易脫離 NgModule 束縛,也許您也正這麼做。

但對於 Component 而言,無論它來自 NgModule,或本身也是 Standalone Component,我們都需要打斷這些依賴項目的連鎖,否則會 import 不完,也難以達成測試獨立。在 Component 當中引用 Component 相當自然,在這串樹狀結構當中,除非僅對「葉子」寫測試,否則這樣的問題是常見的。

一樣透過簡單範例說明,假設 WelcomeComponent 引用了 BannerComponent ,它也是個 Standalone,於是直接在 imports 引入。

@Component({
standalone: true,
imports: [ BannerComponent ], // 載入另一個 Standalone Component
selector: 'app-welcome',
template: '<app-banner></app-banner>',
})
export class WelcomeComponent {
@ViewChild(BannerComponent, { static: true })
public banner: BannerComponent;
}

測試時,會與前述探討 Service 的案例一樣。建立 WelcomeComponent 實體時,會試圖取得 imports 內的資源,進而牽出一整串的依賴關係。

TestBed.configureTestingModule({
imports: [ WelcomeComponent ],
});
// 建立 WelcomeComponent 實體時,也會建立 "真的" BannerComponent 實體
fixture = TestBed.createComponent(WelcomeComponent);

在討論應對方案前,再讓我們回顧一下 NgModule 時代的情況。

NgModule 時代 Mock Component 處理法之一:直接賦值

在純 NgModule 的情境下,透過定義 schemas: [NO_ERRORS_SCHEMA] 可以省下不少功夫。有了它,即便沒有 import 對應的 NgModule,測試仍然可以執行,即便元件無法被識別。

TestBed.configureTestingModule({
// 解析失敗時,不報 Error
schemas: [ NO_ERRORS_SCHEMA ],

// 假設 BannerComponent 存在於 BannerModule,但我們並不 import
// imports: [ BannerModule ],
declarations: [ WelcomeComponent ],
});

// 建立 WelcomeComponent 實體時,不會建立 BannerComponent 實體
fixture = TestBed.createComponent(WelcomeComponent);

這可能是個迷惑行為,為什麼明知 BannerComponent 存在於 BannerModule 又不載入它?這樣真的可以動嗎?

正因沒有載入 BannerModule 使這個測試範圍內BannerComponent 並不存在,當 Angular 解析樣板(Template) 時無法識別app-banner 是何物,又因 NO_ERRORS_SCHEMA 抑制了錯誤,使得測試程可以繼續執行。但這仍然是有極限的,若您的測試路線不會經過它,也就是不會存取 BannerComponent 實體,的確不會引發錯誤;相反,若您的測試路徑會存取 BannerComponent,例如實作包含如下的行為,那麼就會因為 BannerComponent實體不存在,測試時發生錯誤。

export class WelcomeComponent implements OnInit {

@ViewChild(BannerComponent, { static: true })
banner: BannerComponent;

public ngOnInit(): void {
this.banner.setMessage('Hello'); // 若在您的測試環境當中,BannerComponent 並不存在,則執行到這行將會出錯
}
}

然而這並不表示你必須 import BannerModule ,實際上您可能需要做出「假的」 BannerComponent 並替換,而並非引入真正的實作,以維持 WelcomeComponent 測試的獨立性。

其中一招,可以如此「土法煉鋼」實現,例如:

TestBed.configureTestingModule({
schemas: [ NO_ERRORS_SCHEMA ],
declarations: [ WelcomeComponent ]
});
fixture = TestBed.createComponent(WelcomeComponent);
component = fixture.componentInstance;

// 在測試中取得 Component 實體之後,立即填入假的 Banner 物件
comp.banner = {
setMessage: (value: string) => {},
} as BannerComponent;

如上例,對 .banner 直接賦予透過 Object Literal 方式建立的物件,並且使它包含必要的方法。當然,您也可以從 @Component 開始寫起,篇幅考量就不再贅述,僅是表達可以直接替換,透過如此簡單粗暴方法。

NgModule 時代 Mock Component 處理法之二:基於 selector 取代元件

前述「直接賦值」的方式並不是一體適用的,因為 @ViewChild() 也可以作用在 private 變數身上,若 banner 宣告為 private 將無法直接在測試程式中進行賦值。當然,若搬出 as any 可以突破該限制,畢竟本質是 JavaScript,但這樣就無法發揮 TS 帶來的型別檢查與提示,並不推薦,僅當不得以的手段。

比較理想的方案是:調整實作以樣板變數(template reference variable) 作為查詢條件,而非直接以 Class 查詢。

// <ap-banner #banner></ap-banner>

// @ViewChild(BannerComponent, { static: true })
@ViewChild('banner', { static: true }) // 改成以樣板變數查詢
private banner: BannerComponent;

接下來只需要在 TestBed 的 declarations 提供「假的」但 selector 一樣是 app-bannerMockBannerComponent 即可。

@Component({
selector: 'ap-banner', // 與原始實作相同的 selector
template: '...'
})
class MockBannerComponent extends BannerComponent { }

TestBed.configureTestingModule({
declarations: [
WelcomeComponent,
MockBannerComponent,
]
});
fixture = TestBed.createComponent(WelcomeComponent);
component = fixture.componentInstance;
component.banner; // 會取得 MockBannerComponent 的實體

NgModule 討論的差不多了,那麼 Standalone 呢?別急!

Standalone 時代:TestBed.overrideComponent

Standalone Component 在初始建立物件實體的過程,就會去建立它依賴的各項資源,為了中斷這樣的行為,可以透過 TestBed 的 overrideComponent 進行覆寫,再建立實體。以此解決:

  • Standalone Component 透過 imports 帶進來的整串依賴。
  • 替換身在 NgModule 當中的 Service。

如下例,瞄準原本定義在 Standalone Component 的 imports 欄位進行覆蓋,移除原本的資源,再加入動過手腳的資源 (Fake / Mock)。

@NgModule({
declarations: [ MockComponent(BannerComponent) ],
exports: [ MockComponent(BannerComponent) ]
})
class MockBannerModule { }

// ----------
TestBed.overrideComponent(
WelcomeComponent,
{
remove: {
imports: [
UserModule, // 不讓測試有機會建立 UserModule 裡面的實例,從而避掉可能的依賴串
BannerModule, // 不讓測試有機會建立 "真的" BannerComponent
]
},
add: {
imports: [
MockBannerModule, // 換成自己準備的 MockBannerModule,使 "假的" BannerComponent 被建立
]
}
}
);
TestBed.configureTestingModule({
imports: [
WelcomeComponent,
],
providers: [
{ provide: UserNgModuleService, useValue: userNgModuleServiceStub }, // 上面已移除 UserModule,故這邊補 provide
],
});

在上面的範例,一口氣對 Service 與 Component 進行了替換,基本的思路是「移除原有的,加入假造的」。

應對方案整理

依照上述的討論,可以整理出 Standalone 與 NgModule 場景下的應對方案:

小結

對比本文章前述 NgModule 時代,透過 NO_ERRORS_SCHEMA 帶來的「方便」,Standalone 由於它的特性,使得測試時很可能需要透過 TestBed.overrideComponent 進行更多的準備,需要明確了解哪些東西該被 override,但這不見得是壞事。

正因這樣的「麻煩」,使得依賴關係被明確揭露,開發人員知道哪這些依賴是換還是不換。與實作 Standalone Component 相當,由於必須在 imports 定義,無論是引入其他的 Standalone Component / Directive / Pipe、引入來自 NgModule 的資源,或注入 Service 等,要用到什麼就必須引入什麼,對比 NgModule 來說更為直觀。

收起那瘋狂的想法

既然可以 overrideComponent ,那麼是否可以直接把 standalone 寫為 false 呢?似乎可以在測試階段讓它退回早期,就可以用熟悉的套路來省些工夫。但實際上是不行的,Angular 會直接拋錯

Error: An override for the StandaloneWelcomeComponent class has the `standalone` flag. Changing the `standalone` flag via TestBed overrides is not supported.

--

--