Unit Test | 4.4. 통합 테스트 작성하기 - 상태 관리 모킹

Kate Jung·2024년 3월 10일
0

Front-end Test

목록 보기
17/17
post-thumbnail

📌 코드의 내부 구조 및 설명

🔹 ProductInfoTable

장바구니의 상품 리스트를 보여주는 테이블 영역

...생략
const ProductInfoTable = () => {
  const { cart, removeCartItem, changeCartItemCount } = useCartStore(state =>
    pick(state, 'cart', 'removeCartItem', 'changeCartItemCount'),
  );
  const { user } = useUserStore(state => pick(state, 'user'));

  return (
		    ...생략
          {Object.values(cart).map(item => (
            <ProductInfoTableRow
              key={item.id}
              item={item}
              user={user}
              removeCartItem={removeCartItem}
              changeCartItemCount={changeCartItemCount}
            />
          ))}
        ...생략
  );
};

export default ProductInfoTable;
  • 장바구니의 상품 목록을 렌더링하기 위해 CartStore에서 cart 스테이트를 조회하며 장바구니 상품 삭제, 수량 변경을 위해 removeCartItem, changeCartItemCount 액션을 가져옴.

    그리고 어떤 사용자의 장바구니인지 알기 위해 UserStore에서 사용자 정보 스테이트를 가져옴.

    이러한 정보들을 ProductInfoTableRow 컴포넌트에 props로 넘김.

  • 내부적으로 카트, 유저 스토어를 사용하며 ProductInfoTableRow 컴포넌트를 자식으로 여러 상품 목록을 렌더링함.

  • 즉, 꽤나 복합적인 요소들이 결합된 컴포넌트이며 이런 컴포넌트에 대한 테스트를 통합 테스트라고 볼 수 있음.

🔹 ProductInfoTableRow

...생략

const ProductInfoTableRow = ({
  item,
  user,
  removeCartItem,
  changeCartItemCount,
}) => {
  const { id, title, count, images, price } = item;

  const handleClickDeleteItem = itemId => () => {
    removeCartItem(itemId, user.id);
  };

  const handleChangeCount = itemId => ev => {
    ...생략

    changeCartItemCount({ itemId, userId: user.id, count: newCount });
  };

  return (
      ...생략
        <TextField
          variant="standard"
          onChange={handleChangeCount(id)}
          defaultValue={count}
          size="small"
          sx={{ width: '10ch' }}
          InputProps={{
            endAdornment: <InputAdornment position="end"></InputAdornment>,
          }}
        />
        <IconButton
          aria-label="delete button"
          size="small"
          onClick={handleClickDeleteItem(id)}
        >
          <DeleteIcon fontSize="inherit" />
        </IconButton>
  );
};

export default ProductInfoTableRow;
  • 넘겨받은 정보를 사용하여 상품명, 가격과 같은 정보와 삭제, 수량변경 필드를 렌더링함. 그리고 삭제 버튼 클릭, 수량필드 변경과 같은 사용자의 상호작용을 통해 ProductInfoTable에서 props로 넘겨받은 액션들을 호출함.
  • Material UI의 다양한 컴포넌트를 자식으로 가지고 있으며 사용자의 인터렉션을 통해 장바구니 상품을 삭제하거나 수량을 변경함.

📌 ProductInfoTableRow를 대상으로 통합 테스트를 작성하지 않는 이유

이 경우, (각각의 상품을 따로 검증하기보다) 테이블의 모든 상품 항목을 대상으로 기능이 올바르게 동작하는지 검증하는 것이 훨씬 효율적임.

ProductInfoTable을 대상으로 통합 테스트를 작성하는 것이 더욱 실제 앱의 동작과 유사하게 인터렉션에 따른 UI 변경 사항을 검증 가능하기 때문임.

  • 통합 테스트 작성 예시

    • ProductInfoTableRow

      삭제 버튼을 눌러 아이템을 삭제하는 기능을 테스트로 검증하여도 props으로 받은 removeCartItem 함수의 호출 여부만 spy 함수를 통해 확인하는 정도가 전부.

      수량 변경의 경우에도 마찬가지로 변경된 수량으로 changeCartItemCount 액션의 호출 여부만 검증 가능.

    • ProductInfoTable

      실제 상황과 유사하게 테스트 실행 가능

      액션(removeCartItem, changeCartItemCount) 호출에 따른 cart state의 상태 변경까지 모두 검증 가능. 이 과정에서 ProductInfoTableRow 컴포넌트의 기능까지 모두 검증 가능

📌 통합 테스트의 대상이 되는 컴포넌트에 state나 api에 대한 제어 코드를 응집하는 것이 좋은 이유

로직 파악 및 유지 보수에도 좋고 테스트의 단위를 나누기도 깔끔함.

ProductInfoTableRow 컴포넌트에서도 state나 action을 직접 가져와도 되지만 이렇게 되면 state 제어를 하는 코드가 산재되어 로직 파악이 어려워질 수 있음.

이러한 이유로 예제에서도 ProductInfoTable 컴포넌트를 하나의 통합 테스트 단위로 보고 state나 action을 가져온 hook을 모두 이 컴포넌트에 작성함.

📌 통합 테스트를 작성할 때는 페이지의 비즈니스 로직을 기준으로 최대한 실제 사용성과 유사하게 범위를 나누는 것이 매우 중요

그리고 이를 위해 비즈니스 로직 수행을 위한 API 호출이나 State 변경 작업을 통합 테스트의 단위로 미리 나눠두면 적절히 분리된 비즈니스 로직 단위로 컴포넌트의 통합 테스트를 어려움 없이 작성 가능

📌 테스트 코드 작성 (ProductInfoTable.spec.jsx)

