React TDD - Travel Product App(2)

박정호·2022년 8월 25일
0

TDD

목록 보기
6/6
post-thumbnail

⭐️ SummaryPage

지금까지 OrderPage에서 했었던 것은 products의 수량 조절, options의 체크 조절, 각자 금액과 total 금액까지 구하는 기능들을 구현했었다. 그렇다면 이제 구매하려는 상품들을 주문하는 기능을 추가해보자.

이 페이지에서 구현할 UI는 다음과 같다.

  • OrderPage에서 구매하려고한 products, options의 금액과 목록이 주문확인 UI에 뜬다.
  • 주문 확인 체크 박스를 눌러야 주문 확인 버튼을 누를 수 있는 코드를 구현.

우선, SummaryPage에 대한 테스트케이스 작성과 실제 코드 작성을 해보자.

❌ Write a failing test

  1. DOM에 렌더링할 컴포넌트를 render함수에 넣는다.
  2. getByRole을 이용하여 DOM 상의 name이 "주문하려는 것을 확인하셨나요?"를 갖는 checkbox 앨리먼트를 선택한다.
  3. checkbox의 값은 false인지 유효성 검증한다.
  4. getByRole을 이용하여 DOM 상의 name이 '주문 확인'인 button 앨리먼트를 선택한다.
  5. confirmButton이 true로 취급되어 disabled로 동작 불가인 상태인지 유효성 검증한다.(평상시에는 동작불가이다가 체크박스에 체크될 경우 false가 되어 abled(동작) 한다.)

💡 잠깐) 앞서 보았던 컴포넌트들의 테스트케이스에서 context를 받기위한 wrapper를 감싸주었었다. 따라서, SummaryPage.test.js 또한 cumtom render를 사용하자.

import { render, screen } from "@testing-library/react";
에서
import { render, screen } from "../../../test-utils";
로 변경

Why? -> React TDD - Travel Product App(1)의 context wrapper 부분 참고

<SummaryPage.test.js>

💡 잠깐) screen
'DOM 테스팅 라이브러리에서 내보낸 모든 쿼리는 컨테이너를 첫 번째 인수로 받아들입니다. 전체 document.body를 쿼리하는 것은 매우 일반적이기 때문에 DOM 테스팅 라이브러리는 또한 document.body에 미리 바인딩된 모든 쿼리가 있는 screen 객체를 내보냅니다(inside 기능 사용). React Testing Library와 같은 래퍼는 화면을 다시 내보내므로 동일한 방식으로 사용할 수 있습니다.' (공식문서)

✏️ Make the test Pass

  1. input의 속성은 체크박스이고 checked는 false이며 onChange를 통해 checked의 false,true 변화를 감지한다.
  2. 라벨은 input의 이름을 적는 태그로, htmlFor에 input의 'confirm-checkbox'를 적어 input과 연결
  3. 체크박스에 체크가 될 경우 checked의 값은 true가 되며, button의 disabled 속성은 !true(=false)가 되어 동작이 가능이 가능해진다. 즉, 체크박스에 체크를 함으로써 버튼의 동작이 활성화 된다.

<SummaryPage.js>

참조: Label태크 & HTMLFor

🧺 Form Order to order completion

지금까지 한 테스트케이스는 각자 컴포넌트에 대한 테스트들을 진행하였다.
이제 주문부터 주문확인 그리고 주문완료까지의 모든 과정을 테스트케이스로 작성해보자.

모든 과정이 담긴 최상위 컴포넌트인 App을 테스트
(App.js의 경우 OrderContextProvider로 감싸져 있는 컴포넌트이기 때문에 custom render가 아닌 일반 render를 사용한다.)

👉 주문(OrderPage)

  1. products의 수량 증가에 대한 테스트 케이스 작성.(현재 america 수량 2개, England 수량 3개)
  2. options의 체크 유무에 대한 테스트케이스 작성.(현재 insurance에 체크 한개)

❌ Write a failing test

✏️ Make the test Pass

orderPage에 대한 테스트 케이스였으므로 orderPage에 실제 코드를 작성.
또한 주문페이지, 주문확인페이지, 주문완료페이지를 서로 연결시켜보자.

  • step이라는 값이 0,1,2로 변하는 것에 따라서 어떤 컴포넌트가 올지 결정된다.
  • props로 전달된 setStep을 이용하여 step값을 1로 변경시킨다.
    (즉, 주문페이지에서 주문하기를 클릭하면 step이 1로 변경되어 주문확인(SummaryPage)페이지로 넘어가게된다.)

