component.loading第一支測試,我想驗證搜尋時會先顯示載入中。在 Angular,這種測試會寫大概這樣:
component.onSearch('report');
expect(component.loading).toBe(true);
到了 React,我打開測試檔,準備寫一樣的東西,然後停住了。
component 在哪?
SearchBox 是一個函式。它沒有實例,沒有 this,loading 是 useState 裡的一個區域變數,外面完全拿不到。我想呼叫的 onSearch,也只是函式裡的一個區域函式。
找了半天「怎麼從外面讀 React 元件的 state」,最後在 Testing Library 的文件首頁看到一句話,才知道自己問錯問題了。
先回頭看我在 Angular 習慣的寫法:
describe('FileListComponent', () => {
let fixture: ComponentFixture<FileListComponent>;
let component: FileListComponent;
const fileService = { search: jasmine.createSpy().and.returnValue(of([])) };
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FileListComponent],
providers: [{ provide: FileService, useValue: fileService }],
});
fixture = TestBed.createComponent(FileListComponent);
component = fixture.componentInstance;
});
it('搜尋時呼叫 service', () => {
component.onSearch('report');
expect(fileService.search).toHaveBeenCalledWith('report');
expect(component.loading).toBe(true);
});
});
這段測試做了三件事:
FileService 換成假的onSearch
loading、檢查 service 有沒有被呼叫它測的,是元件這個物件的內部:有哪些方法、哪些屬性、呼叫了哪個 service。
覺得很合理。直到在 React 找不到 component 這個東西。
測試越像使用者實際使用軟體的方式,就越能給你信心。
使用者不會呼叫 onSearch,也看不到 loading 這個變數。他們看到的是一個搜尋框,打字,然後畫面上出現「載入中」,接著出現結果。
所以 React 的測試寫成這樣:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('搜尋後顯示結果', async () => {
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'report');
expect(await screen.findByText('Q3 report.pdf')).toBeInTheDocument();
});
沒有 component、沒有 fixture.detectChanges()、沒有檢查任何內部狀態。
找到搜尋框、打字、等結果出現。 就像一個使用者在操作。
這也解釋了我為什麼找不到 component.loading:不是 React 藏起來了,是 Testing Library 刻意不讓你碰。 元件內部用 useState 還是 TanStack Query、變數叫 loading 還是 isPending,都是實作細節,使用者不在乎,測試也不該在乎。
Testing Library 有一套查詢的優先順序,越前面越好:
getByRole:用無障礙角色找,例如 button、searchbox、heading
getByLabelText:用表單欄位的 label 找getByText:用畫面上的文字找getByTestId:最後手段,用 data-testid
這個順序本身就是一種提醒:如果你只能用 test id 找到一個按鈕,代表螢幕閱讀器的使用者大概也找不到它。 寫測試的過程,順便檢查了無障礙。
另外有三種前綴要分清楚:getBy 找不到會直接報錯,queryBy 找不到回傳 null(用來確認某個東西不在畫面上),findBy 會等一段時間(預設一秒),用在資料要非同步出現的時候。
上面那支測試有個問題:SearchBox 會真的去打 API。
在 Angular,我會用 DI 把 service 換掉。React 這邊,第一個念頭是用 vi.mock 把 API 模組換掉:
vi.mock('~/lib/api', () => ({
api: { search: vi.fn().mockResolvedValue([{ id: '1', name: 'Q3 report.pdf' }]) },
}));
能動,但我很快就後悔了。
把整套手刻的 useSearch 換成 TanStack Query,API 的呼叫方式也順手改了:api.search 多了一個 signal 參數,回傳的資料結構也調整過。畫面上的行為完全沒變,但這支 mock 了模組的測試全部壞掉——因為它綁死了「元件內部是怎麼呼叫 API 的」。
Angular 的測試靠替換依賴,React 的測試傾向替換邊界。
今天才真的搞懂。這裡說的「邊界」,是網路。
MSW(Mock Service Worker)會在網路層攔截請求,回傳假的回應:
// test/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/search', ({ request }) => {
const q = new URL(request.url).searchParams.get('q');
return HttpResponse.json([{ id: '1', name: `Q3 ${q}.pdf` }]);
}),
];
// test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
元件照常呼叫 api.search,api.search 照常用 axios 發請求,請求在快要出門的那一刻被 MSW 接住,回傳假資料。從元件到 axios 的每一行程式碼,都是真的在跑。
想測錯誤情況,就在單一測試裡換掉 handler:
it('API 失敗時顯示錯誤訊息', async () => {
server.use(
http.get('/api/search', () => new HttpResponse(null, { status: 500 })),
);
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'report');
expect(await screen.findByRole('alert')).toHaveTextContent('搜尋失敗');
});
後來把之前寫的那批測試全部改成 MSW 版本,再回頭把資料層從手刻 hook 換成 TanStack Query 來回切了一次。測試一行都沒改,全部綠燈。
那一刻我才真正理解,「測行為、不測實作」換來的是什麼:可以放心重構。
至於 Day 2 那個用 Context 注入 FakeApiService 的做法,在這套思路下其實很少用到了。Context 注入留給真的有「多種實作要切換」的情況;單純為了測試,攔網路就夠了。
Angular 的 TestBed.configureTestingModule 是一個現成的容器:要什麼 provider,往裡面塞。
React 這邊,元件需要什麼,測試就得自己包好。我們的元件會用到 TanStack Query 跟 React Router,所以寫了一個共用的 render:
// test/render.tsx
export function renderWithProviders(ui: ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
兩個細節是踩過才知道的:
每支測試都要一個新的 QueryClient。 共用的話,上一支測試的快取會留到下一支,測試之間互相汙染。這跟「SSR 時不能共用 QueryClient」是同一個道理,只是場景從使用者換成了測試。
retry 要關掉。 TanStack Query 預設失敗會重試三次,錯誤情況的測試會等到逾時才失敗。
至於有 loader、action 的路由元件,React Router 提供了 createRoutesStub,可以在測試裡組出一個小型的路由,連同假的 loader 一起渲染。路由層的測試我還在摸索,這裡先點到為止。
不是所有東西都適合「從畫面測」。Day 11 那個 useDebouncedValue,它的價值就在於時間控制,從畫面很難精準驗證。
這種純邏輯的 hook,可以用 renderHook 單獨測,再搭配假的時鐘:
import { renderHook, act } from '@testing-library/react';
it('停手 300ms 才更新', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'r' } },
);
rerender({ value: 're' });
expect(result.current).toBe('r'); // 還沒到 300ms,維持舊值
act(() => { vi.advanceTimersByTime(300); });
expect(result.current).toBe('re'); // 時間到,更新了
vi.useRealTimers();
});
這裡測的是 hook 的回傳值,算是「測實作」。但 hook 的回傳值本來就是它對外的介面,就像測一個函式的回傳值一樣,並不違反原則。
我的分界是:元件從畫面測,共用的 hook 跟工具函式從介面測,內部的 state 永遠不碰。
| Angular | React | |
|---|---|---|
| 測試容器 | TestBed |
自己包 providers 的 render |
| 取得元件 | fixture.componentInstance |
沒有,也不需要 |
| 觸發行為 | 呼叫元件方法 | userEvent 模擬使用者操作 |
| 檢查結果 | 檢查屬性、檢查 spy | 檢查畫面上看得到的東西 |
| 找元素 | querySelector、By.css |
getByRole、getByLabelText |
| 更新畫面 | fixture.detectChanges() |
自動,非同步用 findBy 等待 |
| 替換 API | DI 換掉 service | MSW 在網路層攔截 |
| 測 hook / 邏輯 | 直接 new service 測 | renderHook |
補充一句公道話:Testing Library 其實也有 Angular 版本,Angular 一樣可以用「從畫面測」的方式寫測試。只是 TestBed 讓「直接摸元件內部」太容易,我三年來從來沒有理由換。
我以為今天要學的是另一套測試 API,結果換掉的是測試的對象。
在 Angular,我測的是元件這個物件:它有什麼方法、什麼屬性、呼叫了哪個 service。DI 讓替換依賴變得很容易,fixture.componentInstance 讓摸進內部變得很容易,於是我的測試自然而然地綁在實作上。
在 React,函式元件根本沒有可以摸的內部。Testing Library 順著這件事,把測試的對象換成使用者看到的畫面;MSW 把替換的位置從依賴移到網路。
寫到第二十天才真正明白:邊界換得越外面,測試就越不怕你重構裡面。