[test] jest와 react testing library 공부

jiseong·2022년 1월 15일
5

T I Learned

목록 보기
169/291

개념

테스트가 필요한 경우

  • 코드를 작성하고나면, 원하는대로 동작하는지 알기 위해 테스트를 함.
  • 코드에 버그가있으면, 어떤 상황에서 버그가 발생하는지를 알기 위해 테스트를함.
  • 코드를 리팩토링하면, 원래대로 동작하는지 테스트함

테스트의 구성

1) setup
테스트하기 위한 환경을 만든다.(mock data, mock function 등을 준비)

2) expectation
원하는 테스트 결과를 만들기 위한 코드를 작성함.

3) assertion
원하는 결과가 나왔는지를 검증함.

화이트박스 테스팅, 블랙박스 테스팅

화이트박스 테스팅

컴포넌트 내부 구조를 미리 안다고 가정하고 테스트 코드를 작성

블랙박스 테스팅

컴포넌트 내부 구조를 모른 채 어떻게 동작하는지에 대한 테스트 코드를 작성

테스팅의 범위에 따른 분류

Unit Testing

  • 다른 부분과 분리된 작은 코드를 만들고 그것을 테스트함 (작은 코드는 function, module, class 등을 의미)

  • 각 부분이 원하는 대로 동작함을 보장하기 위함

  • 테스트는 서로 분리되어야함

ex) 특정 컴포넌트가 데이터에 따라 잘 렌더링되는지를 테스트하는 경우

ex) 특정 함수가 잘 동작하는지 테스트하는 경우

Integration Testing

  • 앱의 특정 부분이 동작하는지 테스트함

ex) 여러 컴포넌트가 한꺼번에 동작하거나, 어떤 페이지의 부분이 잘 동작하는지를 테스트하는 경우

ex) react-router, redux 등이 특정 컴포넌트와 함께 잘 작동하는지를 테스트하는 경우

End-to-end Testing

  • 유저가 어떤 시나리오를 가지고 그 시나리오의 end-to-end로 잘 작동하는지 테스트함

  • 필요한 경우 웹서버, 데이터베이스를 실행함

  • 범위가 너무 넓어서 에러가 발생했을 때, 특정 기능이 안 된다는 것은 알 수 있지만, 정확히 어떤 부분에 문제가 생겼는지는 알기 힘듦

ex) 유저가 회원가입 후, 로그인하여 유저 정보 페이지를 볼 수 있는지 테스트하는 경우.


실습

유저정보 토글앱

버튼을 누르면 유저 정보를 보여주는 앱을 테스트

컴포넌트

function ShowUserInfo() {
  const [show, setShow] = useState(false);
  
  const handleClick = () => setShow(b => !b);
  
  return (
    <div>
      {!show && <div>유저 정보를 보려면 버튼을 누르세요.</div>}
      {
        show && (
          <ul>
            <li>Email - elice@elicer.com</li>
            <li>Address - 서울시 강남구 테헤란로 401</li>
          </ul>
        )
      }
      <button onClick={handleClick}>{!show ? "유저정보 보기" : "유저정보 가리기"}</button>
    </div>
  )
}

테스팅

import { screen, render } from "@testing-library/react";
import userEvent from '@testing-library/user-event';
import SimpleToggle from "./App";

// describe: 테스트를 그룹화하는 함수
describe("앱을 렌더링합니다.", () => {
  test("버튼이 있습니다.", () => {
    render(<SimpleToggle />);
           
    // "유저정보 보기" 버튼을 찾습니다.
    const button = screen.getByRole('button',{
      name: '유저정보 보기'
    });
  
    // 버튼이 존재하는지 체크합니다.
    expect(button).toBeInTheDocument();
  });

  test("버튼을 누르지 않았을 시, 유저 정보 안내문이 보입니다.", () => {
    render(<SimpleToggle />);
           
    // 텍스트를 찾습니다.
    const text = screen.getByText("유저 정보를 보려면 버튼을 누르세요.");
    
    // 텍스트가 존재하는지 체크합니다.
    expect(text).toBeInTheDocument();
  });
});



