React 서버 컴포넌트, 어떻게 테스트할까? (공식 지원 전까지의 유일한 해답)
Next.js가 React 서버 컴포넌트(RSC)를 지원하기 시작한 이래로, 저 역시 제 프로젝트에 적극적으로 사용하고 있습니다.
서버에서 데이터를 가져와야 하는 모든 컴포넌트가 더 이상 브라우저에서 데이터를 페칭할 필요가 없다는 점은 정말 합리적이라고 생각합니다.
더 적은 코드, 더 낮은 복잡성, 그리고 더 적은 버그를 의미하니까요.
하지만 RSC가 대중에게 공개된 지 몇 년이 지났음에도, Next.js 팀과 React 팀 모두 아직 서버 컴포넌트를 테스트할 공식적인 방법을 제공하지 않고 있습니다.
심지어 LLM(대규모 언어 모델)조차 그 방법을 모르더군요.
스스로 생각할 수 없으니 어찌 보면 당연한 일입니다.
다행히도 Next.js와 React는 오픈소스이고, 서버 컴포넌트를 테스트하고 싶었던 사람이 저 혼자만은 아니었습니다.
저는 Steven Robert라는 개발자가 공유한 하나의 Gist를 발견했고, 이를 사용해 서버 컴포넌트를 테스트할 수 있었습니다.
조금은 '핵(hack)'에 가깝지만, 확실하게 동작합니다.
해결책: 커뮤니티에서 찾은 헬퍼 함수
핵심은 서버 컴포넌트가 본질적으로 '비동기 함수'라는 점을 이용하는 것입니다.
이 헬퍼 함수는 테스트 환경에서 이 비동기 함수를 실행하여 일반 React 엘리먼트로 '해결(resolve)'한 다음, React Testing Library의 render
함수에 넘겨주는 방식으로 동작합니다.
아래가 바로 그 마법 같은 코드입니다.
// From https://gist.github.com/sroebert/a04ca6e0232a4a60bc50d7f164f101f6
import type { PropsWithChildren, ReactElement, ReactNode } from 'react'
import React, { Children, cloneElement, isValidElement } from 'react'
import { render } from '@testing-library/react'
function setFakeReactDispatcher<T>(action: () => T): T {
/**
* We use some internals from React to avoid a lot of warnings in our tests when faking
* to render server components. If the structure of React changes, this function should still work,
* but the tests will again print warnings.
*
* If this is the case, this function can also simply be removed and all tests should still function.
*/
if (!('__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE' in React)) {
return action()
}
const secret = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE
if (!secret || typeof secret !== 'object' || !('H' in secret)) {
return action()
}
const previousDispatcher = secret.H
try {
secret.H = new Proxy(
{},
{
get() {
throw new Error('This is a client component')
},
}
)
} catch {
return action()
}
const result = action()
secret.H = previousDispatcher
return result
}
async function evaluateServerComponent(node: ReactElement): Promise<ReactElement> {
if (node && node.type?.constructor?.name === 'AsyncFunction') {
// Handle async server nodes by calling await.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const evaluatedNode: ReactElement = await node.type({ ...node.props })
return evaluateServerComponent(evaluatedNode)
}
if (node && node.type?.constructor?.name === 'Function') {
try {
return setFakeReactDispatcher(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const evaluatedNode: ReactElement = node.type({ ...node.props })
return evaluateServerComponent(evaluatedNode)
})
} catch {
// If evaluating fails with a function node, it might be because of using client side hooks.
// In that case, simply return the node, it will be handled by the react testing library render function.
return node
}
}
return node
}
async function evaluateServerComponentAndChildren(node: ReactElement) {
const evaluatedNode = (await evaluateServerComponent(node)) as ReactElement<PropsWithChildren>
if (!evaluatedNode?.props.children) {
return evaluatedNode
}
const children = Children.toArray(evaluatedNode.props.children)
for (let i = 0; i < children.length; i += 1) {
const child = children[i]
if (!isValidElement(child)) {
continue
}
children[i] = await evaluateServerComponentAndChildren(child)
}
return cloneElement(evaluatedNode, {}, ...children)
}
// Follow <https://github.com/testing-library/react-testing-library/issues/1209>
// for the latest updates on React Testing Library support for React Server
// Components (RSC)
export async function renderServerComponent(nodeOrPromise: ReactNode | Promise<ReactNode>) {
const node = await nodeOrPromise
if (isValidElement(node)) {
const evaluatedNode = await evaluateServerComponentAndChildren(node)
return render(evaluatedNode)
}
return render(node)
}
이 코드는 어떻게 동작할까요?
이 코드는 크게 세 부분으로 나눌 수 있습니다.
- 'evaluateServerComponent': 이 함수의 역할은 간단합니다.
컴포넌트의 타입이 'AsyncFunction'(즉, 서버 컴포넌트)이면,await
를 사용해 해당 함수를 실행하고 그 결과(Promise가 해결된 값)를 반환합니다.
이것이 전체 로직의 핵심입니다. - 'setFakeReactDispatcher': 이름에서 알 수 있듯, 이 함수는 React의 내부 동작을 잠시 속이는 '트릭'입니다.
서버 컴포넌트를 평가하는 동안 클라이언트 컴포넌트에서만 사용 가능한 훅(예:useState
)이 호출되면 에러를 발생시키도록 하여, 테스트 환경이 마치 서버 환경인 것처럼 행동하게 만듭니다.
이를 통해 불필요한 경고 메시지를 피할 수 있습니다. - 'renderServerComponent': 최종적으로 우리가 테스트 코드에서 호출할 함수입니다.
이 함수는 서버 컴포넌트와 그 자식들을 재귀적으로 평가(evaluate
)하여 일반 React 엘리먼트로 만든 뒤, 우리가 잘 아는@testing-library/react
의render
함수에 전달합니다.
프로젝트에 적용하는 방법
적용 방법은 매우 간단합니다.
- 프로젝트의 테스트 관련 폴더(예:
src/lib/testing
)에render-server-component.tsx
같은 이름으로 새 파일을 만듭니다. - 위 코드를 그대로 복사하여 붙여넣습니다.
- 이제 테스트하고 싶은 서버 컴포넌트가 있다면,
render
대신renderServerComponent
를 사용하면 됩니다.
예를 들어, 아래와 같은 서버 컴포넌트가 있다고 가정해 봅시다.
// src/components/Greeting.tsx
async function getGreetingMessage() {
await new Promise(resolve => setTimeout(resolve, 100)); // DB 조회 시뮬레이션
return "Hello, Server Component!";
}
export async function Greeting() {
const message = await getGreetingMessage();
return <h1>{message}</h1>;
}
이 컴포넌트를 테스트하는 코드는 다음과 같습니다.
// src/components/Greeting.test.tsx
import { screen } from '@testing-library/react';
import { Greeting } from './Greeting';
import { renderServerComponent } from '../lib/testing/render-server-component';
test('should render the greeting message', async () => {
// render 대신 renderServerComponent를 사용하고 await를 붙여줍니다.
await renderServerComponent(<Greeting />);
// findBy* 쿼리를 사용해 비동기적으로 렌더링된 텍스트를 찾습니다.
const heading = await screen.findByRole('heading', {
name: /Hello, Server Component!/i,
});
expect(heading).toBeInTheDocument();
});
결론
언젠가는 React나 Next.js에서 서버 컴포넌트를 테스트할 수 있는 공식적인 방법을 제공할 것이라고 희망합니다.
하지만 그전까지는, 커뮤니티의 지혜가 담긴 이 방법이 우리가 가진 최선의, 그리고 가장 효과적인 해결책입니다.
서버 컴포넌트의 테스트 부재로 고민하고 있었다면, 이 방법을 한번 시도해 보세요.
여러분의 테스트 커버리지를 한 단계 더 끌어올릴 수 있을 것입니다.