에러? 난 그전에 방지해 [프로젝트에 테스트 적용해보기]

승환입니다·2023년 11월 26일
0
post-thumbnail
post-custom-banner

Test 란?

제품 or 서비스의 품질을 확인하는 과정

제품이 예상하는 대로 동작 하는지 확인하는 과정

제품 EX) 함수, 특정한 기능 ,UI , 성능 ,API스펙

Test Code 의 장점?

  • 자신감

  • 기능이 정상 동작

  • 손쉬운 유지보수

  • 이슈에 대해 예측

  • 코드의 품질 향상

  • 좋은 문서화

  • 시간을 절약

  • 버그를 빠르게 발견

  • 요구 사항 만족

    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는 웹 애플리케이션을 테스트하기 위한 오픈 소스 자동화 도구이다. 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문법을 쓸 수 없다.
따로 라이브러리를 설치해주자

cypress에서 RTL문법 쓰기

npm install --save-dev cypress @testing-library/cypress

그 후

import '@testing-library/cypress/add-commands';

support 폴더

어디다가? support폴더안에 e2e파일안에 내가 원하는것을 전부 import하는 곳이다. 말 그대로 서포트

fixtures 폴더

fixtures는 더미데이터 또는 이미지 넣는 곳 !

test가 아닌 it으로

다시 코드로 가보자면 cypress는 테스팅할떄 test(""...)가 아닌 it으로 하는게 필수인거같다. test로 해봤는데 에러가 났었다.

cypress에서 파일 전송?

역시나 라이브러리를 깔아야한다.

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] };
};

분명하게 계산의 영역이 나누어졌고 이렇게 나눈다면 다른 팀원들이 내 코드를 볼때도 함수의 역할을 바로 직관적으로 알 수 있고 순수함수를 테스팅할 떄도 정말 편할 것이라고 생각이 든다 !
테스트를 적용하기 전에도 테스팅을 어떻게 할 건지 생각을 하면서 코드를 짜야한다는것을 느꼇다.

profile
자바스크립트를 좋아합니다.
post-custom-banner

0개의 댓글