티스토리 뷰

728x90
SMALL

배경

테스트에 최근에 관심이 생겨서 백엔드는 해봤는데 프론트 개발자가 프론트 테스트를 제대로 해보지 않았다. 그래서 차근차근 조금씩 학습할 예정이다.

 

프론트엔드에서 테스트가 필요한 이유

백엔드는 당연히 테스트가 필요하다. db에 접근하기 때문에 실수가 나오면 안되고 ui를 보고 직접 테스트하는게 불가능하기 때문에 어차피  수동으로 테스트를 해볼려면 일일히 postman을 이용해서 하나하나 테스트해봐야 한다. 그리고 많은 쿼리가 들어오면 서버가 터지기도 하기 때문에 진짜 필수다. 그런데 프론트에서도 필요할까??? 사실 간단한 프로젝트에서는 필요하지 않다. 그냥 버튼 몇 번 클릭해보면 눈으로 확인 가능하니 말이다. 하지만 프로젝트 규모가 커지고 실제로 배포해서 사용자가 있는 서비스에서는 사소한 오류라도 나와서는 안된다.(테스트 코드를 만들고 오류를 잡는 분들이 있어도 무조건 오류가 나온다.) 게다가 프론트엔트가 복잡해지면서 테스트의 중요성이 더욱 올라가고 있다. 리팩토링을 할 때도 실수로 작동하는 방식이 달라지는 경우가 있는데 이런 경우에도 유용하다. 나도 몇달전까지만 해도 조금 회의적이였지만 최근에 생각이 바뀌게 되어서 테스트를 공부하고 있다. 여담이지만 나는 필요성을 느낄 때 공부하는 것을 좋아한다.

 

프론트엔드에서의 테스트 종류

단위 테스트

단위 테스트는 각 모듈을 단독 실행 환경에서 독립적으로 테스트하는 것을 말한다. 유틸함수를 검증할 수도 있고 리액트에서 렌더링 테스트를 할 수도 있고 ui 테스트를 할 수도 있고 이전과 다음을 비교하는 스냅샷 테스트를 할 수도 있다.

통합 테스트

통합 테스트는 두 개 이상의 모듈이 실제로 연결된 상태를 테스트하는 것을 말한다. 단위 테스트만으로는 부족한 경우를 채워줄 수 있다.

E2E 테스트

E2E 테스트는 실제 사용자의 입장 및 환경에서 테스트하는 것을 말한다. 프론트엔드에서 E2E 테스트는 실제 브라우저를 실행해서 테스트하는 것을 말한다.

 

참조블로그 https://blog.mathpresso.com/%EB%AA%A8%EB%8D%98-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A0%84%EB%9E%B5-1%ED%8E%B8-841e87a613b2

 

그래서 뭐부터 사용하지??

나는 일단 단위테스트부터 해볼려고 한다. jest를 이용한 모킹과 유틸함수는 백엔드에서 경험해봤고 redux saga도 jest를 이용해 테스트를 해봤다. 하지만 렌더링 테스트와 스토리북을 이용한 ui 테스트는 해보지 않았다. 일단 지금 상태에서 나는 렌더링 테스트가 더 중요하다고 생각한다. 이벤트에 따른 변화를 테스트하지 않고 확실하다고 말하기는 어렵다. 그래서 직접 입력하고 버튼을 클릭하고 로그를 찍어보고는 했다. 테스트를 한다면 내가 생각한 예외 상황을 언제나 알 수 있고 일일히 이벤트를 발생시키지 않아도 보다 확실하게 검증할 수 있다. 그래서 나는 리액트 테스트 첫번째 글로 렌더링 테스트를 적기로 마음먹었다.

 

렌더링 테스트 라이브러리

라이브러리를 조금 찾아봤다. 리액트 공식문서에서는 Jest, Mocha, ava 등의 라이브러리를 소개한다. 나는 jest를 사용한 경험이 있고 만족했기 때문에 이것을 사용할 것이다. 그 외에 react testing library가 존재하는데 이것을 이용하면 조금 더 편하게 사용할 수 있다. 그래서 이 두가지를 설치했다.

  "devDependencies": {
    "jest": "^27.4.3",
    "ts-jest": "^27.1.1",
    "@testing-library/jest-dom": "^5.16.1",
    "@testing-library/react": "^12.1.2",
    "@testing-library/user-event": "^13.5.0"
  }

 

기본 설정하기

