在這一年即將結束之際,Angular 世界一直在熱議 Signal Forms 等熱門話題。但悄悄間,一場測試革命正隨著 Angular 21 版本到來,而且來得相當出人意料——距離發布僅剩兩週(撰寫本文時)。
在本文中,我們將探討 Vitest 如何成為新的預設測試框架,非同步測試將如何改變新的無區域 Angular 應用程式,以及 Testronaut 將如何作為 Playwright 元件測試的 Angular 社群版本首次亮相。
目錄
Vitest最重要的不是Vitest本身🙃。
近兩年來,Angular 開發人員一直處於一種測試停滯狀態。
隨著 Karma 於 2023 年被棄用,團隊們開始思考未來的發展方向。他們應該繼續使用舊工具——例如 Jasmine,並期待 Modern Web Test Runner 的出現嗎?其他團隊已經轉向 Jest——尤其是在 Nx monorepos 環境中——但他們不確定是繼續使用 Jest,還是轉向 Vitest(Vitest 也支援 Nx)。
由於缺乏明確的選擇,未來規劃變得非常困難,尤其是對於新專案而言🤷。
現在,隨著 Vitest 宣布將從 Angular 21 開始作為測試框架提供,大家終於鬆了一口氣。
開發人員終於做出了決定,知道該選擇什麼了。
不確定性消失了,這才是最重要的!

選擇Vitest是正確的。
與 Jasmine 甚至 Jest 相比,Vitest 提供了強大的 API、豐富且面向未來的生態系統,該系統以 TypeScript 為先導,並完全支援 ESM 。它還包含瀏覽器模式,這意味著測試可以在真實的瀏覽器環境中執行。
這對Angular團隊來說至關重要。他們希望推進專案,但又不想強迫所有人重寫程式碼。因此,選擇像Jasmine/Karma這樣能在瀏覽器中執行測試的測試框架至關重要。
儘管 API 有所不同——尤其是在非同步測試和模擬方面——但環境保持不變。
Vitest 由 Vite 提供支援。雖然 Angular 的實作並沒有完全依賴 Vitest(Angular 自己建立測試,而不是使用 Vite),但它仍然縮小了與 Vite 生態系統的差距。
最終,Angular 不想再做局外人了。它希望融入更廣泛的 JavaScript 生態系統——而 Vite 正是實現這一目標的關鍵。

圖片顯示,Jest 仍佔優勢,但 Vitest 勢頭正盛。
以下僅列舉Vitest強大API的部分功能:
expect.poll接受一個傳回值的函數。 Vitest 會持續執行斷言,直到逾時或匹配成功為止。
import { expect, test } from 'vitest';
test('wait for the asynchronous tasks to end', async () => {
let a = 1;
setTimeout(() => a++);
Promise.resolve().then(() => a++);
await expect.poll(() => a).toBe(3);
});
軟斷言會執行測試中的所有預期結果,即使其中一個失敗。如果只有一個預期結果失敗,則整個測試都會被標記為失敗。
test('resource', () => {
const todoResource = getSomeResource();
expect.soft(resource.status()).toBe('resolved');
expect.soft(resource.error()).toBeUndefined();
expect.soft(resource.hasValue()).toBe(true);
})
測試上下文允許我們為測試加入不同的功能。可以把它看作是測試的某種「依賴注入」。
在下面的範例中,我們新增了一個wait函數,然後該函數可以作為測試中的一個參數使用。
context.ts
import { test as base } from 'vitest';
interface Fixtures {
wait: (timeout?: number) => Promise<void>;
}
export const test = base.extend<Fixtures>({
wait: async ({}, use) => {
await use(
(timeout = 0) =>
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
);
},
});
async.spec.ts
import { test } from './context';
import { expect } from 'vitest';
test('wait for the asynchronous task', async ({ wait }) => {
let a = 1;
setTimeout(() => a++);
Promise.resolve().then(() => a++);
expect(a).toBe(1);
await wait();
expect(a).toBe(3);
});
Angular 20 中已經可以使用 Vitest 了。例如,這個倉庫已經在使用它了。只要執行ng test (就像使用 Jasmine/Karma 一樣),它就會執行測試。
從 Angular 21 開始,當你執行ng new時,系統會提示你在 Vitest 和 Jasmine 之間進行選擇, Vitest 是預設選項。
我們也會提供遷移示意圖,幫助您從 Jasmine 遷移過來。

