리액트 컴포넌트 소프트웨어 정적 동적 비동기 테스트 TDD react testing library Jest 사용법 정리

suhyeon kim·2023년 3월 1일
0

개발하면서 한번쯤은 들어본 TDD(테스트 주도개발)
처음엔 필요성을 그다지 느끼지 못했는데 지금 생각해보면 어떤걸 테스트 해야할지 몰라서 그랬던것같기도하다.

첫 테스트할때 새로운 메소드들때문에 생소할수있지만 반복하다보면 조금씩 눈에 들어온다.
JEST와 react testing library를 공부하면서 중요하다 생각한걸 정리해보고자 한다.

목차
1. TEST & TDD
2. Testing tool (JEST, React Testing Library)
3. 리액트 테스트시 자주 사용한 주요 API 소개

1) TEST & TDD

TEST란?

개발상에서 불리는 테스트란 우리의 제품 및 서비스가 예상하는대로 동작하는지 확인하고 검증하는 용도로 사용된다.(함수, 특정기능, UI,성능, API 스펙 등)

테스트 피라미드

1) Unit test
각 컴포넌트 및 기능 단위의 동작을 검증함.
단위테스트는 함수와 같이 a single piece of code를 검증하는데 도움을 준다.
함수에 인풋을 넣고 기대했던 아웃풋이 나오는지 확인하는 방식으로 단위테스트는 짧고 간단하며 매우 빨리 실행된다.

2) Integrating Test
최소 두개 이상의 클래스 또는 서브 시스템의 결합을 테스트하는 방법, 각 모듈간의 상호작용을 검증함.
일반적으로 단위 테스트만큼 많은 통합테스트는 필요하지 않지만 서로 다른 구성요소가 함께 잘 동작하는지 확인할필요가 있다.

3) End to End test
실제유저가 보는 화면을 기준으로 하는 테스트.
사용자의 브라우저환경에서 직접 값을 입력해서 테스트 할수있다.

테스트 구성

1) Arrage : 테스트할 데이터준비
2) Act : 주어진 데이터에 값을 넣어 실행
3) Assert : 데이터 최종결과값이 예상한 값인지 확인

class Calculator {
    constructor(){
        this.value = 0;
    }
    set(num){
        this.value = num;
    }
    clear(){
        this.value = 0;
    }
}
describe('Calculator', ()=>{
    let cal;
    beforeEach(()=>{
       //assert : 테스트할 데이터(클래스)준비
        cal = new Calculator()
    })

    it('set', ()=>{
       //act : 주어진 데이터에 값을 넣어 실행
        cal.set(9)
       //assert : 데이터 결과값 9인지 확인
        expect(cal.value).toBe(9)
    })
})

테스트 범위 (무엇을테스트하는가?)

1) 요구사항이 적절하게 잘 동작하는가
2) 모든 경우를 테스트 (잘못된포맷형식,Null,undefined,특수문자,잘못된이메일형식,작은숫자,큰숫자,순서가 잘못되었을때 등)
3) 역관계를 통해 결과값 확인
4) 다른 수단을 이용해서 결과값이 맞는지 확인
5) 에러상황을 잘 컨트롤 하는지(네트워크 에러시, 메모리부족시,데이터베이스 오류시 등)
6) 성능확인을 정확한 수치로 확인


TDD 기법

TDD(Test Driven Development)란? 개발전 테스트 코드를 먼저 작성하는것.
TDD는 테스트 주도 개발의 약자로써 작은 단위의 테스트 케이스를 작성하고, 이를 실패/통과하는 코드들을 실험하고 추가하는 단계를 반복해서 구현한다. 테스트 코드를 작성함으로써 실제 코드에 대해 기대되는 바를 보다 명확하게 정의 함으로써 불필요한 설계를 피할수있고 정확한 요구사항에 집중할수있다.

TDD 작성 순서

