哈囉,各位邦友們!
今天要來學習單元測試 + E2E 測試,確保每次重構或新增功能都能放心使用。
hero-journey/src/app/hero.service.spec.ts
與 hero-detail.spec.ts
擴充實際測試案例。ng e2e
smoke test,驗證整體流程。Angular CLI v20 預設使用 Karma + Jasmine。在 repo 根目錄執行:
npm run test
會啟動瀏覽器並 watch 檔案。
我們在 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,直接呼叫即可確認是否同步更新。Day22 把 HeroDetail
改寫成 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
正常運作。querySelector
) 確認模板輸出,避免只驗證內部 private 狀態。Angular CLI 目前在 ng e2e
找不到目標時會詢問要安裝哪個方案。選擇 Playwright(對應套件 playwright-ng-schematics
),專案會自動新增:
playwright.config.ts
tests/example.spec.ts
package.json
的 e2e
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 端對端測試讓我們在部署、升級相依套件後仍能快速確認主線流程。
測試成為與效能、部署同等重要的一環,讓 累積的成果有持續演進的底氣。
參考資料: