iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 20 篇

Day 20|測試不再問元件,改問畫面

  • 分享至 

  • xImage
  •  

我想測 loading,卻找不到 component.loading

第一支測試,我想驗證搜尋時會先顯示載入中。在 Angular,這種測試會寫大概這樣:

component.onSearch('report');
expect(component.loading).toBe(true);

到了 React,我打開測試檔,準備寫一樣的東西,然後停住了。

component 在哪?

SearchBox 是一個函式。它沒有實例,沒有 this,loading 是 useState 裡的一個區域變數,外面完全拿不到。我想呼叫的 onSearch,也只是函式裡的一個區域函式。

找了半天「怎麼從外面讀 React 元件的 state」,最後在 Testing Library 的文件首頁看到一句話,才知道自己問錯問題了。


一、Angular:測試的對象是元件這個物件

先回頭看我在 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);
  });
});

這段測試做了三件事:

  • 透過 DI 把 FileService 換成假的
  • 直接呼叫元件的方法 onSearch
  • 直接檢查元件的屬性 loading、檢查 service 有沒有被呼叫

它測的,是元件這個物件的內部:有哪些方法、哪些屬性、呼叫了哪個 service。

覺得很合理。直到在 React 找不到 component 這個東西。


二、Testing Library:測試的對象是畫面

測試越像使用者實際使用軟體的方式,就越能給你信心。

使用者不會呼叫 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 有一套查詢的優先順序,越前面越好:

  1. getByRole:用無障礙角色找,例如 button、searchbox、heading
  2. getByLabelText:用表單欄位的 label 找
  3. getByText:用畫面上的文字找
  4. 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 把替換的位置從依賴移到網路。

寫到第二十天才真正明白:邊界換得越外面,測試就越不怕你重構裡面。


上一篇
Day 19|分享出去的網址,搜尋條件不見了
下一篇
Day 21|切到下一個檔案,表單還是上一個
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言