Anglar Standalone Component 與 NgModule 測試 Mock 技巧
跟著 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。但這樣的「正常」其實是有前提的
WelcomeComponent
是個 Standalone Component。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-banner
的 MockBannerComponent
即可。
@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.