React 테스트

2024. 3. 24. 10:56E8

React 테스팅

Mocking -특정 동작을 흉내 내는 것

ex) 실제 API를 호출하는 게 아니라, 가짜 payload를 반환하는 mocking function을 만듦

Stubbing -더미를 채워 넣는 것 ex) Child 컴포넌트를 렌더링 하지 않고, 대신 그 자리에<div> 등을 채워 넣음

setup -테스트하기 위한 환경 생성 / mock data, mock function 등을 준비

expectation –원하는 테스트 결과를 만들기 위한 코드를 작성

assertion –정말 원하는 결과가 나왔는지를 검증

const transformUser= (user) => {
  const{ name, age, address} = user
  return[name, age, address]
}

test("Test transformUser", () => {
  // setup
  const mockUser = { name: "testName", age: 20, address: "testAddress" };
  // expectation
  const result = transformUser(mockUser);
  // assertion
  expect(result).toBe(["testName", 20, "testAddress"]);
});

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

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

 

Unit Testing

다른 부분과 분리된 작은 코드를 만들고 그것을 테스트

작은 코드는 function, module, class 등을 의미

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

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

 

Integration Testing
앱의 특정 부분이 동작하는지 테스트함 ex) 여러 컴포넌트가 한꺼번에 동작하거나, 어떤 페이지의 부분이 잘 동작하는지를 테스트하는 경우ex) react-router, redux 등이 특정 컴포넌트와 함께 잘 동작하는지를 테스트하는 경우

jest

Assertion matchers jest는 풍부한 matcher를 제공하여, 여러 상황에서 match를 체크
expect()는 expectation object를 리턴
이 object의 메서드를 이용해 여러 매칭 상황을 assert
Async Assertion 비동기 상황의 테스트를 처리할 수 있는 여러 방법을 제공
callback, promise, async/await을 모두 활용
Mock functions mock function을 만듦
모듈 라이브러리 전체를 mocking
Lifecycle functions 각 테스트의 시작과 끝, 전체 테스트의 시작과 끝에 원하는 작업을 할 수 있음
beforeEach, afterEach, beforeAll, afterAll 함수를 활용
describe 블록 안에 사용하면 별도의 scope
Grouping describe 함수를 이용해 여러 test() 함수를 논리적으로 나눔
describe 함수 안에 describe 함수가 중첩될 수 있음
Snapshot Testing 특정 함수, 모듈, 컴포넌트 등의 결과를 serializable 한 형태의 snapshot으로 저장하고, 추후 변경이 발생했을 때 이전의 snapshot과 새로운 snapshot을 비교하여 변경이 발생했는지를 추측
jest의 주요 기능으로, 코드의 변경이 컴포넌트의 렌더링 결과에 영향을 미치는지를 파악하기에 적합

 

beforeAll, beforeEach, afterEach, afterAll의 순서로 Lifecycle 함수들이 실행

describe 블록 안에 있는 before-*, after-* 함수는 해당 블록의 범위 안에서 실행

describe 함수는 모든 test() 함수 이전에 실행

따라서 test() 함수들은 순차적으로 한꺼번에 실행됨

test("User component", () => {
  const mockProps = { name: "test-username", age: 20 }; // setup
  const { container } = render(<User {...mockProps} />); // expectation
  expect(container.firstChild).toMatchSnapshot(); // assertion
});

jest 활용

Assertion Matchers 활용

function isPythagorean(a, b, c) {
  return a * a + b * b === c * c;
}
test("Should 3, 4, 5 pythagorean", () => {
  expect(isPythagorean(3, 4, 5)).toBe(true);
});
test("Should 3, 4, 6 not pythagorean", () => {
  expect(isPythagorean(3, 4, 6)).toBe(false);
});

function createTodo(id, title, content) {
  return { id, title, content };
}
test("Should create user", () => {
  const id = 1,
    title = "Test todo",
    content = "Test content";
  expect(createUser(id, title, content)).toEqual({ id, title, content });
});
test("Should create user", () => {
  const id = 1,
    title = "Test todo",
    content = "Test content";
  expect(createUser(id, title, content).title).toMatch("Test todo");
});

function transformUser(user) {
  const { name, age, address } = user;
  return [name, age, address];
}
test("Should contain name after transformUser", () => {
  const user = { name: "test name", age: 20, address: "test address" };
  expect(transformUser(user)).toContain("test name");
});
test("Should contain name after transformUser", () => {
  const user = { name: "test name", age: 20, address: "test address" };
  expect(transformUser(user)).not.toContain(30);
});