jest config는 express 글에서 간단하게 소개했었다. 그래서 간단하게만 소개하겠다. 기본 jest 사용법을 모른다면 아래 글을 참조하자

https://ms3864.tistory.com/394

 

express typescript typeorm환경에서 테스트하기1(기본 jest 사용법)

배경 jest는 우아한테크캠프에서 조금 공부하고 사용해보긴 했다. 그런데 사가 테스트에만 사용을 했었고 tdd방식으로 만들어본적도 없다. 또한 백엔드가 진짜 테스트가 중요한데 백엔드에서 적

ms3864.tistory.com

 

transform 부분은 리액트와 typescript라서 설정해주는 것이고 setupFilesAfterEnv로 각 테스트 파일이 실행되기 전에 지정한 파일을 실행시킨다. 또한 moduleNameMapper로 절대경로를 설정해준다.(절대경로를 사용하지 않는다면 필요없다)

 

jest.config.js

module.exports = {
  transform: {
    '^.+\\.(js|jsx)?$': 'babel-jest',
    '^.+\\.(ts|tsx)?$': 'ts-jest',
  },
  testEnvironment: 'jsdom',
  testMatch: ['<rootDir>/**/*.test.(js|jsx|ts|tsx)'],
  setupFilesAfterEnv: ['<rootDir>/setup-tests.ts'],
  transformIgnorePatterns: ['<rootDir>/node_modules/'],
  modulePaths: ['<rootDir>/src'],
  moduleNameMapper: {
    '@/(.*)$': '<rootDir>/src/$1',
  },
};

 

jest-dom이 제공하는 matcher를 Jest 테스트 러너에게 인식시킨다.

 

 

setup-test.ts

import '@testing-library/jest-dom/extend-expect';

 

package.json

  "scripts": {
    "test": "jest --config ./jest.config.js"
  },

 

카운트 컴포넌트

어떤 컴포넌트로 테스트해볼까 생각하다가 가장 간단한 increase, decrease 버튼으로 숫자를 조작하는 컴포넌트가 생각났다. react, redux에서 가장 많이 사용되는 예제가 아닌가 싶다.

 

count-ex.tsx

import React, { useCallback, useState } from 'react';
import styled from 'styled-components';

const Wrapper = styled.div``;

const CountEx: React.FC = () => {
  const [count, setCount] = useState(0);
  const onIncrease = useCallback(() => {
    setCount((count) => count + 1);
  }, []);
  const onDecrease = useCallback(() => {
    setCount((count) => count - 1);
  }, []);
  return (
    <Wrapper>
      <button type="button" onClick={onIncrease}>
        increase
      </button>
      <button type="button" onClick={onDecrease}>
        decrease
      </button>
      <div data-testid="count">{count}</div>
    </Wrapper>
  );
};

export default CountEx;

간단한 컴포넌트를 만들었다.

 

import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import CountEx from './count-ex';

const renderComplex = () => {
  const { getByText, getByTestId } = render(<CountEx />);
  const increaseBtn = getByText('increase');
  const decreaseBtn = getByText('decrease');
  const count = getByTestId('count');
  return { increaseBtn, decreaseBtn, count };
};

먼저 처음에 함수를 만들었다. 이렇게 한 이유는 가독성과 재사용 코드를 줄이기 위해서다. toast ui의 글을 보고 참조했다. render는 react의 render를 생각하면 된다. jest-dom에 렌더해주는 것이다. 이 라이브러리를 설치하지 않으면 react-dom을 import해서 직접 해줘야 된다. getByText는 불러온 컴포넌트에서 text로 html 태그를 찾고 getByTestId는 data-testid로 html 태그를 찾는다. toBeInTheDocument는 jest-dom에 존재하는지 확인해주는 함수다. 기본 jest 사용법은 적지 않겠다.

 

이제 실제 테스트 코드를 하나하나 살펴보자

 

  it('should render default component', () => {
    const { increaseBtn, decreaseBtn, count } = renderComplex();
    expect(increaseBtn).toBeInTheDocument();
    expect(decreaseBtn).toBeInTheDocument();
    expect(count).toBeInTheDocument();
    expect(count.textContent).toBe('0');
  });

