Angular 17 測試基礎

為什麼需要測試?

在 Angular 開發中,測試是確保程式碼品質和穩定性的重要手段。特別是在使用 Angular 17 的 standalone 元件和新的 control flow 語法(@for@if 等)時,良好的測試策略更顯重要。

測試的價值

  • 防止回歸錯誤 - 確保新功能不會破壞既有功能

  • 程式碼品質保證 - 強制思考程式碼的設計和邊界條件

  • 重構信心 - 安全地改善程式碼結構

  • 文件化功能 - 測試本身就是最好的使用說明

Angular 測試環境設定

基本匯入設定

在 Angular 17 standalone 架構下,測試設定需要特別注意模組的匯入:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';

標準測試配置模板

describe('YourComponent', () => {
  let component: YourComponent;
  let fixture: ComponentFixture<YourComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [
        YourComponent,          // Standalone 元件直接匯入
        NoopAnimationsModule,   // 禁用動畫提升測試效能
        ReactiveFormsModule,    // 表單相關功能
        MatFormFieldModule,     // Angular Material 模組
        MatSelectModule,
        // 其他必要模組...
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(YourComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

Angular 17 Standalone 測試特點

與傳統模組化差異

傳統模組化方式:

// ❌ 舊的方式
beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [YourComponent],  // 使用 declarations
    imports: [CommonModule, FormsModule],
  }).compileComponents();
});

Standalone 方式:

// ✅ 新的方式
beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [YourComponent],  // 直接匯入 standalone 元件
  }).compileComponents();
});

Control Flow 語法測試

Angular 17 的新 control flow 語法(@if@for@switch)在測試中的處理:

// 元件 template 使用新語法
template: `
  @if (showTitle) {
    <h1>{{ title }}</h1>
  }
  @for (option of options; track option.value) {
    <div>{{ option.text }}</div>
  }
`

// 測試新 control flow
it('should display title when showTitle is true', () => {
  component.showTitle = true;
  component.title = 'Test Title';
  fixture.detectChanges();

  const titleElement = fixture.nativeElement.querySelector('h1');
  expect(titleElement).not.toBeNull();
  expect(titleElement.textContent).toBe('Test Title');
});

it('should render options using @for', () => {
  component.options = [
    { value: '1', text: 'Option 1' },
    { value: '2', text: 'Option 2' }
  ];
  fixture.detectChanges();

  const optionElements = fixture.nativeElement.querySelectorAll('div');
  expect(optionElements.length).toBe(2);
  expect(optionElements[0].textContent).toBe('Option 1');
});

測試工具與函式庫

Jasmine 基本語法

Angular 使用 Jasmine 作為測試框架,提供豐富的斷言方法:

// 基本斷言
expect(value).toBe(expected);           // 嚴格相等 (===)
expect(value).toEqual(expected);        // 深度相等
expect(value).toBeTruthy();             // 真值
expect(value).toBeFalsy();              // 假值
expect(value).toBeNull();               // null
expect(value).toBeUndefined();          // undefined
expect(value).toBeDefined();            // 已定義

// 陣列和物件
expect(array).toContain(item);          // 包含元素
expect(array).toHaveSize(number);       // 陣列長度
expect(object).toHaveProperty('key');   // 物件屬性

// 數字比較
expect(number).toBeGreaterThan(other);
expect(number).toBeLessThan(other);
expect(number).toBeCloseTo(other, precision);

// 字串
expect(string).toContain(substring);
expect(string).toMatch(/pattern/);

// 函式
expect(fn).toThrow();
expect(fn).toThrowError(ErrorType);

Spy 函式

Spy 是測試中監控和模擬函式調用的重要工具:

// 建立 spy
const spy = jasmine.createSpy('spyName');
const methodSpy = spyOn(object, 'methodName');

// Spy 驗證
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(arg1, arg2);
expect(spy).toHaveBeenCalledTimes(number);

// Spy 回傳值設定
spy.and.returnValue(value);
spy.and.callThrough();          // 調用原始函式
spy.and.throwError(error);      // 拋出錯誤

異步測試處理

fakeAsync 和 tick

處理有時間延遲的操作:

import { fakeAsync, tick, flush } from '@angular/core/testing';

it('should handle debounced input', fakeAsync(() => {
  component.searchInput.setValue('test');
  
  // 模擬時間經過
  tick(300);  // 等待 300ms
  
  expect(component.searchResults).toBeDefined();
  
  // 清理所有剩餘的計時器
  flush();
}));

async 和 whenStable

處理 Promise 和其他異步操作:

it('should load data asynchronously', async () => {
  component.loadData();
  fixture.detectChanges();
  
  // 等待所有異步操作完成
  await fixture.whenStable();
  
  expect(component.data.length).toBeGreaterThan(0);
});