1) 실제코드를 구현하기전 테스트 코드를 먼저 작성한다.
2) 테스트 코드 실행 ===> 실패 (당연히 실제코드가 없기때문에 실패한다.)
3) 이때 실패하는 테스트를 통과할 정도의 최소 실제 코드를 작성해서 성공시킨다.
4) 이를 반복하면서 코드들을 추가한다.
5) 전체적인 기능이 완성된다면 중복코드제거, 일반화등의 리팩토링을 수행한다.

TDD 이점

1) 디버깅시간 단축
2) 문서기능 : TDD를 하게 될 경우 테스팅을 자동화 시킴과 동시에 보다 정확한 테스트 근거를 산출할수있다.
3) 재설계시간의 단축 : 개발자가 무엇을 해야하는지 분명하게 정의하고 개발을 시작하게된다. 또한 테스트 시나리오를 작성하면서 다양한 예외사항에 대해 생각할수있다. 이는 개발진행중 소프트웨어의 전반적인 설계가 변경되는 일을 방지할수있다.
4) 코드의 품질 향상
5) 코드간 의존성 낮춤
6) 보다 튼튼한 객체 지향적인 코드 생산 : TDD는 코드의 재사용 보장을 명시하므로 TDD를 통한 소프트웨어 개발시 기능별 철저한 모듈화가 이루어진다. 종속성과 의존성이 낮은 모듈로 조합되어있어 필요에 따라 추가 제거해도 소프트웨어 전체 구조에 영향을 미치지 않는다.

TDD 단점

개발시간 증가 : 중간중간 테스트를 진행하면서 코드를 작성해야하기때문에 일반 개발방식에 비해 대략 10~30%정도 늘어난다.


2) Testing tool

1) 자바스크립트 - 자바스크립트에서 데스트시 JEST추천. JEST 관련 구글이나 네이버에 많은 정보들이 있음.
2) 리액트 - 리액트 테스트시 추천되는 툴은 JEST와 REACT Testing library가 있다.

  • JEST : jest는 jsdom이라는 가상dom을 통해 자바스크립트 테스트 코드를 작성하게 해준다.
  • React testing library : 리액트테스팅라이브러리는 리액트 컴포넌트용 테스트 코드 작성을 도와준다. 리액트 테스팅 라이브러리는 리액트 내부 구현사항에 의존하지 않고 테스트가 가능하다. 사용자 중심의 테스트 유틸리티를 제공하는데 DOM을 찾는 기능들이 있고, 실제 사용자가 DOM을 사용하는 방식과 유사한 형태로 제공되어있다.
  • 왜 두개나 추천? : react testing library는 컴포넌트 내에서 연결되어있는 자식 노드들을 렌더링 해주지 않기때문에 JEST (mocking기능)에 있는 기능과 섞어 사용하면 충분히 테스트가 가능하다.

3) 개인적인 팁

  • JEST는 Mocking method, Matcher 이해필수
  • REACT testing library를 들어가서 어떻게 공부해야할지 막막하다면
    Frameworks > DOM Testing library > introduction & cheatsheet만 읽어봐도 어느정도 눈에 들어온다.

3) 리액트 테스트시 자주 사용한 주요 API

JEST(Matcher,Mocking method,Snapshot)

JEST는 자바스크립트 개발에 용이하며 test runner, test matcher, test mock프레임워크를 제공해주기때문에 손쉽게 테스트해볼수있다.

1) Matcher

다양한 Matcher함수가 제공된다. (toBe(),toEqual(),toHaveLength(),toContain()등)

expect('test target').Matcher(Conditions the test subject must pass);

2) Mocking Method

테스트하고자 하는 코드가 의존하는 function이나 class에 대해 모조품을 만들어 일단 테스트 하는 것을 말한다.
리액트 컴포넌트 테스트를 하면서 jest.fn(),mockImplemention(),jest.mock()을 많이 사용했다.

test("mock Test", () => {
  const mockFn = jest.fn();
  mockFn.mockImplementation((name) => `I am ${name}`);

  mockFn("a");
  mockFn(["b", "c"]);

  expect(mockFn).toBeCalledTimes(2);
  expect(mockFn).toBeCalledWith("a");
  expect(mockFn).toBeCalledWith(["b", "c"]);
});