먼저 처음에 렌더링되는 컴포넌트를 확인한다. 사실 toBe('0')을 제외하고는 필요한가?? 하는 의문점이 들기는 한다. 어차피 무조건 렌더링 되는데 말이다. 통합 테스트라면 얘기가 다르지만... 일단은 진행해보자

 

  it('should increase count', () => {
    const { increaseBtn, count } = renderComplex();
    fireEvent.click(increaseBtn);
    expect(count.textContent).toBe('1');
  });

  it('should decrease count', () => {
    const { decreaseBtn, count } = renderComplex();
    fireEvent.click(decreaseBtn);
    expect(count.textContent).toBe('-1');
  });
  
  it('should same count', () => {
    const { increaseBtn, decreaseBtn, count } = renderComplex();
    fireEvent.click(increaseBtn);
    fireEvent.click(decreaseBtn);
    expect(count.textContent).toBe('0');
  });

fireEvent로 클릭 이벤트를 발생시킨다. 위에서 설치한 user-event를 사용할 수도 있는데 user-event는 fireEvent의 상위 개념이라고 보면 된다. 다음 컴포넌트에서 설명하겠다. 마침 설명할 내용이 나온다. 그리고 그냥 expect, tobe를 사용하면 끝이다.

 

count-ex.test.tsx

import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import CountEx from './count-ex';

const renderComplex = () => {
  const { getByText, getByTestId } = render(<CountEx />);
  const increaseBtn = getByText('increase');
  const decreaseBtn = getByText('decrease');
  const count = getByTestId('count');
  return { increaseBtn, decreaseBtn, count };
};

describe('<CountEx />', () => {
  it('should render default component', () => {
    const { increaseBtn, decreaseBtn, count } = renderComplex();
    expect(increaseBtn).toBeInTheDocument();
    expect(decreaseBtn).toBeInTheDocument();
    expect(count).toBeInTheDocument();
    expect(count.textContent).toBe('0');
  });

  it('should increase count', () => {
    const { increaseBtn, count } = renderComplex();
    fireEvent.click(increaseBtn);
    expect(count.textContent).toBe('1');
  });

  it('should decrease count', () => {
    const { decreaseBtn, count } = renderComplex();
    fireEvent.click(decreaseBtn);
    expect(count.textContent).toBe('-1');
  });

  it('should same count', () => {
    const { increaseBtn, decreaseBtn, count } = renderComplex();
    fireEvent.click(increaseBtn);
    fireEvent.click(decreaseBtn);
    expect(count.textContent).toBe('0');
  });
});

 

폼 컴포넌트

이번에는 로그인 폼으로 만들어볼까 한다. 로그인창에서는 onchange(input) 이벤트로 state가 변경되고 submit, disabled 처리도 해야 한다. 간단하지만 테스트하기에는 적절한 경우다.

 

useinputs

import { useReducer, useCallback } from 'react';

type TObj = Record<string, string>;

const init: TObj = {};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function reducer(state: any, action: TObj) {
  switch (action.type) {
    case 'CHANGE':
      return {
        ...state,
        [action.name as string]: action.value,
      };
    case 'RESET':
      return Object.keys(state).reduce((acc, cur) => {
        acc[cur] = '';
        return acc;
      }, init);
    default:
      return state;
  }
}

const useInputs = (initialForm: TObj) => {
  const [form, dispatch] = useReducer(reducer, initialForm);
  const onChange = useCallback((e) => {
    const { name, value } = e.target;
    dispatch({ type: 'CHANGE', name, value });
  }, []);
  const reset = useCallback(() => dispatch({ type: 'RESET' }), []);
  return [form, onChange, reset];
};

export default useInputs;

 

 

form-ex.ts

import React, { useEffect, useState } from 'react';
import useInputs from '@/hooks/use-inputs';

interface IProps {
  onLogin: (email: string, password: string) => Promise<void>;
}

const FormEx: React.FC<IProps> = ({ onLogin }) => {
  const [{ email, password }, onChange, reset] = useInputs({
    email: '',
    password: '',
  });
  const [disabled, setDisabled] = useState(true);

  useEffect(() => {
    if (email && password) {
      setDisabled(false);
    } else {
      setDisabled(true);
    }
  }, [email, password]);

  const onSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await onLogin(email, password);
    reset();
  };

  return (
    <form onSubmit={onSubmit}>
      <input type="email" placeholder="user@gmail.com" value={email} onChange={onChange} name="email" />
      <input type="password" value={password} onChange={onChange} name="password" />
      {disabled && <div>이메일과 비밀번호를 입력해주세요</div>}
      <button type="submit" disabled={disabled}>
        로그인
      </button>
    </form>
  );
};

export default FormEx;

테스트 할 부분을 생각해보면 onchange이벤트, disabled에 따른 ui 변화와 button attribute변화, onsubmit, onLogin 실행이 있을 것 같다.

 