常用測試配置

禁用動畫

import { NoopAnimationsModule } from '@angular/platform-browser/animations';

// 在 TestBed 中加入
imports: [NoopAnimationsModule]

Mock 服務

const mockService = {
  getData: jasmine.createSpy('getData').and.returnValue(of(testData)),
  saveData: jasmine.createSpy('saveData').and.returnValue(of({}))
};

beforeEach(() => {
  TestBed.configureTestingModule({
    // ...
    providers: [
      { provide: YourService, useValue: mockService }
    ]
  });
});

處理 Icon 錯誤

Angular Material 的 icon 在測試環境中常見問題:

import { MatIconRegistry } from '@angular/material/icon';
import { of } from 'rxjs';

providers: [
  {
    provide: MatIconRegistry,
    useValue: {
      addSvgIcon: () => {},
      getNamedSvgIcon: () =>
        of(document.createElementNS('http://www.w3.org/2000/svg', 'svg')),
    },
  },
],

測試最佳實踐

1. AAA 模式

Arrange - Act - Assert 是測試的標準結構:

it('should update title when input changes', () => {
  // Arrange - 準備測試資料
  const testTitle = 'Test Title';
  
  // Act - 執行操作
  fixture.componentRef.setInput('title', testTitle);
  fixture.detectChanges();
  
  // Assert - 驗證結果
  expect(component.title()).toBe(testTitle);
});

2. 一個測試一個功能

// ✅ 好的做法 - 每個測試專注於一個功能
it('should have empty string as default value', () => {
  expect(component.title()).toBe('');
});

it('should update title when input changes', () => {
  fixture.componentRef.setInput('title', 'Test');
  fixture.detectChanges();
  expect(component.title()).toBe('Test');
});

// ❌ 避免的做法 - 一個測試包含多個驗證
it('should handle title correctly', () => {
  expect(component.title()).toBe('');
  fixture.componentRef.setInput('title', 'Test');
  fixture.detectChanges();
  expect(component.title()).toBe('Test');
  // 太多功能混在一起
});

3. 有意義的測試描述

// ✅ 清楚的描述
it('should display error message when validation fails', () => {});
it('should emit selectionChange when option is selected', () => {});

// ❌ 模糊的描述
it('should work correctly', () => {});
it('should test functionality', () => {});

4. 適當的測試覆蓋率

重點測試:

  • 公開 API - 輸入屬性、輸出事件、公開方法

  • 商業邏輯 - 核心功能和計算

  • 邊界條件 - 空值、極限值、錯誤情況

  • 使用者互動 - 點擊、輸入、導航

不需要測試:

  • 私有方法 - 透過公開方法間接測試

  • 第三方程式庫 - 假設它們已經經過測試

  • 簡單的 getter/setter - 除非有特殊邏輯

常見錯誤與解決

1. 忘記 detectChanges()

// ❌ 錯誤 - 忘記觸發變更檢測
it('should display title', () => {
  component.title = 'Test';
  // 忘記 fixture.detectChanges();
  const element = fixture.nativeElement.querySelector('.title');
  expect(element.textContent).toBe('Test'); // 可能失敗
});

// ✅ 正確
it('should display title', () => {
  component.title = 'Test';
  fixture.detectChanges(); // 觸發變更檢測
  const element = fixture.nativeElement.querySelector('.title');
  expect(element.textContent).toBe('Test');
});

2. 異步操作處理不當

// ❌ 錯誤 - 沒有等待異步操作
it('should load data', () => {
  component.loadData();
  expect(component.data).toBeDefined(); // 可能失敗
});

// ✅ 正確
it('should load data', async () => {
  component.loadData();
  fixture.detectChanges();
  await fixture.whenStable();
  expect(component.data).toBeDefined();
});

3. 資源清理

describe('Component with subscriptions', () => {
  let subscription: any;

  afterEach(() => {
    // 清理訂閱避免記憶體洩漏
    if (subscription) {
      subscription.unsubscribe();
      subscription = null;
    }
  });

  it('should handle events', () => {
    subscription = component.eventEmitter.subscribe(spy);
    // 測試邏輯...
  });
});

總結

Angular 17 的測試基礎包括:

  • 環境設定 - Standalone 元件的匯入方式

  • 測試工具 - Jasmine、Spy、異步處理

  • 最佳實踐 - AAA 模式、單一功能測試、有意義的描述

  • 常見問題 - 變更檢測、異步操作、資源清理

掌握這些基礎概念後,就可以進入更進階的 ComponentFixture 和 Host Component 測試技巧。

Last updated