測試 signals 是軟體開發中非常重要的一部分,但由於時間限制,它很容易被開發團隊忽略。
在應用程式中使用 signals、computed signals、 signal inputs 和 effect 時,負責的團隊會編寫測試案例來驗證其正確性。
這是我第一次寫程式碼來測試 signals;因此,我在寫這篇文章時也學到了新東西。
import { computed, Injectable, signal } from '@angular/core';
@Injectable({
  providedIn: 'root'
})
export class AppService {
  #counter = signal(0);
  value = computed(() => this.#counter());
  increase(num = 1) {
    this.#counter.update((prev) => prev + num);
  }
  decrease(num = 1) {
    this.#counter.update((prev) => prev - num);
  }
  reset() {
    this.#counter.set(0);
  }
}
這是一個偏好問題。我將 #counter signal 和 value computed signal 封裝在 AppService 服務中,但邏輯也可以在 AppComponent 組件中實現,因為它很簡單。
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
@Component({
  selector: 'app-child',
  standalone: true,
  imports: [],
  template: `
    <p>Child works!</p>
    <p data-testId="count">Count: {{ count() }}</p>
    <p data-testId="double">Double: {{ double() }}</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
  count = input(0);
  double = computed(() => this.count() * 2);
} 
ChildComponent 組件由 count signal input 和 double computed signal 組成。
import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core';
import { AppService } from './app.service';
import { ChildComponent } from './child/child.component';
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ChildComponent],
  template: `
    <h1>Hello, {{ title }}</h1>
    <div>
      <p id="value">Value: {{ appService.value() }}</p>
      <button id="increase" (click)="increase(1)">Add 1</button>
      <button id="increase2" (click)="increase(2)">Add 2</button>
      <button id="decrease" (click)="decrease(1)">Decrease</button>
      <button id="decrease2" (click)="decrease(2)">Decrease 2</button>
      <button id="reset" (click)="reset()">Reset</button>  
      <app-child [count]="appService.value()" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
  title = 'day29-signal-testing';
  appService = inject(AppService);
  increase(num = 1) {
    this.appService.increase(num);
  }
  decrease(num = 1) {
    this.appService.decrease(num);
  }
  reset() {
    this.appService.reset();
  }
}
export const buttonClick = <T>(el: DebugElement, fixture: ComponentFixture<T>,
   target: HTMLElement, expected: string) => {   
   el.nativeElement.click();
   fixture.detectChanges();
   expect(target.textContent).toBe(expected);
}
export const getElement = <T>(fixture: ComponentFixture<T>, key: string): DebugElement => {
   return fixture.debugElement.query(By.css(key));
}
在 test/button-test.util.ts 檔案中新增兩個測試用例將重複呼叫的實用函數。 getElement 函數透過 fixture 和 CSS 選擇器 查詢 DebugElement。 buttonClick 函數使用 HTML 元素執行單擊,觸發 change detection,並比較元素文字是否與預期結果相同。
el.nativeElement.click();
fixture.detectChanges();
該元素執行單擊,然後發生 change detection。
expect(target.textContent).toBe(expected);
將元素文字與預期結果進行比較。如果比較為正確的,則測試通過。否則,測試失敗,我需要修正它。
// app.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { ChildComponent } from './child/child.component';
describe('AppComponent', () => {
  let fixture: ComponentFixture<AppComponent>;
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent, ChildComponent],
    }).compileComponents();
    fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
  });
}
我們可以在 app.component.spec.ts 檔案中找到測試案例。 beforeEach 函數在每個測試案例之前執行,以編譯 AppComponent 組件、初始化 fixture 變數並觸發 change detection。
it('should increase the counter by 1', () => {  
    const value: HTMLParagraphElement = getElement(fixture, '[id="value"]').nativeElement;
   expect(value.textContent).toBe('Value: 0');
   const el = getElement(fixture, '[id="increase"]');     
   buttonClick(el, fixture, value, 'Value: 1');
});
第一個測試案例驗證 #counter signal 增加 1 並且 value computed signal 顯示正確的值。 第一個 expect 語句驗證 paragrah 元素 'Value: 0'。 呼叫可重複使用實用函數後,測試程式碼更具可讀性。
const el = getElement(fixture, '[id="increase"]');     
buttonClick(el, fixture, value, 'Value: 1');
然後,測試用例查詢 "Add 1" 按鈕並將其傳遞給 buttonClick 函數。此函數點擊按鈕,在執行 change detection 後更新 signal,並驗證元素文字為 'Value:1'。
it('should increase the counter by 2 after 2 button clicks', () => {  
    const value: HTMLParagraphElement = getElement(fixture, '[id="value"]').nativeElement;
   expect(value.textContent).toBe('Value: 0');
    const el = getElement(fixture, '[id="increase"]');
   buttonClick(el, fixture, value, 'Value: 1');
   buttonClick(el, fixture, value, 'Value: 2');
});
此測試案例驗證點擊兩次按鈕後 paragraph 元素是否顯示正確的值。 點擊按鈕並進行 change detection。點擊第一次按鈕後,paragraph 元素顯示 'Value:1'。 再次按一下相同按鈕,會出現另一個 change detection。此元素顯示預期值 'Value:2'。
it('should update the child component', () => {
    const value: HTMLParagraphElement = getElement(fixture, '[id="value"]').nativeElement;
   expect(value.textContent).toBe('Value: 0');
   const el = getElement(fixture, '[id="increase2"]');
   buttonClick(el, fixture, value, 'Value: 2');
   const count: HTMLParagraphElement = getElement(fixture, '[data-testId="count"]').nativeElement;
   const double: HTMLParagraphElement = getElement(fixture, '[data-testId="double"]').nativeElement;
   expect(count.textContent).toBe('Count: 2');
   expect(double.textContent).toBe('Double: 4');
});
ChildComponent 組件是 AppComponent 組件的子元件。我想驗證 input 更新時子組件顯示正確的值。此測試案例查詢 "Add 2" 按鈕,模擬按鈕單擊和 change detection。 paragraph元素顯示正確的 'Value:2'。
const count: HTMLParagraphElement = getElement(fixture, '[data-testId="count"]').nativeElement;
expect(count.textContent).toBe('Count: 2');
第一行查詢 ChildComponent 顯示 count signal input 的 paragraph 元素。按鈕點擊後 input 變為2;因此,該元素顯示 'Value:2'。 expect 語句驗證元素文字是否與預期結果相同。
const count: HTMLParagraphElement = getElement(fixture, '[data-testId="double"]').nativeElement;
expect(count.textContent).toBe('Count: 4');
第一行查詢 ChildComponent 顯示 double computed signal 的 parapgrah 元素。按鈕點擊後輸入變為2;因此, double computed signal 重新計算為 4。 expect 語句驗證元素文字是否與預期結果相同。
// child.component.spec.ts
let fixture: ComponentFixture<ChildComponent>;
beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ChildComponent]
    })
    .compileComponents();
    fixture = TestBed.createComponent(ChildComponent);
    fixture.detectChanges();
});
在 child.component.spec.ts 中,我們初始化 fixture 和 component 變數來測試 signal input。
it('should update the signal input', () => {
   const count: HTMLParagraphElement = getElement(fixture, '[data-testId="count"]').nativeElement;
   const double: HTMLParagraphElement = getElement(fixture, '[data-testId="double"]').nativeElement;
    expect(count.textContent).toBe('Count: 0');  
    expect(double.textContent).toBe('Double: 0');
    fixture.componentRef.setInput('count', 1);  
    fixture.detectChanges();
    expect(count.textContent).toBe('Count: 1');
    expect(double.textContent).toBe('Double: 2');
});
signal input為 0,因此兩個段落元素最初都顯示 'Count: 0' 和'Double: 0'。
fixture.componentRef.setInput('count', 1);  
fixture.detectChanges();
我使用 componentRef.setInput 將 count input 設為 1,並觸發 change detection。
expect(count.textContent).toBe('Count: 1');
expect(double.textContent).toBe('Double: 2');
count 段落元素顯示 'Value:1'。 double computed signal 變為 2,因此段落元素顯示 'Double: 2'。
我無法讓 effect 測試用例發揮成功;因此,我不會在這篇文章中包含任何 effect 的測試案例。
signal、 computed()、 input() 和 effect。change detection 以更新訊號 (signal)。然後,編寫CSS來查詢HTML元素以驗證結果。鐵人賽的第 29 天到此結束。