Vitest 在 Angular 20 中執行於 ng test 之後
欲了解更多訊息,請查看Matthieu Riegler 的 LinkedIn 帖子——我相信之後還會有更多資源發布。
當然,也別忘了造訪 Vitest 官方網站: https://vitest.dev
waitForAsync()和fakeAsync()</a>到目前為止,Zone.js 在 Angular 處理異步行為方面一直扮演著核心角色。
Zone.js 可以修改諸如setTimeout和setInterval類的計時函數,從而能夠觀察和追蹤框架中的非同步任務。然而,Zone.js 始終無法修改原生 JavaScript Promise ,因為它是語言層級的結構,而不僅僅是一種方法。
在 Angular 測試中,基於 Zone.js 建構的兩個函數可協助管理異步行為: fakeAsync()和waitForAsync() 。
waitForAsync()會等待所有已排程的非同步任務完成,然後再繼續執行測試。
it('should wait for async tasks to end', waitForAsync(() => {
let a = 1;
setTimeout(() => {
a++;
expect(a).toBe(2);
});
}));
這種方法在大多數情況下都有效,但也有其限制——尤其是在非同步任務執行後進行斷言,或將setTimeout安排在很遠的未來(例如 1 秒或更久)時。在這種情況下,測試會一直等待。
it('should wait for too long', waitForAsync(() => {
let a = 1;
setTimeout(() => {
a++;
expect(a).toBe(2);
}, 10_000);
}));
it('should fail to assert an asynchronous task', waitForAsync(() => {
let a = 1;
setTimeout(() => {
a++;
});
expect(a).toBe(2);
}));
fakeAsync()成為首選解決方案。它使開發人員能夠透過tick()和flush()完全控制非同步佇列。 tick tick()模擬時間的流逝,而flush()執行所有待處理的定時器,但週期性定時器(如setInterval除外。
it('runs all asynchronous tasks immediately (synchronously) after flush', fakeAsync(() => {
let a = 1;
setTimeout(() => {
a++;
}, 5_000);
setTimeout(() => {
a++;
}, 5_000);
flush();
expect(a).toBe(3);
}));
it('runs asynchronous tasks tick by tick 😉', fakeAsync(() => {
let a = 1;
setTimeout(() => {
a++;
}, 5_000);
setTimeout(() => {
a++;
}, 5_000);
tick(5_000);
expect(a).toBe(2);
tick(5_000);
expect(a).toBe(3);
}));
從 Angular 20.2 開始,無區域模式變得穩定。在 Angular 21 中,它成為新的預設設定。
這也意味著,除非您明確啟用 Zone.js,否則fakeAsync()和waitForAsync()將停止運作。
雖然很不幸,但這意味著依賴這兩個實用程式的測試將不得不重寫——不是完全重寫,但至少要重寫管理非同步任務的部分。
幸運的是,測試框架提供了自己的非同步控制機制。
在 Vitest 和 Jest 中,此功能稱為偽計時器。
偽定時器模擬非同步 API,例如setTimeout 、 setInterval等。它們的運作方式與waitForAsync和fakeAsync非常相似,但並不完全相同。
runAllTimers() :執行所有非同步任務,包括週期性任務。此外,由其他非同步任務觸發的非同步任務也會被覆寫。這是從flush()函數得知的,而runAllTimers也會覆寫setInterval 。
advanceTimersByTime(ms) : 在一段時間內執行所有非同步任務。類似fakeAsync()中的tick() 。
runOnlyPendingTimers() - 僅執行目前已排程的計時器。
我們必須明確啟用偽計時器,以便測試框架可以先建立模擬物件。
由於這是在beforeEach中完成的,因此我們也需要在afterEach中停用它們。
describe('async tasks', () => {
beforeEach(() => {
vitest.useFakeTimers();
});
afterEach(() => {
vitest.resetAllMocks();
});
it('runs all asynchronous tasks immediately (synchronously)', () => {
let a = 1;
setTimeout(() => {
a++;
}, 5_000);
setTimeout(() => {
a++;
}, 10_000);
vitest.runAllTimers();
expect(a).toBe(3);
});
it('runs asynchronous tasks tick by tick 😉', () => {
let a = 1;
setTimeout(() => {
a++;
}, 5_000);
setTimeout(() => {
a++;
}, 10_000);
vitest.advanceTimersByTime(5_000);
expect(a).toBe(2);
vitest.advanceTimersByTime(5_000);
expect(a).toBe(3);
});
});
還不錯,對吧?
不過這裡有個問題。如上所述,Zone.js 不會編譯成原生 Promise,因為原生 Promise 無法被模擬。偽造的定時器不會進行任何編譯。因此,如果存在原生 Promise,偽造的定時器就無法覆蓋它。
it('fails on Promises', () => {
let a = 1;
Promise.resolve().then(() => a++);
vitest.runAllTimers();
expect(a).toBe(2); // this will fail
});
還有一個變體,將 Promise 和“可模擬”計時器結合起來使用。
it('fails on Promises 1', () => {
let a = 1;
setTimeout(() => Promise.resolve().then(() => a++));
vitest.runAllTimers();
expect(a).toBe(2);
});
it('fails on Promises 2', () => {
let a = 1;
Promise.resolve().then(() => setTimeout(() => a++));
vitest.runAllTimers();
expect(a).toBe(2);
});
怎麼辦?我們可以回退到輪詢,或使用上面測試案例範例中的wait函數。但對於偽定時器也有解決方案。
為了解決這個問題,可以使用偽造的定時器在真正的 Promise 之後加入一個額外的 Promise。然後,我們的測試可以等待第二個 Promise 完成,以確保所有異步行為都已完成。
由於這種情況很常見,Vitest 為我們提供了對應的偽造程式碼。這些程式碼非常容易記憶。實際上,它們與我們之前使用的偽造程式碼相同,只是加入了Async後綴。
describe('async tasks', () => {
beforeEach(() => {
vitest.useFakeTimers();
});
afterEach(() => {
vitest.resetAllMocks();
});
it('succeeds on Promises', async () => {
let a = 1;
Promise.resolve().then(() => a++);
await vitest.runAllTimersAsync();
expect(a).toBe(2);
});
it('succeeds on Promises 1', async () => {
let a = 1;
setTimeout(() => Promise.resolve().then(() => a++));
await vitest.runAllTimersAsync();
expect(a).toBe(2);
});
it('succeeds on Promises 2', async () => {
let a = 1;
Promise.resolve().then(() => setTimeout(() => a++));
await vitest.runAllTimersAsync();
expect(a).toBe(2);
});
});
即使你使用的是 Zone.js,每當你寫新的測試時,也要使用偽計時器。
說實話,無論我們使用 Vitest、Jest 或其他工具,都比不上端對端測試。為什麼呢?
開發者可以在瀏覽器中即時觀看測試執行情況。
端對端框架會自動處理異步行為。無論元件仍在渲染還是等待瀏覽器回應——都沒問題,框架都能幫你搞定。
像testing-library這樣的工具依賴插件來模擬真實的使用者事件。而端對端框架則內建了這項功能。
E2E 測試不僅僅是因為元素存在於 DOM 中就與之互動——它確保元素對於真實用戶來說是真正可用的:可見、不重疊、已啟用等等。
但這種方法也有缺點:端到端框架要求整個應用程式都處於運作狀態。要找到被測功能,通常需要瀏覽整個應用程式,這可能既繁瑣又緩慢。
Cypress Component Testing 為我們展示了一種更好的方法。它可以編譯 Angular 元件,將其掛載到瀏覽器中,並執行完整的端對端測試——但它只專注於元件本身。
那是2022年的情況。如今,首選的端對端框架是Playwright 。它支援元件測試(CT),但僅限於完全支援Vite的框架。
為了讓 Playwright CT 透過 Vite 與 Angular 協同工作,社群付出了巨大的努力。它已經完全實現,運作良好,前景光明。但在 2023 年 11 月,Playwright 團隊關閉了該 PR。他們對 CT 的長期可行性產生了疑慮,並且希望減少框架相關的維護工作。
Testronaut 從灰燼中重生。
Testronaut 由 Younes Jaaidi(本文作者)領導。 Younes 曾參與將 Angular CT 引入 Playwright 的最初工作,此前也為 Cypress CT for Angular 做出了重要貢獻。
Testronaut 是一個社群驅動的 Playwright 元件測試執行器,專為 Angular 設計,但它不會強制 Angular 通過 Vite 編譯。相反,它利用了Angular CLI 。這意味著您可以獲得與使用ng serve相同的建置設定和速度,而無需任何繁瑣的變通方法。
Testronaut 掛載一個元件,讓 Playwright 發揮其最佳功能:執行一個完整的、基於瀏覽器的測試,具備你期望從真正的 E2E 框架中獲得的所有真實性和非同步處理能力。
是的,測試程式碼很簡單。以下是一個範例:
test('should emit an event on click', async ({ mount, page }) => {
await mount(ClickMeComponent, { inputs: { clickMeLabel: 'Press me' } });
await page.getByRole('button', { name: 'Press me' }).click();
await expect(page.getByText('Lift Off!')).toBeVisible();
});
Testronaut 已經可用,但我們建議等到原理圖( ng add @testronaut/angular )發布後再使用,以獲得更流暢的體驗。
原文出處:https://dev.to/rainerhahnekamp/angulars-testing-revolution-vitest-fake-timers-testronaut-2bnj