describe("토글 기능을 테스트합니다.", () => {
  test("버튼을 눌렀을 시, 유저 정보가 보입니다.", () => {
    render(<SimpleToggle />);
    
    const infoText = /유저 정보를 보려면 버튼을 누르세요./i
    

    // 텍스트를 찾습니다.
    // 텍스트 - "유저 정보를 보려면 버튼을 누르세요."
    // 텍스트가 존재하는지 체크합니다.
    const text = screen.getByText(infoText);
    expect(text).toBeInTheDocument();

    // Toggle 버튼을 찾습니다.
    const button = screen.getByRole('button',{
      name: '유저정보 보기'
    });
    
    // 버튼을 클릭합니다.
    userEvent.click(button);
    
    // 위에서 찾은 텍스트가 보이지 않는지 체크합니다.
    // 여기서 주의할점은 보이지 않아야해서 queryByText를 통해 없어도 오류가 나지 않도록
    expect(
      screen.queryByText(infoText)
    ).not.toBeInTheDocument();

    // 이메일 정보를 찾습니다.
    // 이메일 정보 - "Email - elice@elicer.com"
    // 이메일 정보가 문서에 존재하는지 체크합니다.
    const email = screen.getByText("Email - elice@elicer.com");
    expect(email).toBeInTheDocument();
    

    // 주소 정보를 찾습니다.
    // 주소 정보 - "Address - 서울시 강남구 테헤란로 401"
    // 주소 정보가 문서에 존재하는지 체크합니다.
    const address = screen.getByText("Address - 서울시 강남구 테헤란로 401");
    expect(address).toBeInTheDocument();

    // 버튼의 텍스트가 "유저정보 가리기" 로 바뀌는지 체크합니다.
    expect(button).toHaveTextContent("유저정보 가리기");
  });

  test("버튼을 두번 누르면, 유저 정보가 보이지 않습니다.", () => {
    render(<SimpleToggle />);

    // 버튼을 찾습니다.
    // 버튼을 클릭합니다.
    // 이메일 정보가 문서에 있는지 체크합니다.
    const button = screen.getByRole('button',{
      name: '유저정보 보기'
    });
    userEvent.click(button, {clickCount: 1});
    const email = screen.getByText("Email - elice@elicer.com");
    expect(email).toBeInTheDocument();

    // Toggle 버튼을 클릭합니다.
    // 이메일 정보가 문서에서 사라졌는지 체크합니다.
    userEvent.click(button, {clickCount: 1});
    expect(
      email
    ).not.toBeInTheDocument()
  });
});

쇼핑 카트 앱

ShoppingCart 컴포넌트는 카트 목록을 받아 이미지를 보여주고, 수량과 상품 가격, 그리고 총 가격을 보여주는데 이러한 값들이 잘나오는지 테스트

컴포넌트

import React from "react";

const getDiscountPrice = (price, quantity, discount) => (price - price * discount) * quantity;


const getTotalPrice = carts =>
carts
  .map(({price, quantity, discount}) => 
    getDiscountPrice(price, quantity, discount)
  ).reduce((acc, cur) => acc+cur, 0)

function ShoppingCart({ carts }) {
  return (
    <div>
      <h2>쇼핑 목록</h2>

      <ul>
        {carts.map(({id, image, name, quantity, price, discount}) => (
          <Cart
            key={id}
            image={image}
            name={name}
            quantity={quantity}
            price={getDiscountPrice(price, quantity, discount)}
          />
        ))}


      </ul>

      <div>총 가격 : {getTotalPrice(carts)}</div>
    </div>
  );
}

export default ShoppingCart;

function Cart({image, name, quantity, price}){
  return (
      <li>
        <div>
          <img src={image} alt={name} />
        </div>

        <div>
          <div>개수 : {quantity}</div>
          <p>상품 가격 : {price}</p>
        </div>
      </li>
  )
}

테스팅

import { screen, render } from "@testing-library/react";
import ShoppingCart from "./App";

const mockCarts = [
  {
    id: 1,
    name: "강아지 신발 사이즈 xs",
    price: 14000,
    discount: 0.1,
    quantity: 1,
    image: "https://via.placeholder.com/150.png",
  },

  {
    id: 2,
    name: "베이비 물티슈 200매",
    price: 2000,
    discount: 0.2,
    quantity: 10,
    image: "https://via.placeholder.com/150.png",
  },

  {
    id: 3,
    name: "강아지 사료 4kg",
    price: 40000,
    discount: 0.3,
    quantity: 3,
    image: "https://via.placeholder.com/150.png",
  },
];

describe("ShoppingCart 컴포넌트를 렌더링합니다.", () => {
  test("헤더가 있습니다.", () => {
    render(<ShoppingCart carts={mockCarts} />);

    // 헤더를 찾습니다.
    const header = screen.getByRole('heading', {
      name: '쇼핑 목록'
    });
  
    // 헤더가 화면에 있는지 테스트합니다.
    expect(header).toBeInTheDocument();
  });

  test("아이템 3개를 보여줍니다.", () => {
    render(<ShoppingCart carts={mockCarts} />);

    // 모든 리스트 아이템을 찾습니다.
    const lis = screen.getAllByRole('listitem');
    // 모두 총 3개인지 체크합니다.
    expect(lis.length).toBe(3);
  });

  test("아이템의 이미지를 노출합니다.", () => {
    render(<ShoppingCart carts={mockCarts} />);

    // "강아지 사료 4kg"란 텍스트로 이미지를 찾으세요.
    // 이미지의 src attribute가 mockCarts의 데이터와 같은지 체크하세요.
    const image = screen.getByAltText('강아지 사료 4kg');
    expect(image).toHaveAttribute('src', mockCarts[2].image);
  });
});

