제품 or 서비스의 품질을 확인하는 과정
⇒ 제품이 예상하는 대로 동작 하는지 확인하는 과정
제품 EX) 함수, 특정한 기능 ,UI , 성능 ,API스펙
자신감
기능이 정상 동작
손쉬운 유지보수
이슈에 대해 예측
코드의 품질 향상
좋은 문서화
시간을 절약
버그를 빠르게 발견
요구 사항 만족
Unit Test
⇒ 단위 테스트 , 함수 ,모듈, 클래스 등
Integration Test
⇒ 통합 테스트 , 함수들 , 클래스들
E2E Test
⇒End to End 테스트 , 전부
리액트 쿼리로 짠 코드에 통합 테스트
를 넣어봤다.
나는 jest의 mock.fn()
또는 route/api
를 활용해서 가짜 api를 만든 후 테스트를 할 줄 알았는데
공식문서에서는 nock
이라는 새로운 몫 라이브러리를 써서 테스팅했다.
새로운 라이브러리를 한번 써보고 싶어서 나도 nock을 적용해보며 테스팅을 진행했다.
결론은 처음엔 낯설었지만 굉장히 간편해서 자주 쓸 거 같다 !!
잘 짠 코드인지는 모르겠지만 실행은 잘되어서 기분이 좋당 🙂
내가 진행한 테스트는 로그인 성공에 대한 테스트이다.
아래 코드는 api 로직이다.
const { mutate } = useMutation({
mutationFn: (data: UserLogin) => PostLogin(data),
onSuccess: (result) => {
Swal.fire({
icon: 'success',
title: '로그인에 성공하셨습니다 !',
text: '메인페이지로 이동합니다.',
});
setCookie('token', result.userId, { maxAge: 60 * 6 * 24 });
router.push('/');
},
onError: (error) => {
Swal.fire({
icon: 'error',
title: '아이디 또는 비밀번호를 확인해주세요',
});
},
});
아이디 비밀번호가 들어간 data 객체를 mutate해주면 로그인되는 아주 간단한 api이다.
그럼 테스트 코드를 한번 보자
const useLogin = () => {
return useMutation({
mutationFn: async (data: { accountID: string; password: string }) => {
try {
const response = await axios.post(
'http://localhost:8080/users/login',
data,
);
return response.data; // 반환값은 API 응답에서 원하는 데이터로 수정
} catch (error) {
// 오류 처리
console.error('API 호출 중 오류:', error);
throw error;
}
},
});
};
test('[Success] 로그인 성공 ', async () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const id = screen.getByTestId('id-input');
const password = screen.getByTestId('password-input');
const button = screen.getByRole('button', { name: '로그인' });
expect(id).toBeInTheDocument();
expect(password).toBeInTheDocument();
expect(button).toBeInTheDocument();
await userEvent.type(id, 'sun123123');
await userEvent.type(password, '123123');
await userEvent.click(button);
const idValue: string = (id as HTMLInputElement).value;
const passwordValue: string = (password as HTMLInputElement).value;
nock('http://localhost:8080')
.post('/users/login', {
accountID: 'sun123123',
password: '123123',
})
.reply(200, { user: 'seunghwan' });
const { result } = renderHook(() => useLogin(), { wrapper });
await act(async () => {
await result.current.mutate({
accountID: idValue,
password: passwordValue,
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ user: 'seunghwan' });
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
});
현재 리팩토링이 되지 않는 날 것?의 코드다.
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
react-query를 쓰기위해서 QueryClientProvider로 children을 감싸주었다.
const id = screen.getByTestId('id-input');
const password = screen.getByTestId('password-input');
const button = screen.getByRole('button', { name: '로그인' });
로그인을 하기 위해서는 id와 password를 적고 로그인 버튼을 눌러야하기때문에 getBy쿼리로 각각 다 접근을 해주었다.
expect(id).toBeInTheDocument();
expect(password).toBeInTheDocument();
expect(button).toBeInTheDocument();
접근한 쿼리들이 document위에 잘 존재하는지 검사했다.
await userEvent.type(id, 'sun123123');
await userEvent.type(password, '123123');
await userEvent.click(button);
id 와 password를 넣어주고 로그인 버튼을 누르는 테스트이다.
nock('http://localhost:8080')
.post('/users/login', {
accountID: 'sun123123',
password: '123123',
})
.reply(200, { user: 'seunghwan' });
const { result } = renderHook(() => useLogin(), { wrapper });
await act(async () => {
await result.current.mutate({
accountID: idValue,
password: passwordValue,
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ user: 'seunghwan' });
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
});
nock이라는 가짜 mock 라이브러리를 활용해서 http://localhost:8080/users/login으로 api를 보내는걸 인터셉터해서 반환값을 바꿔주었다.
그 후 useLogin함수를 호출한다.
반환된 mutate로 api를 호출해준다.
import {
act,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import nock from 'nock';
import { useLogin } from '@/src/tests/login';
import { WithAllContexts } from '../../tests/utils';
import Login from './page';
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
}),
}));
describe('로그인 컴포넌트', () => {
beforeEach(() => {
render(
<WithAllContexts>
<Login />
</WithAllContexts>,
);
});
test('[Error] 아이디 또는 비밀번호를 입력안했을 시 alert창 띄우기', async () => {
const id = screen.getByTestId('id-input');
const password = screen.getByTestId('password-input');
const button = screen.getByRole('button', { name: '로그인' });
expect(id).toBeInTheDocument();
expect(password).toBeInTheDocument();
await userEvent.type(id, 'testUser');
expect(id).toHaveValue('testUser');
expect(password).toHaveValue('');
await userEvent.click(button);
const alert = screen.getByRole('dialog');
expect(alert).toBeInTheDocument();
});
test('[Error] 아이디가 있다면 유저 아이콘이 나온다', async () => {
const id = screen.getByTestId('id-input');
await userEvent.type(id, 'seunghwan');
const icon = screen.getByTestId('id-icon');
expect(icon).toBeInTheDocument();
});
test('[Error] 비밀번호를 입력했을 시 아이콘이 나오고 타입이 바뀐다', async () => {
const password = screen.getByTestId('password-input');
await userEvent.type(password, 'testPassword');
const visibleIcon = screen.getByTestId('password_invisible_icon');
expect(visibleIcon).toBeInTheDocument();
await userEvent.click(visibleIcon);
const invisibleIcon = screen.getByTestId('password_visible_icon');
expect(invisibleIcon).toBeInTheDocument();
expect(password).toHaveAttribute('type', 'text');
});
test('[Error] 회원가입하러가기는 /signUp이라는 링크를 가지고있다.', async () => {
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/signUp');
});
test('[Success] 로그인 성공 ', async () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const id = screen.getByTestId('id-input');
const password = screen.getByTestId('password-input');
const button = screen.getByRole('button', { name: '로그인' });
expect(id).toBeInTheDocument();
expect(password).toBeInTheDocument();
expect(button).toBeInTheDocument();
await userEvent.type(id, 'sun123123');
await userEvent.type(password, '123123');
await userEvent.click(button);
const idValue: string = (id as HTMLInputElement).value;
const passwordValue: string = (password as HTMLInputElement).value;
nock('http://localhost:8080')
.post('/users/login', {
accountID: 'sun123123',
password: '123123',
})
.reply(200, { hi: 'hi' });
const { result } = renderHook(() => useLogin(), { wrapper });
await act(() => {
result.current.mutate({
accountID: idValue,
password: passwordValue,
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ hi: 'hi' });
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
});
});
아직 모자른게 많지만 천천히 배우면서 리팩토링 해봐야겠다.. !
프로젝트에 통합테스트를 이어서 E2E 테스트를 넣어보고 싶어서 공부를 한 후 적용해봤다. 복잡한 로직도 없고 간단한 테스트를 한거지만 나한테는 어려웠기떄문에 적용하면서 배운 점을 기록하려고 한다.
다 처음 적용한거라 틀린 내용이 많다 🥹
많이 본 테스트 피라미드..
우선 나는 유닛 , 통합 , e2e에서 어떠한 테스트를 해야 가장 효율적일까 생각을 했었다.
결론은 유닛테스트
에서는 순수 함수를 테스팅하는게 좋다. UI테스트의 경우에는 추후에 기획이 바뀌면서 UI가 바뀔수가 있다. 즉 , 다시 유닛테스트를 짜야할수도 있다는 뜻이다.
유닛테스트는 바뀌지않는 중요 로직을 테스팅하는게 중요한 것 같다.
통합테스트
는 API로직을 테스트하고 컴포넌트 단위로 테스팅한다. API로직이 정상적으로 호출되는지가 중요한 것 같다.
E2E 테스트
는 UI테스트를 진행하고 내가 사용자 입장이 되어서 어떤 버튼을 누를지 예상 후 코드를 짜는게 중요하다.
이제 다시 cypress로
Cypress는 웹 애플리케이션을 테스트하기 위한 오픈 소스 자동화 도구이다. Cypress는 JavaScript로 작성되었으며, 개발자와 QA(Quality Assurance) 팀이 웹 애플리케이션을 효과적으로 테스트하고 디버깅하는 데 도움을 주는 강력한 도구이다.
Cypress는 주로 엔드 투 엔드(e2e) 테스트 및 통합 테스트를 위해 사용되며, 개발자와 QA 팀 간의 협업을 강화하고 안정적이고 효과적인 웹 애플리케이션을 개발하는 데 기여합니다.
라는 -chatgpt-
npm install cypress --save-dev
이거하면 끝 !
그러면 root경로에 coverage 폴더랑 cypress가 생긴다.
coverage 폴더는 굉장히 용량이 크기 떄문에 git ignore해주자
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});
baseUrl에 자신의 포트에 맞게 설정해주자 이렇게하면 cy.visit("localhost:....") 이런식인 반복되는 코드를 줄일 수 있다.
여기서 visit은 내가 visit안에 쓴 경로로 라우팅하겠다는 뜻이다.
it('[SUCCESS] 글 쓰기 테스트', () => {
cy.setToken();
cy.getCookie('token').should('have.property', 'value', '1');
cy.findByText('글 쓰기').click({ multiple: true });
cy.wait(5000);
const imagePath = '../fixtures/images/carrot.png';
cy.get('label')
.click({ multiple: true })
.get('input[type="file"]')
.attachFile({ filePath: imagePath });
cy.get(
'.PostProductInfo_postProductInfo__8S14h > :nth-child(1) > input',
).type('당근이');
cy.get(':nth-child(2) > input').type('5000');
cy.get('textarea').type('신선한 당근 급쳐합니다');
cy.get('.CategorySelect_selectBox__FDZPd').click({ multiple: true });
cy.get('.Options_options__BCRBr > :nth-child(5)').click({ multiple: true });
// cy.screenshot();
cy.findByRole('button', { name: '완료' }).click({ multiple: true });
});
여러 테스트 코드 중 일부다.
글 쓰기페이지를 테스팅한건데 한줄 한줄 읽어보면 유닛테스트를 작성해본 사람들이라면 정말 간단한 코드이다.
하지만 cypress에서는 findby...같은 RTL문법을 쓸 수 없다.
따로 라이브러리를 설치해주자
npm install --save-dev cypress @testing-library/cypress
그 후
import '@testing-library/cypress/add-commands';
어디다가? support폴더안에 e2e파일안에 내가 원하는것을 전부 import하는 곳이다. 말 그대로 서포트
fixtures는 더미데이터 또는 이미지 넣는 곳 !
다시 코드로 가보자면 cypress는 테스팅할떄 test(""...)가 아닌 it으로 하는게 필수인거같다. test로 해봤는데 에러가 났었다.
역시나 라이브러리를 깔아야한다.
npm i cypress-file-upload
import 'cypress-file-upload';
const imagePath = '../fixtures/images/carrot.png';
cy.get('label')
.click({ multiple: true })
.get('input[type="file"]')
.attachFile({ filePath: imagePath });
쓰는법은 간단하다 내 image 경로를 넣어주고 file속성인 input태그를 클릭하고 attachFile , 즉 파일을 넣어준다. 끝 ! 간단간단
테스트의 개념과 간단한 단위테스트 ,통합테스트, e2e테스트를 작성해봤다.
cypress는 시각적으로 전체적인 애플리케이션이 오류없이 동작하는 모습을 보니까 신기했다.
신기하고 새로운건 잠시였고 생각해보니까
내가 테스팅 이론을 배우면서 들었던 말 중에 e2e테스트
는 비용이 크다
, 가성비가 안좋다
라는 말을 들었었는데 맞는것같다..!
만약 기획 또는 디자이너가 UI를 바꿔달라고 하거나 기능이 바뀐다면?
나는 기능도 , 테스트도 다시 짜야한다.
즉 일을 두번
해야한다는 것이다.
만약 회사에서 테스트를 도입
해야한다면 비용이크고 가성비가 안좋은 e2e테스트를 꼭 도입을 해야하는지 잘 판단하고 넣어야겠다.
반면 유닛테스트는 필수
인것같다. 작고 변하지않는 중요한 로직
을 가볍게 테스팅할 수 있기때문이다.
추가로 테스트를 편하게하려면 테스트를 하기쉽게 코드를 짜라
라는 말이 정말 와닿았다.
내 개인적인 생각으로 단위테스트는 변하지않는 순수함수
를 테스팅하는게 단위테스트의 핵심이라고 생각한다.
하지만 내 코드를 보면 의존되어있는 코드들이 정말 많았다.
예를들면 아래와 같다.
const clickChangeData = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLInputElement;
if (title === '대표 지역선택') {
if (target.innerText === '대표 지역선택') {
target.innerText = '';
}
setSelectValue((pre) => {
return { ...pre, area: target.innerText };
});
} else if (title === '시/구/군') {
if (target.innerText === '시/구/군') {
target.innerText = '';
}
setSelectValue((pre) => {
return { ...pre, city: target.innerText };
});
} else {
if (target.innerText === '카테고리') {
target.innerText = '';
}
setSelectValue((pre) => {
return { ...pre, category: target.innerText };
});
}
};
코드를 보면 데이터
, 계산
, 액션
으로 코드의 역할을 나누었을 때 이 함수에는 세개가 전부 섞여 있다. 이렇게되면 기능상 문제는 없지만 코드를 나눌수도 , 그리고 유지보수가 힘들어진다. 유지보수가 힘들어진다는것은 테스트를 짜기 힘든 코드라는 뜻이기도 하다.
그렇다면 나중에 유지보수를 "나"를 위해 리팩토링을 해보자
const clickChangeData = (e: React.MouseEvent<HTMLDivElement>) => {
// 데이터
const target = e.target as HTMLInputElement;
//계산
const option = selectOption(target.innerText, title);
// 액션
setSelectValue((pre) => {
return { ...pre, [option.location]: option.text };
});
};
// 계산
export const selectOption = (text: string, title: string) => {
let result;
switch (title) {
case '대표 지역선택':
if (text === '대표 지역선택') {
text = '';
}
result = ['area', text];
break;
case '시/구/군':
if (text === '시/구/군') {
text = '';
}
result = ['city', text];
break;
default:
if (text === '카테고리') {
text = '';
}
result = ['category', text];
break;
}
return { location: result[0], text: result[1] };
};
분명하게 계산의 영역이 나누어졌고 이렇게 나눈다면 다른 팀원들이 내 코드를 볼때도 함수의 역할을 바로 직관적으로 알 수 있고 순수함수를 테스팅할 떄도 정말 편할 것이라고 생각이 든다 !
테스트를 적용하기 전에도 테스팅을 어떻게 할 건지 생각을 하면서 코드를 짜야한다는것을 느꼇다.