const renderComplex = () => {
  const onLogin = jest.fn();
  const { getByText, queryByText, container } = render(<FormEx onLogin={onLogin} />);
  const email = () => container.querySelector('input[name="email"]') as HTMLInputElement;
  const password = () => container.querySelector('input[name="password"]') as HTMLInputElement;
  const loginBtn = () => getByText('로그인');
  const disabledDiv = () => queryByText('이메일과 비밀번호를 입력해주세요');
  const changeEmail = (str: string) => {
    userEvent.type(email(), str);
  };
  const changePassword = (str: string) => {
    userEvent.type(password(), str);
  };
  const onClick = async () => {
    await act(async () => {
      userEvent.click(loginBtn());
    });
  };
  return { onLogin, email, password, loginBtn, disabledDiv, changeEmail, changePassword, onClick };
};

이번에도 먼저 필요한 함수를 미리 만들어 놓는다. 여기서 userEvent가 나오는데 fireEvent는 keyup, keychange 등의 이벤트를 각각 지정해줘야 한다. 하지만 userEvent는 모두 한번에 지원해준다. 필요한 경우마다 다른 이벤트를 적용하자. queryByText가 있는데 이것은 존재하지 않을 수도 있는 경우에 사용한다. 만약 qeryByText로 접근했는데 없다면 오류를 뱉으니 말이다. 또한 act 함수를 사용했는데 비동기 함수를 사용할 때는 이렇게 해주자. submit하면 onLogin 함수를 호출하고 reset을 해야 하는데 act 함수를 사용하지 않ㄹ으면 reset함수가 실행되지 않는다. 

 

  it('should render default component', () => {
    const { email, password, loginBtn, disabledDiv } = renderComplex();
    expect(email()).toBeInTheDocument();
    expect(password()).toBeInTheDocument();
    expect(loginBtn()).toBeInTheDocument();
    expect(disabledDiv()).toBeInTheDocument();
  });

아까와 비슷하게 초기 렌더링되는 html element를 확인하자. 주의할점은 disabledDiv도 확인하는 것이다.

 

  it('should update onchange', () => {
    const { email, password, changeEmail, changePassword } = renderComplex();
    changeEmail('email');
    expect(email().value).toBe('email');
    changePassword('pwd');
    expect(password().value).toBe('pwd');
  });

onchange 이벤트를 확인한다.

 

  describe('disabled test', () => {
    it('input values are not filled', () => {
      const { loginBtn, disabledDiv } = renderComplex();
      expect(loginBtn()).toBeDisabled();
      expect(disabledDiv()).toBeInTheDocument();
    });

    it('password value are not filled', () => {
      const { loginBtn, changeEmail, disabledDiv } = renderComplex();
      changeEmail('email');
      expect(loginBtn()).toBeDisabled();
      expect(disabledDiv()).toBeInTheDocument();
    });

    it('email value are not filled', () => {
      const { loginBtn, changePassword, disabledDiv } = renderComplex();
      changePassword('pwd');
      expect(loginBtn()).toBeDisabled();
      expect(disabledDiv()).toBeInTheDocument();
    });
  });

disabled한 경우를 모두 테스트한다. 로그인 버튼의 disabled attribute와 disabledDiv 존재 여부를 모두 확인한다.

 

  it('if disabled, disable submit', () => {
    const { loginBtn, onLogin, onClick } = renderComplex();
    expect(loginBtn()).toBeDisabled();
    onClick();
    expect(onLogin).not.toHaveBeenCalled();
  });

disabled한 경우에 클릭한 경우 onLogin이 호출되지 않았는지 확인한다.

 

  it('should submit', async () => {
    const { onLogin, changeEmail, changePassword, onClick, disabledDiv, email, password, loginBtn } = renderComplex();
    changeEmail('email');
    changePassword('pwd');
    expect(loginBtn()).not.toBeDisabled();
    expect(disabledDiv()).not.toBeInTheDocument();
    await onClick();
    expect(onLogin).toHaveBeenCalled();
    expect(email().value).toBe('');
    expect(password().value).toBe('');
  });

email, password를 입력한 후에 submit이 실행되는지 확인한다. (사실 input type email이라 저 로직이 실행되면 안되는데 그것까지는 잡아주지 못한는 것 같다.) enabled로 바뀌었는지 확인하고 클릭을 한 후에 onLogin이 실행되었는지 그리고 reset 되었는지 확인한다.

 