👉 주문확인(SummaryPage)

❌ Write a failing test

  1. h1, h2와 같은 headText에 알맞은 name이 작성되어있는지 확인.
  2. products의 이름과 수량에 대한 정보가 화면상에 존재하는지를 toBeTheDocument()를 통해 확인.
  3. 주문확인에 대한 알림과 주문확인 버튼에 대한 name이 작성되었고 체크되었는지 확인

✏️ Make the test Pass

주문확인 UI는 총 세 부분으로 나눌 수 있다.

❤️ 상단 부분

1. 주문확인

 <h1>주문 확인</h1>

2. Products: ₩4000

  • orderDatas에 존재하는 totals에서 Products의 총액을 출력.
  const [orderDatas] = useContext(OrderContext);
	...
  <h2>Products: ₩ {orderDatas.totals.products}</h2>

3. 상품 목록 (상품 수량, 상품 이름)

  • Array.from을 이용하여 Map()으로 생성된 orderDatas의 products를 복사하여 새로운 Array 객체로 만들고 productArray에 저장.
  • productArray를 map을 이용하여 li태그에 반복 출력하는 값을 productList에 저장하고 ul태그에 productList를 출력.
const productArray = Array.from(orderDatas.products);
const productList = productArray.map(([key, value]) => (
    <li key={key}> {value} {key} </li>
  ));
	...
      <ul>{productList}</ul>

💡 잠깐) Array.from
Array.from() 메서드는 유사 배열 객체(array-like object)나 반복 가능한 객체(iterable object)를 얕게 복사해 새로운Array 객체를 만든다.

주문확인 페이지에서 console.log를 이용하여 orderDatas와 productArray를 출력해보
았다.

<console.log(orderDatas)>

  • orderDatas는 Map()으로 생성된 반복가능객체로 다음과 같이 출력.

    <console.log(productArray)>
  • productArray는 Array.from으로 생성된 새로운 배열 객체로, 다음과 같이 출력.

참고

💚 중간 부분

1. Option: ₩ 500 + option 목록
option의 경우 말그대로 option이기 때문에 option의 가격과 목록을 products와 같이 기본적으로 출력하는 것이 아니라, option이 존재할 경우에만 출력되게 조건을 준다.

  • orderDatas.options.size는 option이 몇개 존재하는지를 출력한다. 만약 이 값이 0보다 크다면 option이 존재한다는 true값을 의미하고, hasOptions 저장한다.
  • if문이 true일 경우(option이 존재할 경우) Array.from으로 options의 keys를 새로운 반복 객체로 만들어서 optionsArray에 저장한다. (options.key는 option의 이름을 의미. option은 product와 달리 수량이 존재하지 않으므로..)
  • optionsArray의 값을 li태그에 반복 출력하는 값을 optionList에 저장한다.
  • 마지막으로 optrionsRender에 옵션의 총액과 orderList가 저장되고, 출력된다.
const hasOptions = orderDatas.options.size > 0;
  let optionsRender = null;
  if (hasOptions) {
    const optionsArray = Array.from(orderDatas.options.keys());
    const optionList = optionsArray.map((key) => <li key={key}>{key}</li>);
    optionsRender = (
      <>
        <h2>옵션: {orderDatas.totals.options}</h2>
        <ul>{optionList}</ul>
      </>
    );
  }
  ...
  {optionsRender}

💜 하단 부분

1. 주문하려는 것을 확인하셨나요? + 주문확인

  • 알림 체크와 버튼의 관계는 SummaryPage에서 설명하였으므로 생략.

2. 주문확인 클릭 시 주문완료페이지로 이동

  • 주문확인 버튼 클릭 form의 onSubmit으로 form의 데이터가 제출이되고 handleSubmit이 작동한다.
  • 데이터 제출 후 기본적으로 재렌더링되어 handleSubmit의 코드가 구현되지않는 것을 방지하기 위해 event.preventDefault()를 작성.
  • setStep(2)로 step을 2로 변화, 즉 step의 값이 2일 때 작동되는 CompletePage가 출력된다.
  const [checked, setChecked] = useState(false);
  const handleSubmit = (event) => {
    event.preventDefault();
    setStep(2);
  };
  ... 
  <form onSubmit={handleSubmit}>
        <input
          type="checkbox"
          checked={checked}
          onChange={(e) => setChecked(e.target.checked)}
          id="confirm-checkbox"
        />
        <label htmlFor="confirm-checkbox">주문하려는 것을 확인하셨나요?</label>
        <br />
        <button disabled={!checked} type="submit"> 주문 확인 </button>
 </form>

