상위 컴포넌트에서 하위 컴포넌트에 위임한 처리까지 테스트하면 상위 컴포넌트의 책임이 불분명해진다.
상위 컴포넌트는 연동하는 부분에만 집중해 테스트를 작성하면 테스트 목적 컴포넌트간 책임도 분명해진다.
프로바이더
프로바이더를 사용하는 전역 UI를 대상으로 실시하는 테스트의 중점은 다음과 같다.
Provider
의 상태에 따라 렌더링 여부가 변경됨
Provider
의 갱신 함수로 상태를 갱신 가능
1. 테스트용 컴포넌트를 만들어 인터렉션 실행하기
useToastAction
이라는 커스텀 훅을 사용하면 최하위 컴포넌트에서도 <Toast>
렌더링 가능
이를 테스트하고자 테스트용으로만 사용할 컴포넌트를 만들어 실제와 비슷한 상황을 재현 해보자.
showToast
를 실행할 수만 있으면 되기 때문에 버튼을 클릭하면 showToast
가 실행되도록 구현 ㄱ
Copy // 테스트용 컴포넌트
const TestComponent = ({ message } : { message : string }) => {
const { showToast } = useToastAction ();
return < button onClick ={() => showToast ( message )}>show </ button >
}
테스트 render
함수로 최상위 컴포넌트인 <TestProvider>
와 하위 컴포넌트인 <TestComponent>
를 렌더링 한다.
Copy test ( "showToast를 호출하면 Toast 컴포넌트가 표시된다." , async () => {
const message = 'test' ;
render (
< TestProvider >
< TestComponent message = {message} />
</ TestProvider >
)
expect ( screen .queryByRole ( "alert" )). not .toBeInTheDocument ();
await user .click ( screen .getByRole ( "button" ));
expect ( screen .getByRole ( "alert" )) .toHaveContent (message)
})
2. 초깃값을 주입해서 렌더링된 내용 확인하기
<TestProvider>
는 Props
에 defaultState
라는 초깃값을 설정할 수 있도록 구현됨
단순히 렌더링 여부를 확인하고 싶은거면 defaultState
에 초깃값을 주입해 검증하면 됨
Copy test ( "showToast를 호출하면 Toast 컴포넌트가 표시된다." , async () => {
const state : ToastState = {
isShown : true ,
message : "성공했습니다" ,
style : "succeed" ,
}
render (
< TestProvider defaultState = {state} > {null} </ TestProvider >
)
expect ( screen .getByRole ( "alert" )) .toHaveContent ( state .message)
})
Next.js 라우터 렌더링 통합 테스트
라우터 (페이지 이동과 URL을 관리하는 기능)와 관련된 UI 컴포넌트의 통합 테스트
Next.js에서 라우터 부분을 테스트하려면 목 객체를 사용해야 한다.
next-router-mock
: 제스트에서 Next.js 라우터를 테스트할 수 있도록 목 객체를 제공하는 라이브러리
useRouter
를 활용한 URL 참조 혹은 변경에 대한 통합 테스트를 jsdom에서 실행 가능
Copy test ( "현재 위치는 'My Posts'이다" , () => {
mockRouter .setCurrentUrl ( "/my/posts" ); //현재 url이 /my/posts 라고 가정
})
Copy import mockRouter from 'next-router-mock' ;
test ( "현재 위치는 '...'이다" , () => {
mockRouter .setCurrentUrl ( "/my/posts/create" );
render (< Nav onCloseMenu ={() => {}} />);
const link = screen .getByRole ( "link" , {name : "Create Post" });
expect (link) .toHaveAttribute ( "aria-current" , "page" );
})
test.each 활용
동일한 테스트를 매개변수만 변경해 반복하고 싶다면 test.each
사용해보자.
Copy test .each ([
{ url : "/my/posts" , name : "My Posts" } ,
{ url : "/my/posts/123" , name : "My Posts" } ,
{ url : "/my/posts/create" , name : "Create Posts" } ,
])( "$url의 현재 위치는 $name이다" , ({ url , name }) => {
mockRouter .setCurrentUrl (url);
render (< Nav onCloseMenu ={() => {}} />);
const link = screen .getByRole ( "link" , {name});
expect (link) .toHaveAttribute ( "aria-current" , "page" );
})
입력 통합 테스트
Copy import { render , screen } from '@testing-library/react'
import mockRouter from 'next-router-mock' ;
function setup (url = "/my/posts?page=1" ) {
mockRouter .setCurrentUrl (url);
render (< Header />)
const combobox = screen .getByRole ( "combobox" , { name : "공개 여부" });
return { combobox }
}
test ( "기본값으로 '모두가 선택됨" , async () => {
const { combobox } = setup ();
expect (combobox) .toHaveDisplayValue ( "모두" )
})
test ( "status?=public 으로 접속하면 '공개'가 선택됨" , async () => {
const { combobox } = setup ( "/my/posts?status=public" );
expect (combobox) .toHaveDisplayValue ( "공개" )
})
인터렉션 테스트
인터렉션 함수를 별도로 만들어 테스트 코드에서 UI 컴포넌트의 입력을 재현하는 코드를 직관적으로 바꿔보자
Copy import { render , screen } from '@testing-library/react'
import mockRouter from 'next-router-mock' ;
const user = userEvent .setup ();
function setup (url = "/my/posts?page=1" ) {
mockRouter .setCurrentUrl (url);
render (< Header />)
const combobox = screen .getByRole ( "combobox" , { name : "공개 여부" });
async function selectOption (label : string ) {
await user .selectOptions (combobox , label);
}
return { combobox , selectOption }
}
test ( "공개 여부를 변경하면 status가 변경된다." , async () => {
const { selectOption } = setup ();
expect (mockRouter) .toMatchObject ({query : {page : "1" }});
await selectOption ( "공개" );
expect (mockRouter) .toMatchObject ({
query : { page : "1" , status : "public" } ,
})
await selectOption ( "비공개" );
expect (mockRouter) .toMatchObject ({
query : {page : "키" , status : "private"
})
})
검증 범위를 좁히면 UI 컴포넌트의 책임과 이에 따른 테스트 코드가 더욱 명확해진다.
React Hook Form 테스트
폼은 전송하기 전에 입력된 내용을 참조하기 때문에 폼을 구현할 떄 먼저 '어디에서 입력 내용을 참조할 것인지 '를 정해야 한다.
제어 컴포넌트 : useState
를 사용해 컴포넌트 단위로 상태를 관리하는 컴포넌트
제어 컴포넌트로 구현된 폼은 관리 중인 상태를 필요한 타이밍에 웹 API로 보낸다.
비제어 컴포넌트 : 폼을 전송할 떄 <input>
등의 입력 요소에 브라우저 고유 기능을 사용해 값을 참조하도록 구현함
전송시 직접 값을 참조하기 때문에 useState
상태를 관리하지 않아도 되며, ref
로 DOM의 값을 참조
value
, onChange
를 따로 지정하지 않는다.
제어 컴포넌트에서 useState
로 지정한 초깃값은 defaultValue
로 대체
React Hook Form은 비제어 컴포넌트 로 고성능 폼을 쉽게 작성할 수 있도록 도와주는 라이브러리
입력 요소를 참조하는 ref
, 이벤트 핸들러를 자동으로 생성하고 설정해준다.
Copy const { register , handleSubmit } = useForm ({
defaultvalues : { search : q } ,
})
register
는 전송 시 참조할 입력 내용으로 '등록한다'는 의미
register
함수를 사용하는것만으로 참조와 전송 준비가 완료됨
Copy < input type = "search" { ... register ( "search" )} />
폼 유효성 검사 테스트
입력 내용에 따라 어떤 유효성 검사가 실시되는지에 중점을 두고 테스트
React Hook Form에는 하위 패키지로 resolver
가 있다.
여기에 입력 내용을 검증할 유효성 검사 스키마 객체를 할당 가능
Copy export const PostForm = (props : Props ) => {
const {
register ,
setValue ,
handleSubmit ,
control ,
formState: { erros , isSubmitting } ,
} = useForm < PostInput >({
resolver : zodResolver (createMyPostInputSchema) , // 입력 내용 유효성 검사 스키마
});
return (
< form
aria - label = {props.title}
className = {styles.module}
onSubmit = { handleSubmit (props.onValid , props.onInvalid)}
>
)
}
Copy // src/lib/schema/MyPosts.ts
import * as z from 'zod' ;
export const createMyPostInputSchema = z .object ({
title : z .string () .min ( 1 , '한 글자 이상의 문자를 입력해주세요' ) ,
description : z .string () .nullable () ,
body : z .string () .nullable () ,
published : z .boolean () ,
imageUrl : z .string ({ required_error : "이미지를 선택해주세요" }) .nullable () ,
})
React Hook Form의 handleSubmit
함수의 인수는 함수를 직접 인라인으로 작성하지 않고 Props
에서 취득한 이벤트 핸들러를 지정 할 수도 있다.
Copy type Props < T extends FieldValues = PostInput > = {
title : string ;
children ?: React . ReactNode ,
onClickSave : (isPublish : boolean ) => void ,
onValid : SubmitHandler < T >;
onInvalid ?: SubmitErrorHandler < T >;
}
< form onSubmit = { handleSubmit (props.onValid , props.onInvalid)}/>
해당 컴포넌트의 책임
유효하지 않은 내용이 전송되면 onInvalid
실행
인터렉션 테스트
인터렉션 테스트를 위해 설정 함수에 인터랙션 함수를 추가한다. (개별 인터렉션들을 반환)
Copy async function setup () {
const { container } = render (< Default />);
const { selectImage } = selectImageFile ();
async function typeTitle (title : string ) {
const textbox = screen .getByRole ( "textbox" , {name : "제목" });
await user .type (textbox , title);
}
async function saveAsPublished () {
await user .click ( screen .getByRole ( "switch" , {name : "공개 여부" }));
await user .click ( screen .getByRole ( "button" , {name : "공개하기" }));
await screen .findByRole ( "alertdialog" );
}
async function saveAsDraft () {
await user .click ( screen .getByRole ( "button" , {name : "비공개 상태로 저장" }));
}
async function clickButton (name : "네" | "아니오" ) {
await user .click ( screen .getByRole ( "button" , { name }));
}
return {container , typeTitle , saveAsPublished , saveAsDraft , clickButton , selectImage}
}
Copy test ( "공개를 시도하면 AlertDialog가 표시된다." , async () => {
const { typeTitle , saveAsPublished , selectImage } = await setup ();
await typeTitle ( "201" );
await selectImage ();
await saveAsPublished ();
expect ( screen .getByText ( "기사를 공개합니다. 진행하시겠습니까?" )) .toBeInTheDocument ();
})
test ( "API 통신을 시작하면 '저장 중입니다...'가 표시된다" , async () => {
const { typeTitle , saveAsPublished , clickButton , selectImage } = await setup ();
await typeTitle ( "hoge" );
await selectImage ();
await saveAsPublished ();
await clickButton ( "네" );
await waitFor (() => expect ( screen .getByRole ( "alert" ) .toHaveTextContent ( "공개됐습니다." ))
})
화면 이동 테스트
화면 이동은 웹 API 호출이 정상적으로 종료된 후에 발생한다.
waitFor
함수로 mockRouter
의 pathname
이 특정 페이지와 일치하는지 검증
Copy test ( "공개에 성공하면 화면을 이동한다" , async () => {
const { typeTitle , saveAsPublished , clickButton , selectImage } = await setup ();
await typeTitle ( "201" );
await selectImage ();
await saveAsPublished ();
await clickButton ( "네" );
await waitFor (() => expect (mockRouter) .toMatchObject ({ pathname : "/my/posts/201" }))
})
이미지 업로드 통합 테스트
파일 업로드 기능은 E2E 테스트에서도 검증 가능하고 통합 테스트에서도 검증 가능하다.
컴퓨터에 저장된 이미지를 선택하여 업로드 시도
이미지 업로드에 성공하면 프로필 이미지로 적용
jest로 재현할 수 없는 처리들은 mock 객체를 이용하자.
이미지를 선택하는 mock 함수
테스트 환경인 jsdom은 브라우저 API 제공하지 않으므로 이미지 선택(Browser API)를 사용할 수 없음
user.upload
를 호출해 이미지 선택 인터렉션 재현하기
Copy export function selectImageFile (
inputTestId = 'file' ,
fileName = 'hello.png' ,
content = 'hello'
) {
const user = userEvent .setup ();
// 더미 이미지 파일 작성
const filePath = [ 'C:\\fakepath\\${fileName}`];
const file = new File ([content] , fileName , { type : "image/png" });
// render한 컴포넌트에서 data-testid="file"인 input을 취득
const fileInput = screen .getByTestId (inputTestId);
// 이 함수를 실행하면 이미지 선택이 재현됨
const selectImage = () => user .upload (fileInput , file);
return { fileInput , filePath , selectImage };
}
이미지 업로드 API를 호출하는 mock 함수
이미지 업로드 API를 호출하게되면 Next.js의 API Routes에 요청이 발생하고 AWS S3에 이미지를 업로드 하는 처리가 실행됨
이와 같은 처리까지 UI 컴포넌트 테스트에서 실행하면 본 목적에서 벗어남
그래서 목 함수를 설정해 정해진 응답이 오도록 설정
Copy //__mock__/jest.ts
import { ErrorStatus , HttpError } from '@/lib/error' ;
import * as UploadImage from '../fetcher' ;
import { uploadImageData } from './fixture' ;
jest .mock ( '../fetcher' );
export function mockUploadImage (status : ErrorStatus ) {
if (status && status > 299 ) {
return jest
.spyOn (UploadImage , "uploadImage" )
.mockRejectedValueOnce ( new HttpError (status) .serialize ());
}
return jest
.spyOn (UploadImage , "uploadImage" )
.mockResolvedValueOnce (uploadImageData);
}
Copy test ( "이미지 업로드에 성공하면 이미지의 src 속성이 변경됨" , async () => {
mockUploadImage ()
render (< TestComponent />);
expect ( screen .getByRole ( 'img' ) .getAttribute ( "src" )) .toBeFalsy ();
const { selectImage } = selectImageFile ();
await selectImage ();
await waitFor (() =>
expect ( screen .getByRole ( "img" ) .getAttribute ( "src" )) .toBeTruthy ()
})
test ( "이미지 업로드에 실패하면 경고창이 표시된다." , async () => {
mockUploadImage ( 500 ); // 실패하는 목함수
render (< TestComponent />);
expect ( screen .getByRole ( 'img' ) .getAttribute ( "src" )) .toBeFalsy ();
const { selectImage } = selectImageFile ();
await selectImage ();
await waitFor (() =>
expect ( screen .getByRole ( "alert" ) .toHaveTextContent (
"이미지 업로드에 실패했습니다"
)
})
Last updated 5 months ago