describe("계산된 값을 노출합니다.", () => {
  test("할인된 값을 보여줍니다.", () => {
    render(<ShoppingCart carts={mockCarts} />);

    // 상품 가격에 할인가가 반영되었는지 체크하세요.
    // 상품 가격 - (price - price * discount) * quantity
    const {price, discount, quantity} = mockCarts[0];
    const discountPrice = (price - price * discount) * quantity
    const prices = screen.getAllByText(/상품 가격 :/i);
    expect(prices[0]).toHaveTextContent(`상품 가격 : ${discountPrice}`);
  });

  test("총 가격을 보여줍니다.", () => {
    render(<ShoppingCart carts={mockCarts} />);

    // 직접 mockCarts의 totalPrice를 계산해보세요.
    // 총 가격 - 모든 카트 상품 가격의 합
    
    const getDiscountPrice = (price, quantity, discount) => (price - price * discount) * quantity;
    
    const getTotalPrice = carts =>
    carts
      .map(({price, quantity, discount}) => 
        getDiscountPrice(price, quantity, discount)
      ).reduce((acc, cur) => acc+cur, 0)
    
    const totalPrice = getTotalPrice(mockCarts);
      
    expect(screen.getByText(`총 가격 : ${totalPrice}`)).toBeInTheDocument();
    
  });
});

유저정보 입력폼

유저명을 입력받아 제출하는 간단한 폼을 테스트

컴포넌트

import React, { useState } from "react";

function validateInput(value) {
  if(!value) {
    return '유저명을 필수로 입력해주세요.';
  }
  
  if(value.length > 20) {
    return '20자 이하의 문자열을 입력해주세요.';
  }
  
  return '';
}

export default function UsernameForm() {
  const [value, setValue] = useState("");
  const [error, setError] = useState("");
  const [submitted, setSubmitted] = useState(false);
  const [success, setSuccess] = useState('');

  const handleChange = (e) => {
    const input = e.target.value;
    setValue(input);
    setError(validateInput(input));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    setSubmitted(true);
    
    
    const error = validateInput(value);
    setError(error);
    if(error) return;
    setSuccess('유저명 생성에 성공했습니다.');
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          id="username"
          type="text"
          name="username"
          placeholder="유저명을 입력하세요"
          value={value}
          onChange={handleChange}
        />

        <button type="submit">제출</button>
        {submitted && <div data-testid='error-box'>{error}</div>}
      </form>

      {success && <div data-testid='success-box'>{success}</div>}
    </div>
  );
}

테스팅