3) Snapshot testing(파일 스냅샷 생성법)

스냅샷테스팅이란 어떤 기능의 예상 결과를 미리 정확히 포착해놓고 실제 결과에 비교하는 테스트이다. 테스트 대상기능의 구현이 변경되어 실제 결과와 스냅샷을 떠놓은 예상 결과와 달라질 경우 해당 테스트 케이스는 실패하게 되는데 이럴경우 새로운 스냅샷(-u옵션)을 떠서 새롭게 교체할수도있다.

import renderer from 'react-test-renderer';
import Link from '../Link';

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="http://www.facebook.com">Facebook</Link>)
    .toJSON();
  expect(tree).toMatchSnapshot();
});
//snapshot file
exports[`renders correctly 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

REACT TESTING LIBRARY(render, screen, variant queries)

크게 DOM에 컴포넌트를 렌더링 해주는 render()함수와 특정 이벤트를 발생시켜주는 fireEvent객체, 그리고 DOM에서 특정영역을 선택하기위한 다양한 쿼리함수들이 존재한다.

1) render()
테스트 환경에서 해당 컴포넌트를 렌더링하여 후의 상태를 확인할수있다. 단순히 컴포넌트를 렌더하는것만이 아니라 이 함수가 호출되는순간 그 결과물에는 DOM을 선택할수있는 다양한 쿼리들과 렌더링된 DOM요소를 반환한다(=container). 일반적인 DOM노드이기때문에 container.querySelector와 같이 사용이 가능하다. 자바스크립트 객체 destructuring 문법으로 render()함수가 리턴한 객체로 부터 원하는 쿼리 함수만 담아 사용할수있다.

import React from "react";

function NotFound({ path }) {
  return (
    <>
      <h2>Page Not Found</h2>
      <p>해당 페이지({path})를 찾을 수 없습니다.</p>
      <img
        alt="404"
        src="https://media.giphy.com/media/14uQ3cOFteDaU/giphy.gif"
      />
    </>
  );
}
import React from "react";
import { render } from "@testing-library/react";
import NotFound from "./NotFound";

describe("<NotFound />", () => {
  it("renders header", () => {
    const { getByText } = render(<NotFound path="/abc" />);
// NotFound컴포넌트를 render함수의 인자로 넘긴후 리턴객체로 부터 getByText() 함수를 얻는다.
    const header = getByText("Page Not Found");
//Page Not Found 텍스트를 인자로 넘긴 후 엘리먼트를 얻는다 (h2태그가 되겠죠?)
    expect(header).toBeInTheDocument();
//jest-dom의 toBeInTheDocument() matcher함수를 이용해 해당 엘리먼트가 있는지 검증한다.
  });
});

2) screen()
render 함수를 호출한 결과로 쿼리함수를 사용할수도있지만 react testing library에서 제공하는 screen을 통해 쿼리 함수를 사용할수도있다. 좀 더 간편한 느낌이 드는것도 사실. (screen을 사용하기 위해서 먼저 render함수를 통해 컴포넌트를 렌더링해야한다)

import React from "react";
import { render, screen } from '@testing-library/react'
import NotFound from "./NotFound";

describe('NotFound', ()=>{
  it("renders header", ()=>{
     render(<NotFound path="/abc" />)
    expect(screen.getByText('Page Not Found')).toBeInTheDocument();
  })
})

3) 사용가능한 다양한 쿼리
앞서 말했듯이 render함수를 싱해하고 나면 그 결과물안에는 다양한 쿼리함수들이 있다.
이 쿼리함수들은 react-testing-library기반인 dom-testing-library에서 지원하는 함수들이다.
이 쿼리 함수들은 variant와 Queries의 조합으로 네이밍이 되어있는데 varient에 어떤 종류들이 있는지 알아보자.

Varient

  • getBy : getBy로 시작하는 쿼리는 조건에 일치하는 DOM 엘리먼트를 선택한다. 만약에 없으면 에러를 던진다.
  • getAllBy : QueryBy로 시작하는 쿼리 조건에 일치하는 DOM엘리먼트를 여러개를 선택한다. 만약 하나도 없으면 에러를 던진다.
  • queryBy : queryBy로 시작하는 쿼리 조건에 일치하는 DOM엘리먼트 하나를 선택한다. 만약에 존재하지 않으면 Null을 리턴한다.
  • queryAllBy : queryAllBy로 시작하는 쿼리 조건에 일치하는 DOM엘리먼트 여러개를 선택한다. 만약 존재하지 않으면 빈 배열값을 리턴한다.
  • findBy : findBy로 시작하는 쿼리 조건에 일치하는 DOM엘리먼트 하나가 나타날때까지 기다렸다가 해당 DOM을 선택하는 promise를 반환한다. 기본 timeout인 4500ms이후에도 나타나지 않으면 에러가발생한다.
  • findAllBy : findBy로 시작하는 쿼리 조건에 일치하는 DOM엘리먼트 여러개가 나타날때까지 기다렸다가 해당 DOM을 선택하는 promise를 반환한다. 기본 timeout인 4500ms이후에도 나타나지 않으면 에러가발생한다.

Queries

  • ByRole: 특정 role값을 지니고 있는 엘리먼트를 선택한다 (ex.li태그=>listitem,input type=text 태그 =>textbox 등 아래 홈페이지에서 찾아볼수있음)
  • ByLabelText : label이 있는 input의 label내용으로 input을 선택한다
  • ByPlaceholderText : placeholder값으로 input및 textarea를 선택한다.
  • ByText: 엘리먼트가 가지고있는 텍스트 값으로 DOM을 선택한다
  • ByDisplayValue: input, textarea,select가 지니고 있는 현재 값을 가지고 엘리먼트를 선택한다.
  • ByAltText: alt속성을 가지고 있는 엘리먼트 (주로 img)를 선택한다
  • ByTitle:title속성을 가지고 있는 DOM혹은 title엘리먼트를 지니고 있는 SVG를 선택할때 사용한다.
  • ByTestid: 다른 방법으로 선택을 하지 못할때 사용하는 방법인데 특정 DOM에 직접 test할때 사용할 id를 달아서 선택하는것을 의미한다.

4) 실제 테스트에 적용하기

  • 정적상태 컴포넌트
    내부 상태가 없고 단순히 고정된 텍스트와 이미지로 구성되어있는 정적상태의 컴포넌트를 테스트해보자
//AlbumList.jsx
export default function AlbumList({album}){
    const {title, cover, description} = album.snippet
    const navigate = useNavigate();

    return(
        <li onClick={()=>{navigate(`/albums/${album.id}`,{state:{album:album}})}}>
            <img src={cover.url} alt={title} />
            <div>
                <p>{title}</p>
                <p>{description}</p>
            </div>
        </li>
    )
}
//AlbumList.test.js
describe('AlbumList', ()=>{

const album = {
    id:1,
    snippet: {
        title: 'title',
        description: '1',
        cover:{
            url: 'http://albumCover/',
        }
    }
}

it('renders Album list item', ()=>{
  render(
      <MemoryRouter>
          <AlbumList album={album}/> //AlbumList 컴포넌트를 렌더링해서 HTML 리턴
      </MemoryRouter>
     )
    const image = screen.getByRole('img'); //HTML 역할에 대해 위 ARIA in HTML 링크 참고
      //getBy+ByRole = getByRole, img역할을 지니고 있는 DOM 엘리먼트를 선택
    expect(image.src).toBe(cover.url)
    expect(image.alt).toBe(title)
    expect(screen.getByText(title)).toBeInTheDocument()
    expect(screen.getByText(description)).toBeInTheDocument()  
})
})

MemoryRouter란?
테스트하고자하는 컴포넌트에 리액트 라우터 (link나 useNavigate 등)를 사용하고있으면 테스트.js에서도 그 환경을만들어줘야한다. MemoryRouter을 사용하면 환경을 만들어줄수있다.
자세한 내용은 아래 참고!
https://v5.reactrouter.com/web/guides/testing
https://reactrouter.com/en/main/router-components/memory-router

  • 동적상태 컴포넌트
    사용자의 동작,내부 상태에 따라 UI에 변화가 생길 수 있는 좀 더 복잡한 컴포넌트를 테스트해보자.
    (ex. 사용자가 input을 작성하고 버튼을 클릭하면 어떻게 되는지 등)

fireEvent 보다는 userEvent가 추천되고있으니 자세한 내용은 아래를 참조!
https://testing-library.com/docs/ecosystem-user-event/#typeelement-text-options

export default function InputTest(){

const [text, setText] = useState('')
const navigate = useNavigate()
    
const onSubmitHandler = (e)=>{
      e.preventDefault();
      navigate(`/albums/${text}`)
}

<div>
    <form onSubmit={onSubmitHandler}>
    <input
     type='text'
     placeholder='Search...'
     value={text}
     onChange={(e) => setText(e.target.value)}
     />
     <button><BsSearch/></button>
     </form>
 </div>
}
describe('InputTest',()=>{
      it('navigates to results page when the search button is clicked', ()=>{
        const searchKeyword = 'testing-keyword'

    render(
      <MemoryRouter initialEntries={["/home"]}>
       <Routes>
         <Route path='/home' element={<InputTest/>}></Route>
         <Route path={`/albums/${searchKeyword}`} element={<p>{searchKeyword}</p>}></Route>
      </Routes>
     </MemoryRouter>
     )
        const searchInput = screen.getByRole('textbox')
        const searchButton = screen.getByRole('button')

        userEvent.type(searchInput, searchKeyword)
        userEvent.click(searchButton) //클릭이 되면 두번째 route 컴포넌트가 나와야함.

        expect(screen.getByText(searchKeyword)).toBeInTheDocument()
      })
})
  • 비동기(async/await)상태 컴포넌트
    비동기상태의 테스트를 작성할땐 async키워드와 await키워드를 적절하게 잘 사용해줘야한다.

1) findBy
react testing library는 비동기 테스트를 수월하게 할수있도록 다양한 함수를 제공해주는데 findBy로 시작되는 findByRole(),getByLabelTest() 가 그렇다.

const button = screen.getByRole('button', {name: 'Click Me'})
fireEvent.click(button)
await screen.findByText('Clicked once')
fireEvent.click(button)
await screen.findByText('Clicked twice')

2) waitFor
일정시간 동안 기다려야하는 경우 waitFor을 사용해서 예상결과가 통과할때까지 기다릴수있다. 또한 인자로 함수를 받기때문에 유용하게 사용할수있다.

// 콜백함수가 에러를 던지지 않을때까지 기다린다. 
// 즉 mock함수가 한번이라도 불릴때까지 기다린다는 말이다.
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
// ...
test("ON button will be enabled when clicked (waitFor)", async () => {
  render(<Button />);

  userEvent.click(screen.getByRole("button"));

  const button = await waitFor(() =>
    screen.getByRole("button", {
      name: /on/i
    })
  );

  expect(button).toBeInTheDocument();
  expect(button).toBeEnabled();
});

3) waitForElementToBeRemoved
DOM상에서 특정 엘리먼트가 비동기로 사라지는지 확인할때 사용할수있다.

test("OFF button will be removed when clicked", async () => {
  render(<Button />);

  userEvent.click(screen.getByRole("button"));

  await waitForElementToBeRemoved(() =>
    screen.queryByRole("button", {
      name: /off/i
    })
  );
});

가장 자주사용되고 유용한 API들과 개념을 정리해보았다.
이외에도 asFragment나 정말 다양한 userEvent들이 있어,
시간날때마다 공식문서를 확인하면서 잘 작성하면 더욱 좋은 코드가 탄생할것이다!
또한 e2e테스트로 Cypress를 사용해서 연습했는데 꽤나 신기했고 생각보다 편리했다.
머리속에 정리가되면 작성해봐야겠다.

출처 (혹시나 누락된 출처가 있다면 꼭 알려주셔요 반영토록 하겠습니다 :))

0개의 댓글