callback 패턴의 경우, test() 함수가 제공하는 done() 함수를 활용하여 콜백이 끝나고 done()을 호출. 에러가 발생하면 done()의 인자로 에러를 넘김

Promise 패턴의 경우 async/await을 활용하거나, Promise를 리턴

function isPythagoreanAsync(a, b, c) {
  return newPromise((resolve) => {
    setTimeout(() => {
      const result = isPythagorean(a, b, c);
      if (result) return resolve(result);
      reject(newError("Not pythagorean"));
    }, 500);
  });
}

test("Should 3, 4, 5 be pythagoreanasync", (done) => {
  isPythagoreanAsync(3, 4, 5).then(done).catch(done);
});
test("Should 3, 4, 5 be pythagoreanasync", () => {
  return expect(isPythagoreanAsync(3, 4, 5)).resolves.toBe(true);
});
test("Should 3, 4, 6 be not pythagoreanasync", () => {
  return expect(isPythagoreanAsync(3, 4, 6)).rejects.toBe("Not pythagorean");
});

Mock functions 활용

jest.fn()을 활용하여, mock function 객체를 만듦

mockReturnValueOnce() 등으로 리턴하는 값을 임의로 조작 / 여러 번 호출하면, 순서대로 세팅된 값을 반환함

mockResolvedValue()로 promise가 resolve 하는 값을 조작

jest.mock()으로 특정 모듈을 mocking

toHaveBeenCalled 이 함수가 호출되었는지 검증
toHaveBeenCalledWith(arg1, arg2, ...) 이 함수가 특정 인자와 함께 호출되었는지 검증
toHaveBeenLastCalledWith(arg1, arg2, ...) 마지막으로 특정 인자와 함께 호출되었는지 검증
test("Should 3, 4, 5 be pythagoreanasync", (done) => {
  isPythagoreanAsync(3, 4, 5).then(done).catch(done);
});
test("Should 3, 4, 5 be pythagoreanasync", () => {
  return expect(isPythagoreanAsync(3, 4, 5)).resolves.toBe(true);
});
test("Should 3, 4, 6 be not pythagoreanasync", () => {
  return expect(isPythagoreanAsync(3, 4, 5)).rejects.toBe("Not pythagorean");
});

Lifecycle functions

beforeEach(() => {setupMockData()})
afterEach(() => {clearMockData()})

Grouping

describe("This is group 1", () => {
  describe("This is inner group 1", () => {
    test("Test 1", () => {});
  });
  describe("This is inner group 2", () => {
    test("Test 2", () => {});
  });
});

Snapshot testing

toMatchSnapshot()을 호출하면, 기존에 스냅샷이 없었을 경우. snap 파일을 만듦

기존 스냅샷이 있을 경우, 새로운 스냅샷과 비교하여 변경사항이 있으면 테스트는 실패함

toMatchInlineSnapshot()을 호출하면 별도의 스냅샷 파일을 만들지 않음

이 경우, 어떻게 스냅샷이 쓰였는지를 하나의 파일 안에서 알 수 있게 됨

test("Snapshot test form", () => {
  const { container } = render(<MyForm />);
  expect(container.firstChild).toMatchSnapshot();
});
test("Snapshot test form", () => {
  const { container } = render(<MyForm />);
  expect(container.firstChild).toMatchInlineSnapshot();
});
expect(container.firstChild).toMatchInlineSnapshot(`<div></div>`)

react-testing-library

종류

실제 유저가 사용하는 방식대로 테스트하는 접근

유저가 페이지에서 어떤 DOM 요소에 접근하는 방법을 흉내

React 컴포넌트가 렌더링 한 결과에 대한 접근만 가능

get getBy 관련 쿼리는 원하는 요소를 찾지 못할 경우나 여러 개의 요소를 찾을 경우 에러를 던짐
getAllBy 관련 쿼리는 여러 요소를 찾아 배열을 반환 - 원하는 요소를 찾지 못할 경우 에러를 던짐
원소가 반드시 페이지에 존재해야만 하는 경우 활용
find findBy 관련 : 원하는 원소가 없더라도 비동기적으로 기다림 / 여러 원소를 찾거나, 정해진 timeout 동안 찾지 못하면 에러를 던짐
findAllBy 관련: 여러 원소를 검색해 배열을 반환 / 정해진 timeout 동안 찾지 못하면 에러를 던짐