🔹 초기 데이터 설정

beforeEach 셋업을 사용하여 초기 데이터 설정 가능.

우선 ProductInfoTable 컴포넌트는 주스탠드의 데이터를 가져와 사용하기 때문에 장바구니에 데이터가 있는 것처럼 설정하기 위해 모킹한 주스탠드 모드를 사용하여 초기 데이터를 설정해야 함.

beforeEach(() => {
  mockUseUserStore({ user: { id: 10 } });
  mockUseCartStore({
    cart: {
      6: {
        id: 6,
        title: 'Handmade Cotton Fish',
        price: 809,
        count: 3,
				...생략
      },
      7: {
        id: 7,
        title: 'Awesome Concrete Shirt',
        price: 442,
        count: 4,
				...생략
      },
    },
  });
});

이런 식으로 mockUseUserStore와 mockUseCartStore 함수를 호출하여 사용자 정보와 장바구니에 담긴 상품 리스트를 목 데이터로 설정 가능.

이제 ProductInfoTable 컴포넌트의 테스트는 항상 이 데이터를 기준으로 실행될 것

🔹 1번째 테스트

it('특정 아이템의 수량이 변경되었을 때 값이 재계산되어 올바르게 업데이트 된다', async () => {
  // 컴포넌트 렌더링
	const { user } = await render(<ProductInfoTable />);
	// 모든 상품 row 요소를 조회한 후, 1번째 요소 선택
  const [firstItem] = screen.getAllByRole('row');

	// within 함수를 사용하여 firstItem 내에서 textbox라는 role을 가진 text input 필드를 조회
  const input = within(firstItem).getByRole('textbox');

	// 시뮬레이션 - 수량 변경
  await user.clear(input); // 모든 입력 값을 지움
  await user.type(input, '5'); // 수량을 5로 변경

	// 변경한 수량에 따라 가격이 제대로 계산되는지 확인
  expect(screen.getByText('$4,045.00')).toBeInTheDocument();
});

이 테스트의 경우 수량 텍스트 필드를 입력하는 사용자 인터랙션이 필요함.

  • const input = within(firstItem).getByRole('textbox')

    첫 번째 상품에 텍스트 필드 요소를 조회해야 하는데요 플레이스 홀더로 가져올 수 있으나 이 경우 플레이스 홀더 속성이 따로 지정된 것이 없기 때문에 textbox role로 조회해야 함.

  • expect(screen.getByText('$4,045.00')).toBeInTheDocument()

    가격 포맷은 달러 포맷으로 노출되기 때문에 이 부분까지도 테스트에서 한 번에 검증 가능.

    최종적으로 계산된 달러 포맷의 가격이 DOM에 존재하는지 getByText로 조회하고 toBeInTheDocument 매처를 사용하여 검증 가능

🔹 2번째 테스트

it('특정 아이템의 수량이 1000개로 변경될 경우 "최대 999개 까지 가능합니다!"라고 경고 문구가 노출된다', async () => {
  // spy 함수 제작
	const alertSpy = vi.fn();

  // window.alert -> alertSpy로 대체
	// stubGlobal이란 함수를 사용하여 JS DOM 내의 윈도우 객체에 대한 기본 동작을 변경 가능
  vi.stubGlobal('alert', alertSpy);

	// 컴포넌트 렌더링
  const { user } = await render(<ProductInfoTable />);
	// row 조회
  const [firstItem] = screen.getAllByRole('row');

	// textbox 조회
  const input = within(firstItem).getByRole('textbox');

	// 조회한 첫번째 아이템의 텍스트 인풋 요소에 1000을 입력
  await user.clear(input);
  await user.type(input, '1000');

	// 우리가 모킹한 alertSpy에 '최대 999개 까지 가능합니다!'문자열이 전달되어 호출되는지 단언
  expect(alertSpy).toHaveBeenNthCalledWith(1, '최대 999개 까지 가능합니다!');
});

윈도우에 alert함수가 "최대 999개 까지 가능합니다!" 문자열과 함께 호출되었는지 spy함수를 사용하여 단언해야 함.

🔹 3번째 테스트

it('특정 아이템의 삭제 버튼을 클릭할 경우 해당 아이템이 사라진다', async () => {
  const { user } = await render(<ProductInfoTable />);

	// 두 번째 상품 조회
  const [, secondItem] = screen.getAllByRole('row');
	// 삭제 버튼 조회 (button role로)
  const deleteButton = within(secondItem).getByRole('button');

	// 삭제 버튼을 클릭하기 전, 먼저 두번째 아이템이 정상적으로 존재하는지 한번 단언 코드를 넣어봄
  expect(screen.getByText('Awesome Concrete Shirt')).toBeInTheDocument();

	// userEvent에 click 함수를 사용하여 삭제 버튼 클릭을 시뮬레이션함.
  await user.click(deleteButton);

	// Awesome Concrete Shirt 컨텐츠가 DOM에서 존재하지 않는지, 즉 삭제되었는지 확인
  // queryBy~ : 요소의 존재 유무 판단. 존재하지 않아도 에러 X (요소가 DOM에 존재하지 않는 경우 사용 추천)
  expect(screen.queryByText('Awesome Concrete Shirt')).not.toBeInTheDocument();
});
  • 요소가 없을 때 왜 해당 요소가 없는지 명확한 에러 피드백을 주는 get 함수를 사용하는 것이 좋고 요소가 DOM에 존재하지 않는지 단언할 때만 queryBy 함수를 사용하는 것이 좋음.

📌 정리

  • 통합 테스트는 테스트의 범위가 커진 만큼 실제 앱의 비즈니스 로직을 좀 더 상세하게 검증 가능
profile
복습 목적 블로그 입니다.

0개의 댓글