import { screen, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import UsernameForm from "./App";

describe("유저명 폼을 렌더링합니다.", () => {
  test("유저명 폼에는 input이 있습니다.", () => {
    render(<UsernameForm />);

    // input을 찾고, placeholder가 제대로 들어있는지 확인합니다.
    const input = screen.getByRole('textbox');
    expect(input).toBeInTheDocument();
    expect(input).toHaveAttribute('placeholder', '유저명을 입력하세요');
  });

  test("유저명 폼에는 button이 있습니다.", () => {
    render(<UsernameForm />);

    // 제출 버튼이 제대로 렌더링되는지 확인합니다.
    const button = screen.getByRole('button', {
      name: '제출'
    })
    expect(button).toBeInTheDocument();
  });
});

describe("유저명 폼을 검증합니다.", () => {
  test("빈 인풋 제출시, 화면에 오류를 보여줍니다.", () => {
    render(<UsernameForm />);

    // 먼저 인풋이 빈 값을 가지는지 확인합니다.
    const input = screen.getByRole('textbox');
    expect(input).toHaveValue('');
    
    // 버튼을 클릭합니다.
    const button = screen.getByRole('button', {
      name: '제출'
    })
    userEvent.click(button)
    
    // "유저명을 필수로 입력해주세요." 란 에러 메시지가 보이는지 확인합니다.
    const errorBox = screen.getByText('유저명을 필수로 입력해주세요.');
    expect(errorBox).toBeInTheDocument();
  });

  test("21자 이상의 문자열 입력시, 화면에 오류를 보여줍니다.", () => {
    render(<UsernameForm />);

    // 먼저 인풋이 빈 값을 가지는지 확인합니다.
    // 인풋에 21자 이상의 문자를 입력합니다.
    // 제출 버튼을 클릭합니다.
    // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이는지 확인합니다.
    const input = screen.getByRole('textbox');
    expect(input).toHaveValue('');
    userEvent.type(input, '1234512345123451235123451');
    const button = screen.getByRole('button', {
      name: '제출',
    });
    userEvent.click(button);
    expect(
      screen.getByText('20자 이하의 문자열을 입력해주세요.')
    ).toBeInTheDocument()
  });

  test("21자 이상의 문자열을 입력했으나 제출하지 않으면, 에러가 보이지 않습니다.", () => {
    render(<UsernameForm />);
    // 먼저 인풋이 빈 값을 가지는지 확인합니다.
    // 인풋에 21자 이상의 문자를 입력합니다.
    // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이지 않는지 확인합니다.
    const input = screen.getByRole('textbox');
    expect(input).toHaveValue('');
    userEvent.type(input, '12345121453513531513531513531515');
    
    // non click
    expect(
      screen.queryByText('20자 이하의 문자열을 입력해주세요.')
    ).not.toBeInTheDocument()
  });

  test("21자 이상의 문자열 입력시 에러를 보여주며, 1글자를 지우면 에러가 사라집니다.", () => {
    render(<UsernameForm />);


    const errorMessage = '20자 이하의 문자열을 입력해주세요.';
    // 먼저 인풋이 빈 값을 가지는지 확인합니다.
    // 에러 메시지가 보이지 않는지 확인합니다.
    const input = screen.getByRole('textbox');
    expect(input).toHaveValue('');
    expect(
      screen.queryByText(errorMessage)
    ).not.toBeInTheDocument()

    // 인풋에 21자 이상의 문자를 입력합니다.
    // 제출 버튼을 클릭합니다.
    userEvent.type(input, '123451234512345123451');
    const button = screen.getByRole('button', {
      name: '제출',
    });
    userEvent.click(button);

    // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이는지 확인합니다.
    // "{backspace}" 를 인풋에 입력합니다.
    // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이지 않는지 확인합니다.
    expect(
      screen.getByText(errorMessage)
    ).toBeInTheDocument()
    
    // 실제 백스페이스 입력한것과 동일
    userEvent.type(input, "{backspace}");
    
    expect(
      screen.queryByText(errorMessage)
    ).not.toBeInTheDocument()
  });

  test("성공적으로 폼을 제출시 성공 메시지를 보여줍니다.", () => {
    render(<UsernameForm />);

    const errorMessage = '20자 이하의 문자열을 입력해주세요.';
    const successMessage = '유저명 생성에 성공했습니다.';
    // 먼저 인풋이 빈 값을 가지는지 확인합니다.
    // 에러 메시지가 보이지 않는지 확인합니다.
    // 성공 메시지가 보이지 않는지 확인합니다.
    const input = screen.getByRole('textbox');
    expect(input).toHaveValue('');
    expect(
      screen.queryByTestId('error-box')
    ).not.toBeInTheDocument()
        expect(
      screen.queryByTestId('success-box')
    ).not.toBeInTheDocument()

    // 정상적인 Username을 입력합니다.
    // 제출 버튼을 클릭합니다.
    userEvent.type(input, "정상네임");
    const button = screen.getByRole('button', {
      name: '제출'
    })
    
    userEvent.click(button);
    
    expect(
      screen.getByText(successMessage)
    ).toBeInTheDocument()

    // "유저명 생성에 성공했습니다." 라는 성공메시지가 보이는지 확인합니다.
  });
});

알게된 내용

  • toBe와 toEqual의 차이점
    toBe는 같은 내용을 가진 객체여도 서로다른 메모리를 가지면 false
    toEqual을 이용하면 위의 문제를 해결
expect({ name: 'son' }).toEqual({ name: 'son' }); // true
  • it, test는 동일한 동작을 하지만 it은 영어로 작성시 말의 이어짐이 좋다.

  • 값이 없는 것을 원할 때, getBy 대신 queryBy을 작성하는 것을 주의

queryBy 관련 쿼리는 getBy와 비슷하게 원소를 찾아 반환하나, 못찾을경우 에러를 던지지않고 null을 반환함.
여러원소를 찾으면 에러를 던짐

  • act는 UI 테스트를 작성할 때, 렌더링과 같은 작업, 유저 이벤트, 데이터 가져오기는 유저 인터페이스와의 상호작용하는 “단위”

you don't need to use act by yourself. It's wrapped by render function.
react testing library를 사용중이면 render함수 내부에서 호출하고 있음.

  act(() => {
    if (hydrate) {
      ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
    } else {
      ReactDOM.render(wrapUiIfNeeded(ui), container)
    }
  })

궁금한 점

import { render, unmountComponentAtNode } from "react-dom";
import { screen, render } from "@testing-library/react";

의 render 차이

https://stackoverflow.com/questions/59935545/react-testing-library-render-vs-reactdom-render

stackoveflow를 참고하여 해결

0개의 댓글