Promise를 리턴하며, 실패 시 reject, 성공 시 resolve
어떤 유저의 동작 후에 등장하는 원소 등을 테스트하고자 할 때 활용
query queryBy 관련 : getBy와 비슷하게 원소를 찾아 반환하나, 못 찾을 경우 에러를 던지지 않고 null을 반환함. 여러 원소를 찾으면 에러를 던짐
queryAllBy 관련 : getAllBy와 비슷하게 여러 개의 원소를 찾아 배열로 반환하나, 하나도 찾지 못하면 에러 대신 빈 배열을 반환
특정 요소를 찾을 수 없음을 assertion의 기준으로 둘 때 활용
container 컴포넌트를 렌더한 결과를 감싸는 원소
queryselector(), querySelectorAll()을 이용해 selector 문법으로 원소를 선택
jest-dom react-testing-library는 jest를 확장하여, 좀 더 쓰기 편한 assertion을 제공
toBeInTheDocument(), toHaveValue(), toBeDisabled(), toBeVisible() 등, DOM 테스팅에 특히 유용한 assertion 메서드를 제공

쿼리의 우선순위

유저가 페이지를 이동하는 방식에 가까운 쿼리일수록 우선순위가 높음

접근성 높은 HTML을 작성할수록 테스트가 용이한 코드

ByRole

자주 사용되는 Role : button, checkbox, listitem, heading, img, form, textbox, link

자주 사용되는 accessible name : button -텍스트 / label -텍스트 / a -텍스트 / img -alt 텍스트

TestForm.jsx

function TestForm() {
  const formRef = useRef();
  const handleSubmit = (e) => {
    e.preventDefault();
    formRef.current.reset();
  };
  return (
    <form onSubmit={handleSubmit} ref={formRef}>
      <label htmlFor="username">Username</label>
      <input id="username" type="text" name="username" />
      <input type="submit" value="Submit" />
    </form>
  );
}
test("제출버튼을찾아클릭하면, Username 인풋이비워진다.", () => {
  const { getByRole } = render(<TestForm />);
  const usernameInput = getByRole("textbox", { name: "Username" });
  const submitButton = getByRole("button", { name: "Submit" });
  userEvent.type(usernameInput, "test username");
  userEvent.click(submitButton);
  expect(usernameInput).toHaveValue("");
});

Text

유저가 볼 수 있는 Text 값을 기준으로 쿼리를 찾음

ByLabelText label과 연관된 원소를 찾음
ByPlaceholderText placeholder와 연관된 원소를 찾음
ByText 주어진 Text와 연관된 원소를 찾음
ByDisplayValue input, textarea, select 등의 value를 기준으로 원소를 찾음
test("제출버튼을찾아클릭하면, Username 인풋이비워진다.", () => {
  const { getByLabelText, getByText } = render(<SimpleTestForm />);
  const usernameInput = getByLabelText("Username");
  const submitButton = getByText("Submit");
  userEvent.type(usernameInput, "test username");
  userEvent.click(submitButton);
  expect(usernameInput).toHaveValue("");
});

semantic queries

 

유저에게 보이지 않지만, 접근성 스펙에 적합한 alt, title을 이용하여 원소를 검색

ByAltText – img, area, input 등의 alt 속성으로 원소를 검색

ByTitle – title 속성으로 원소를 검색

 

Test ID

data-testid 속성을 원하는 원소에 지정하고, 쿼리를 이용해 찾음

유저가 해당 속성을 기반으로 화면의 요소를 찾는 게 아니므로 우선순위가 낮음

다른 쿼리로 테스트를 작성할 수 없을 때 이 쿼리를 백도어로 활용

test("제출버튼을찾아클릭하면, Username 인풋을비운다.", () => {
  const { getByTestId } = render(<SimpleTestForm />);
  const usernameInput = getByTestId("username-input");
  const submitButton = getByTestId("submit-button");
  userEvent.type(usernameInput, "test username");
  userEvent.click(submitButton);
  expect(usernameInput).toHaveValue("");
});

유저 이벤트

내장 이벤트 함수인 fireEvent, createEvent를, 좀 더 직관적이고 범용적으로 사용할 수 있도록 만든 라이브러리

click, type, keyboard, upload, hover, tab 등 유저가 실제로 웹페이지를 사용하며 만드는 이벤트를 메서드로 제공

userEvent.click(submitButton, { shiftKey: true}, { clickCount: 5})

user event click