form-ex.test.tsx

import React from 'react';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormEx from './form-ex';

const renderComplex = () => {
  const onLogin = jest.fn();
  const { getByText, queryByText, container } = render(<FormEx onLogin={onLogin} />);
  const email = () => container.querySelector('input[name="email"]') as HTMLInputElement;
  const password = () => container.querySelector('input[name="password"]') as HTMLInputElement;
  const loginBtn = () => getByText('로그인');
  const disabledDiv = () => queryByText('이메일과 비밀번호를 입력해주세요');
  const changeEmail = (str: string) => {
    userEvent.type(email(), str);
  };
  const changePassword = (str: string) => {
    userEvent.type(password(), str);
  };
  const onClick = async () => {
    await act(async () => {
      userEvent.click(loginBtn());
    });
  };
  return { onLogin, email, password, loginBtn, disabledDiv, changeEmail, changePassword, onClick };
};

describe('<CountEx />', () => {
  it('should render default component', () => {
    const { email, password, loginBtn, disabledDiv } = renderComplex();
    expect(email()).toBeInTheDocument();
    expect(password()).toBeInTheDocument();
    expect(loginBtn()).toBeInTheDocument();
    expect(disabledDiv()).toBeInTheDocument();
  });

  it('should update onchange', () => {
    const { email, password, changeEmail, changePassword } = renderComplex();
    changeEmail('email');
    expect(email().value).toBe('email');
    changePassword('pwd');
    expect(password().value).toBe('pwd');
  });

  describe('disabled test', () => {
    it('input values are not filled', () => {
      const { loginBtn, disabledDiv } = renderComplex();
      expect(loginBtn()).toBeDisabled();
      expect(disabledDiv()).toBeInTheDocument();
    });

    it('password value are not filled', () => {
      const { loginBtn, changeEmail, disabledDiv } = renderComplex();
      changeEmail('email');
      expect(loginBtn()).toBeDisabled();
      expect(disabledDiv()).toBeInTheDocument();
    });

    it('email value are not filled', () => {
      const { loginBtn, changePassword, disabledDiv } = renderComplex();
      changePassword('pwd');
      expect(loginBtn()).toBeDisabled();
      expect(disabledDiv()).toBeInTheDocument();
    });
  });

  it('if disabled, disable submit', async () => {
    const { loginBtn, onLogin, onClick } = renderComplex();
    expect(loginBtn()).toBeDisabled();
    await onClick();
    expect(onLogin).not.toHaveBeenCalled();
  });

  it('should submit', async () => {
    const { onLogin, changeEmail, changePassword, onClick, disabledDiv, email, password, loginBtn } = renderComplex();
    changeEmail('email');
    changePassword('pwd');
    expect(loginBtn()).not.toBeDisabled();
    expect(disabledDiv()).not.toBeInTheDocument();
    await onClick();
    expect(onLogin).toHaveBeenCalled();
    expect(email().value).toBe('');
    expect(password().value).toBe('');
  });
});

 

후기

이렇게 꼼꼼하게 테스트를하면 확실히 오류를 사전에 방지할 수 있다. typescript, eslint, test를 모두 사용하면 더더욱 그렇다. 그런데 간단한 컴포넌트를 만드는데도 확실히 시간이 걸린다. 속도측면에서는 굉장히 비효율적이다. 회사에서는 전문적으로 테스트를 하는 분들도 있다고 들었다.(수작업으로) 양날의 검같은 느낌이다. 하지만 보다 견고한 프로젝트를 위해서라면 tdd방식도 나쁘지 않을지도?? 특히 복잡한 경우라면 더더욱 그렇다...

 

 

전체 코드

https://github.com/yoonminsang/react-test-ex

 

GitHub - yoonminsang/react-test-ex: 리액트 테스트 예제

리액트 테스트 예제. Contribute to yoonminsang/react-test-ex development by creating an account on GitHub.

github.com

 

참고 글

https://www.daleseo.com/react-testing-library/

https://ui.toast.com/weekly-pick/ko_20210630

 

728x90
LIST

' > react' 카테고리의 다른 글

리액트로 툴팁 컴포넌트 만들기  (2) 2022.10.31
cra 없이 리액트에서 svg 사용하기  (0) 2022.03.15
리액트란  (0) 2021.06.01
리액트 자동완성 및 ㅎㅎ  (0) 2021.01.26
react와 서버(nodejs express) 연결하기  (0) 2020.07.10
댓글
공지사항