<전체코드>

👉 주문완료

❌ Write a failing test

  1. 주문완료페이지가 렌더링되는 과정에서 정규식의 case insensitive를 적용된 loading이라는 텍스트를 확인.(loading/i)

  2. '주문이 성공했습니다'라는 headingText를 확인.

  3. 데이터가 받아와지고 주문확인페이지가 떴을 때에는 loading문구가 사라져야 한다. 따라서, not.toBeIntheDocument를 이용하여 화면상에 보이지 않는지를 확인.

  4. '첫페이지로'라는 버튼 확인.

✏️ Make the test Pass

1. orderCompleted 실행.

  • 가장 먼저 orderCompleted가 실행되고, POST를 통해 요청 URL과 OrderDatas를 백엔드에 보낸다.
  • 백엔드로 부터 받은 요청값을 orderHistory에 저장한다.
  • error 발생 경우 ErrorBanner컴포넌트가 렌더링된다.

💡 잠깐) 백엔드로부터 받은 데이터는 무엇?

  • 서버의 구조는 아래의 Server를 확인하자.
 useEffect(() => {
    orderCompleted(OrderDatas);
  }, []);

  const orderCompleted = async (OrderDatas) => {
    try {
      let response = await axios.post(
        "http://localhost:8000/order",
        OrderDatas
      );
      setOrderHistory(response.data);
      setLoading(false);
    } catch (error) {
      setError(true);
    }
  };

  if (error) {
    return <ErrorBanner message="에러가 발생했습니다." />;
  }

2. orderTable 생성

  • 데이터가 저장된 orderHistory를 표 형태로 반복하여 출력하는 구조를 orderTable에 저장. 그리고 orderTable 출력.
  • Math.random을 사용하여 상품에 대해 난수의 주문번호가 부여되어, 주문번호와 가격이 반환된다.
 const orderTable = orderHistory.map((item) => (
    <tr key={item.orderNumber}>
      <td>{item.orderNumber}</td>
      <td>{item.price}</td>
    </tr>
  ));
  ...
  <tbody>
     <tr>
          <th>주문 번호</th>
          <th>주문 가격</th>
          </tr>
          {orderTable}
  </tbody>

3. loading

  • loading의 기본값은 true로 loading이라는 text가 출력될 것이다.
  • orderCompleted가 실행되고 try부분이 실행(요청 성공)된다면 loading은 false값을 갖게 되고, 주문완료 메인 부분이 return 될것이다.

 const orderCompleted = async (OrderDatas) => {
    try {
     ...
      setLoading(false);
    }
  };
 
 if (loading) {
    return <div>loading</div>;
  } else {
    return (.  ..);
  }

4. resetOrderDatas & 이동

  • '첫페이지로'를 클릭하면 주문페이지로 돌아가게 됨으로 step값이 0으로 변화시키는 것을 확인 가능.
  • 페이지 이동 시 orderDatas의 값들은 초기화되어야하므로 resetOrderDatas를 호출.
 const handleClick = () => {
    resetOrderDatas();
    setStep(0);
  };
 ...        
 <button onClick={handleClick}>첫페이지로</button>

💡 resetOrderDatas
해당 함수는 orderContext.js에 존재한다.
앞서 보았던 resetOrderDatas의 역할은 orderDatas를 초기화 시키는 것이다.
value에 해당 함수가 존재하고 return해주어 위에서와 같이 resetOrderDatas를 호출할 수 있는 것이다.

 const value = useMemo(() => {
 	...
    const resetOrderDatas = () => {
      setOrderCounts({
        products: new Map(),
        options: new Map(),
      });
    };

    return [{ ...orderCounts, totals }, updateItemCount,  resetOrderDatas];
  }, [orderCounts, totals]);

👉 not wrapped in act

이제 모든 테스트케이스 작성과 실제 코드 작성이 끝났다. 하지만, 다음과 같은 코드를 발견할 수 있다.

🧐 Why?

리액트에서 나오는 act 경고는 컴포넌트에 아무것도 일어나지 않을 것으로 예상하고 있을 때 컴포넌트에 어떤 일이 일어나면 나오는 경고이다.

원래 컴포넌트에서 무언가가 일어난다고 해주려면 act라는 함수로 감싸 주어야 한다.