test("숨은텍스트를보여준다.", () => {
  const text = "Hidden text!";
  const { getByRole, queryByText } = render(<Expansion text={text} />);
  expect(queryByText("Hidden text!")).toBe(null); // 화면에 보이지 않아야 함
  const showButton = getByRole("button", { name: "Expand" });
  expect(showButton).toBeInTheDocument();
  userEvent.click(showButton); // 버튼 누르면 내용 보이게
  expect(queryByText(text)).toBeInTheDocument();
});
function Expansion({ text }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <div>
      <button onClick={() => setExpanded((b) => !b)}>Expand</button>
      {expanded && <div>{text}</div>}
    </div>
  );
}

user event type

userEvent.type(inputElement, 'react advanced')
userEvent.type(inputElement, 'react{space}advanced{enter}')
await userEvent.type(inputElement, 'react advanced', { delay: 300})
test("Typeahead에서쿼리에따른검색결과를보인다", () => {
  const mockSearchData = ["kim", "song", "shim", "park", "yoon"];
  const { getByPlaceholderText, getAllByRole } = render(
    <Typeahead searchData={mockSearchData} />
  );
  const inputElement = getByPlaceholderText("Type name...");
  userEvent.type(inputElement, "s");
  expect(getAllByRole("listitem").length).toBe(2);
  userEvent.clear(inputElement);
  expect(getAllByRole("listitem").length).toBe(mockSearchData.length);
});

유저 정보 토글앱

import React, { useState } from "react";
export default function SimpleToggle() {
    const [show,setShow] = useState(false)
  return (
      <div>
        { !show && <div>유저 정보를 보려면 버튼을 누르세요.</div> }
        { show && ( <ul>
            <li>Email - elice@elicer.com</li>
            <li>Address - 서울시 강남구 테헤란로 401</li>
        </ul> )}
        <button onClick={() => setShow(!show)}>유저정보 {!show ? "보기":"가리기"}</button>
      </div>
  );
}
import { screen, render } from "@testing-library/react";
import SimpleToggle from "./App";
import userEvent from '@testing-library/user-event'

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(); // 있는지 체크

    const button = screen.getByRole("button", { name: "유저정보 보기" });
    userEvent.click(button); // 버튼을 클릭

    expect(screen.queryByText(infoText)).not.toBeInTheDocument();
    // 위에서 찾은 텍스트가 보이지 않는지 체크

    const email = screen.getByText("Email - elice@elicer.com"); // 이메일 정보를 찾기
    expect(email).toBeInTheDocument(); 

    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(); // 이메일 정보가 문서에 있는지 체크

    userEvent.click(button, { clickCount:1});
    expect(email).not.toBeInTheDocument(); // 이메일 정보가 문서에서 사라졌는지 체크
  });
});

ShoppingCart 컴포넌트

mockCarts는 가짜 아이템

만일 이 컴포넌트를 실제 앱에 사용한다면 해당 데이터를 서버로부터 받아와야겠지만, 지금은 받아왔다고 가정하고 컴포넌트가 유저에게 올바른 데이터를 보여주는지 테스트

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);

export default function ShoppingCart({ carts }) {
    return (
        <div>
            <h2>쇼핑 목록</h2>
            <ul>
                {carts.map(({ id, image, name, price, quantity, discount }) => (
                    <Cart key={id} image={image} name={name} quantity={quantity} price={getDiscountPrice({ price, quantity, discount })} />
                ))}
            </ul>
            <div>총 가격 : {getTotalPrice(carts)}원</div>
        </div>
    );
}
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 getDiscountPrice = ({ price, quantity, discount }) => (price - price * discount) * quantity
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")
    expect(lis.length).toBe(3) // 모두 총 3개인지 체크
  });

  test("아이템의 이미지를 노출합니다.", () => {
    render(<ShoppingCart carts={mockCarts} />);
    const image = screen.getByAltText("강아지 사료 4kg")
    expect(image).toHaveAttribute('src',mockCarts[2].image) // 이미지의 src attribute가 데이터와 같은지 체크
  });
});

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

  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} />);

    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()
  });
});

 

#코딩독학 #코딩인강 #코딩배우기 #개발자 #코딩이란 #코딩교육
#프론트엔드부트캠프 #백엔드부트캠프 #국비지원부트캠프 #개발자 #백엔드 #AI부트캠프 #개발자국비지원 #백엔드개발자 #프론트엔드개발자

'E8' 카테고리의 다른 글

React 스타일링 2  (0) 2024.03.30
SSR  (0) 2024.03.25
REDUX  (1) 2024.03.23
상태 관리  (0) 2024.03.20
React의 비동기 통신  (1) 2024.03.17