iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Modern Web

Angular:踏上現代英雄之旅系列 第 29

Day 29|測試:單元測試與 E2E 測試

  • 分享至 

  • xImage
  •  

哈囉,各位邦友們!
今天要來學習單元測試 + E2E 測試,確保每次重構或新增功能都能放心使用。

今天要做什麼?

  1. 啟動 Angular 單元測試環境。
  2. hero-journey/src/app/hero.service.spec.tshero-detail.spec.ts 擴充實際測試案例。
  3. 啟用 Playwright,撰寫第一支 ng e2e smoke test,驗證整體流程。

一、啟動 Angular 單元測試環境

Angular CLI v20 預設使用 Karma + Jasmine。在 repo 根目錄執行:

npm run test

會啟動瀏覽器並 watch 檔案。

二、HeroService 單元測試:Signals 快取不能說謊

我們在 Day21 引入 signal 快取英雄清單,測試時需要替代 HTTP 並驗證快取行為。

以下範例直接覆寫 src/app/hero.service.spec.ts

import { TestBed } from '@angular/core/testing';
import { Hero, HeroService } from './hero.service';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';

describe('HeroService', () => {
  let service: HeroService;
  let http: HttpTestingController;

  const mockHeroes: Hero[] = [
    { id: 11, name: 'Dr Nice', rank: 'B', skills: ['Healing'] },
    { id: 12, name: 'Narco', rank: 'A', skills: ['Stealth'] },
  ];

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(HeroService);
    http = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    http.verify();
  });

  it('loadAll() 會把清單寫進 heroesState()', () => {
    let received: Hero[] | undefined;

    service.loadAll().subscribe((heroes) => (received = heroes));

    const req = http.expectOne('api/heroes');
    expect(req.request.method).toBe('GET');
    req.flush(mockHeroes);

    expect(received).toEqual(mockHeroes);
    expect(service.heroesState()).toEqual(mockHeroes);
  });

  it('getById() 會優先回傳快取資料', () => {
    service.loadAll().subscribe();
    http.expectOne('api/heroes').flush(mockHeroes);

    service.getById(11).subscribe();

    http.expectNone('api/heroes/11');
  });

  it('create() 會修剪輸入並把新英雄加入 signal', () => {
    service.create({ name: '  Shadow  ', rank: 'S', skills: ['Dash'] }).subscribe();

    const req = http.expectOne('api/heroes');
    expect(req.request.method).toBe('POST');
    expect(req.request.body).toEqual({ name: 'Shadow', rank: 'S', skills: ['Dash'] });

    req.flush({ id: 99, name: 'Shadow', rank: 'S', skills: ['Dash'] });

    expect(service.heroesState().some((hero) => hero.id === 99)).toBeTrue();
  });
});

重點拆解:

  • provideHttpClientTesting() 提供 HttpTestingController,讓我們能攔截並檢查 HTTP 請求。
  • 透過 http.expectNone() 擔保快取命中時不會多送一支請求。
  • heroesState() 是只讀 signal,直接呼叫即可確認是否同步更新。

三、HeroDetail 元件測試:Signals + rxResource 的 UI 識別

Day22HeroDetail 改寫成 rxResource(),測試上需要 stub 服務並檢查畫面條件渲染。

更新 src/app/hero-detail/hero-detail.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeroDetail } from './hero-detail';
import { HeroService } from '../hero.service';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { of, throwError } from 'rxjs';

describe('HeroDetail', () => {
  let fixture: ComponentFixture<HeroDetail>;
  let component: HeroDetail;
  let heroService: jasmine.SpyObj<HeroService>;

  beforeEach(async () => {
    heroService = jasmine.createSpyObj('HeroService', ['getById']);

    await TestBed.configureTestingModule({
      imports: [HeroDetail],
      providers: [
        { provide: HeroService, useValue: heroService },
        provideRouter([], withComponentInputBinding()),
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(HeroDetail);
    component = fixture.componentInstance;
  });

  it('顯示英雄資訊並產出 avatar URL', () => {
    heroService.getById.and.returnValue(
      of({ id: 12, name: 'Narco', rank: 'A', skills: ['Stealth'] })
    );

    fixture.componentRef.setInput('id', 12);
    fixture.detectChanges();

    const heading: HTMLElement = fixture.nativeElement.querySelector('h2');
    const avatar: HTMLImageElement = fixture.nativeElement.querySelector('img');

    expect(heading.textContent).toContain('Narco');
    expect(avatar.src).toContain('Narco'.toLowerCase());
  });

  it('服務丟出錯誤時顯示錯誤訊息', () => {
    heroService.getById.and.returnValue(throwError(() => new Error('Boom')));

    fixture.componentRef.setInput('id', 99);
    fixture.detectChanges();

    const banner: HTMLElement = fixture.nativeElement.querySelector('app-message-banner');
    expect(banner.textContent).toContain('Boom');
  });

  it('reload() 會重新觸發資料載入', () => {
    heroService.getById.and.returnValues(
      of({ id: 11, name: 'Dr Nice' }),
      of({ id: 11, name: 'Dr Nice', rank: 'B' })
    );

    fixture.componentRef.setInput('id', 11);
    fixture.detectChanges();
    expect(heroService.getById).toHaveBeenCalledTimes(1);

    component.reload();
    fixture.detectChanges();

    expect(heroService.getById).toHaveBeenCalledTimes(2);
  });
});

重點說明:

  • 直接呼叫 fixture.componentRef.setInput() 取代路由輸入。
  • 使用 provideRouter([]) 讓模板中的 routerLink 正常運作。
  • 透過 DOM 查詢 (querySelector) 確認模板輸出,避免只驗證內部 private 狀態。

四、Playwright 端到端測試

Angular CLI 目前在 ng e2e 找不到目標時會詢問要安裝哪個方案。選擇 Playwright(對應套件 playwright-ng-schematics),專案會自動新增:

  • playwright.config.ts
  • tests/example.spec.ts
  • package.jsone2e script 以及 Playwright 相關依賴

執行初始命令:

ng e2e

首次執行會提示安裝 Playwright 及瀏覽器,可依指示完成 npx playwright install

接著在 tests/example.spec.ts 放入以下測試,覆蓋「搜尋 + 詳細頁」的測試

import { test, expect } from '@playwright/test';

test('heroes search smoke flow', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'Heroes' }).click();
  await expect(page).toHaveURL(/\/heroes$/);

  await page.getByLabel('Search heroes').fill('Narco');
  await expect(page.getByText('命中 1 位英雄')).toBeVisible();

  await page.getByRole('link', { name: 'View' }).first().click();
  await expect(page).toHaveURL(/detail\/\d+/);
  await expect(page.getByRole('heading', { level: 2 })).toContainText('Narco');
});

今日小結:
今天實作了單元測試接住資料層與 UI 狀態,確保 Signals 與 Reactive Forms 的行為不被改壞。
也學會透過 Playwright 端對端測試讓我們在部署、升級相依套件後仍能快速確認主線流程。
測試成為與效能、部署同等重要的一環,讓 累積的成果有持續演進的底氣。

參考資料:


上一篇
Day 28|變更檢測:Zoneless
系列文
Angular:踏上現代英雄之旅29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言