act(() => {
/* fire events that update state */
});

😅 But?

지금까지는 act로 감싸준 적이 없다.

react-testing-library 내부에 API에 act를 이미 내포하고 있어서 우리가 일 부로 act로 감싸서 호출하지 않고 렌더링과 업데이트를 할 수 있다. (리액트 콜 스택 안에 있을 때)

  • act함수 사용 유무

현재 react-testing-library 사용중이니 이미 act로 감싸준 것이랑 마찬가지인데 지금은 왜 에러가 날까?

💡중요
컴포넌트가 비동기 API 호출을 할 때나 렌더링이나 어떠한 것이 업데이트 되기 전에 테스트가 종료 될 때는 따로 act로 감싸주어야하기 때문이다.

즉, 첫페이지로라는 버튼을 누르면 주문페이지컴포넌트가 비동기호출로 업데이트되어지고 있는 중이다.(비동기 호출로 서버로부터 이미지,텍스트 업데이트).
하지만, 테스트케이스는 버튼을 클릭하자마자 종료되어버리는 것이다.


지금 현재 나의 테스트케이스 작성 상황은 다음과 같다.

  1. 주문 완료 페이지에 "첫페이지로" 버튼을 누른다.
  2. 첫 페이지로 갔기 때문에 리액트는 첫페이지에서 어떠한 일이 일어날거 라고 생각한다.
  3. 하지만 테스트 코드에서는 첫 페이지부분으로 가는 버튼을 누르고 바로 테스트가 종료 된다.
    -> 그래서 리액트가 act경고를 보여주는 것.

ex) 첫페이지로 이동 후 테스트 종료

😃 Solution

일어날 일들을 waitFor API를 이용해서 테스트가 끝나기전에 컴포넌트가 다 업데이트 되기를 기다리는 것이다..

  • 결과적으로 첫페이지 이동 후 일어나는 일들에 대한 테스트케이스 작성.

💡 잠깐) waitFor?
이전에 학습할 때 findBy는 getBy + waitFor라는 것을 기억할 수 있다.

waitFor를 사용하여 기대가 통과할 때까지 기다릴 수 있다.

따라서 waitFor()라는 함수 대신findByXxx() 함수를 사용할 수 있다.

위에서는 getByRole + awaitFor, 아래에서는 findByRole 사용

await screen.findByRole("spinbutton", { name: "America" });

💻 Server

1️⃣ travel.json

  • orderPage에서 API호출해서 받아온 데이터들은 바로 travel.json의 데이터들이다.
  • countries의 데이터들이 products 컴포넌트에 출력된 것이고, options의 데이터들은 options 컴포넌트에 출력된 것이다.

2️⃣ Server.js

👉 Port & CORS

  • port는 8000으로 지정.
  • react는 3000포트에서 개발되어지므로 CORS문제가 있고 다음과 같은 코드로 해결.

👉 products & options

  • travelDataRaw는 travel.json의 데이터를 찾는다.
  • travelData는 json문자열을 자바스크립트 객체로 변환한다.
  • get 요청에 맞게 데이터를 실어 반환해준다.

👉 orderHistory

  • orderHistory는 주문완료페이지에서 주문번호로 출력되는 데이터이다.

⚙️ Issue & TroubleShooting

간단한 프로젝트를 마치고 이제 발생하는 문제들에 대해 하나씩 해결해보려고 한다.

👉 1. 주문

  • 주문을 할때 메인상품이 선택되고, 그에 맞는 옵션이 선택되어야한다. 하지만, 지금까지 구현한바로는 메인상품 선택없이도 옵션만 선택이 가능하게 되어있고 옵션만 주문이 가능하다. 따라서 메인상품을 반드시 선택해야 옵션이 추가되는 기능을 넣어보자.

👉 2. 주문완료

  • 만약 내가 1개의 상품을 주문하면 주문확인에 1000원이라는 값이 잘 출력되고 주문확인까지 넘어간다. 하지만, 표에는 1000이 두번 출력되는 문제가 발생한다. 주문확인페이지가 렌더링 될때 console.log(orderDatas)를 실행해보니 두번 렌더링되어 두번 연속으로 출력되는 것을 확인할 수 있었다. 왜 그런지 찾아봐야 겠다.
profile
기록하여 기억하고, 계획하여 실천하자. will be a FE developer (HOME버튼을 클릭하여 Notion으로 놀러오세요!)

0개의 댓글