기프티콘 거래 웹앱 with Cypress

김기영·2022년 3월 22일
0

원티드프리온보딩

목록 보기
4/4
post-thumbnail

🎉프로젝트 시작

모바일 중고 쿠폰 거래 웹앱 구현하는 과제가 주어졌다. 구현해야 할 부분은 Api 함수 구현, 결과 타입 정의, 공통 리스트 카드 컴포넌트 그리고 카테고리 상세 페이지, 브랜드 상세페이지이다.

✨Api 함수 구현 및 타입 정의

구현해야할 Api 요청 함수는 총 6개 이다.

// utils/api.ts

export const api = axios.create({
  baseURL: API_ENDPOINT,
});
// 브랜드 + 상품 리스트
export const getBrandAndProductList = async (conCategoryId: number) => {
  const response = await api.get(
    `con-category1s/${conCategoryId}/nested`
  );
  return response.data;
};
// (... 나머지 함수 5개)

baseURL을 api endpoint로 설정해둔 인스턴스를 생성해서 코드 중복을 줄였다. 결과값을 확인하고 아래의 사이트에 입력하여 타입을 지정해주자.

https://transform.tools/json-to-typescript

필요한 타입을 찾기 쉽도록 파일명으로 구분해주었다.

마지막으로 타입추론을 위해 api 함수 반환값 타입을 지정해주자.

export const getBrandAndProductList = async (conCategoryId: number) => {
  const response = await api.get<BrandAndProductList>(
    `con-category1s/${conCategoryId}/nested`
  );
  return response.data;
};

이걸로 api 함수 구현, 타입정의 끝.

✨공통 리스트 카드 컴포넌트

총 세 페이지에서 사용되며, 페이지마다 브랜드 명 유무 정도의 차이가 있다. 상세 데이터를 입력 받아서 렌더하는 카드 아이템 컴포넌트를 만들어보자.

// components/common/ProductCardItem.tsx

interface ProductCardItemProps {
  item: BrandDetailConItem | ProductDetailConItem | ClearanceListConItems;
}

export const ProductCardItem = ({ item }: ProductCardItemProps) => {
  return (...);
};


세 페이지에서 사용되기 때문에, union으로 묶어주었다. 이를 사용하여 전체 리스트를 렌더하는 카드 리스트 컴포넌트를 만들어보자.

// components/common/ProductCardList.tsx

interface ProductCardListProps {
  data: ProductDetailConItem[] | ClearanceListConItems[] | BrandDetailConItem[];
}
export default ProductCardList({ data }: ProductCardListProps) => {
  return (
    <>
      {data?.map((item) => (
        <ProductCardItem item={item} key={item.id} />
      ))}
    </>
  );
}

<ProductCardList data={data} /> 와 같이 전체 데이터를 가공하지 않고 그대로 사용하면 된다.

✨카테고리 상세 페이지

구현해야할 기능은 드래그 가능한 nav bar와 카테고리 데이터 받아와서 뿌려주는 그리드 카드 두 가지이다. 그리드 카드는 위의 리스트 카드 컴포넌트와 같은 방식으로 구현하면 된다.

1. nav bar

만들어 둔 대분류 리스트 조회 API를 사용해서 데이터를 받아와야한다. 동적라우팅 페이지이며, getServerSideProps를 사용해 SSR로 구현할 것이다. Navigation 컴포넌트의 드래그 기능부터 구현하자.

드래그 기능 custom hook

// hooks/useDrag.ts
export default function useDrag () {
  const scrollRef = useRef<HTMLDivElement>(null);
  const [isDrag, setIsDrag] = useState(false);
  const [startX, setStartX] = useState(0);

  const onDragStart = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setIsDrag(true);
    setStartX(e.pageX + scrollRef.current!.scrollLeft);
  };

  const onDragEnd = () => {
    setIsDrag(false);
  };

  const onDragMove = (e: React.DragEvent<HTMLDivElement>) => {
    if (!scrollRef.current) return;
    if (isDrag) {
      scrollRef.current.scrollLeft = startX - e.pageX;
    }
  };
  return { scrollRef, onDragStart, onDragEnd, onDragMove };
}

드래그 기능은 다음 4가지 마우스 이벤트로 구현할 수 있다.

  • mousedown: 마우스 왼쪽 버튼 누른 상태. 이 때 부터 드래그 시작(onDragStart)
  • mousemove: 마우스를 움직이는 상태. 드래그 중(onDragMove)
  • mouseup: 마우스 왼쪽 버튼을 뗀 상태. 드래그 중지(onDragEnd)
  • mouseleave: 마우스가 드래그 객체에서 벗어난 상태. 이 때도 드래그 중지(onDragEnd)

e.pageX는 현재 마우스의 X좌표를 뜻하고, scrollLeft는 드래그 시작 시점에 해당 DOM 객체가 얼마나 X축으로 스크롤 되었는가를 나타낸다. setStartX(e.pageX + scrollRef.current!.scrollLeft) 로 두 값을 더해야 정확한 시작지점을 알 수 있다. scrollLeft를 뺀다면, 드래그 이후 다시 드래그 하려는 순간 원래 위치로 돌아간다.

드래그를 얼마나 했는지 구하는 로직은 다음과 같다.
드래그 정도 = 드래그 시작시점 scrollLeft - 현재 scrollLeft = 드래그 시작시점 마우스 X좌표 - 현재 마우스 X 좌표

scrollLeft = 드래그 시작 지점의 scrollLeft 와 마우스 X 좌표 의 합(startX) - 현재 마우스 X 좌표

// components/category/Navigation.tsx
export const Navigation = ({ item }: NavigationProps): JSX.Element => {
  const { scrollRef, onDragStart, onDragEnd, onDragMove } = useNavigation();

  return (
    <NavContainer
      onMouseDown={onDragStart}
      onMouseMove={onDragMove}
      onMouseUp={onDragEnd}
      onMouseLeave={onDragEnd}
      ref={scrollRef}
    >
      {item.map((menu) => ... )}
    </NavContainer>
  );
};

전체를 감싸는 컴포넌트에 해당하는 이벤트를 달아줌으로써 Navigation 컴포넌트 구현 완료

2. 카테고리 상세 페이지

페이지에서 대분류 리스트 데이터와 카테고리 상세 데이터를 SSR로 받아와 Navigation 컴포넌트와 GridCardList 컴포넌트에 뿌려주어야한다.

// pages/categories/[id].tsx

const CategoryDetailPage: NextPage<CategoryDetailProps> = ({
  categoryList,
}) => {
  return (
    <div>
      <Navigation item={categoryList} />
      <GridCardList data={categoryDetailList} />
    </div>
  );
};
export const getServerSideProps = async (context: any) => {
  const router = context.query.id;
  const categoryList = await getMainCategoryList();
  const data = await getBrandAndProductList(router);
  return {
    props: {
      categoryDetailList: data,
      categoryList: categoryList.conCategory1s,
    },
  };
};

이로써 카테고리 상세 페이지와 nav bar 구현 완료.

✨브랜드 상세 페이지

이미 기능은 다 구현되어있으며, 해당 페이지에서 데이터만 받아와서 컴포넌트에 뿌려주기만 하면 된다.

pages/brands/[id].tsx에 카테고리 페이지와 마찬가지로 SSR로 구현하는 중에 문제가 발생했다.

문제. 두 페이지의 api 요청 주소가 같음

api 명세에 카테고리 > 브랜드 > 상품 데이터 순으로 depth가 구현되어 있으며 위에서 작성한 getBrandAndProductList(id) 함수로 데이터를 받을 수 있다.

스타벅스 브랜드 페이지에서 상품을 보려주려면, 카페 카테고리 api를 호출하여 데이터를 받고, 그 중 스타벅스라는 브랜드 데이터를 필터링해서 출력해주어야했다. 그러나 브랜드 페이지에서 해당 카테고리의 라우트 주소를 알 수 없어서 데이터 호출이 불가능한 것이 문제였다.

해결 시도 1. 리덕스에 데이터 저장

redux를 추가하여 카테고리 페이지에서 받은 전체 데이터를 redux에 저장하고 브랜드 페이지로 이동했을 때 저장된 데이터 중 해당 카테고리에 해당하는 것들만 필터링 하는 것으로 구현했다.

그런데 페이지에서 새로고침을 하면 redux 저장 데이터가 리셋되는 문제가 발생했다.

🐛해결 시도 2. 네트워크 탭

실제 운영중인 사이트의 네트워크 탭을 살펴 봤더니, api 명세와는 전혀 다른 주소로 요청 하고 있었다. 해당 페이지를 제외한 다른 모든 페이지는 명세와 같았다. 이를 참고하여 브랜드 페이지 전용 api 호출 함수를 만들어서 해결했다.

근데 이렇게 해도 되는건가 ..?

이로써 맡은 부분을 모두 해결했고, E2E test가 남았다.

✨E2E test (Cypress)

E2E test는 End to End Test로 종단 간 테스트, 즉 최종 사용자 입장에서 테스트 하는 것이다. 프론트엔드는 사용자에게 직접 노출되는 텍스트나 이미지 같은 UI에 오류가 없는지, 클릭 동작은 제대로 하는지 등을 테스트 한다.

여기서는 E2E test 도구로 Cypress를 사용할 것이다. 프로젝트에 Cypress 추가하는 것부터 테스트 방법까지 간단하게 알아보자. 더 많은 정보는 공식 홈페이지를 참고하자.

1. Cypress 시작

터미널에 yarn add -D cypress @testing-library/cypress를 입력하여 Cypress를 설치한다. 이후 yarn cypress open 을 입력하면 루트 폴더에 다음과 같이 Cypress 폴더가 생성되면서 Cypress 창이 열린다.

생성되는 폴더 구조는 Cypress에서 추천하는 폴더구조이며 다음과 같은 역할을 한다.

  • fixtures: 테스트에서 사용하는 정적 데이터 파일
  • integration: 테스트 파일. 샘플 코드가 들어있음
  • plugins: 환경 변수나 설정 구성 세팅 파일, 특정 단계에 실행할 코드 작성
  • support: 테스트 파일을 읽기 전에 실행될 파일. 모든 테스트 파일에 적용됨

여기서는 다른 부분 빼고 integration에 테스트 파일만 작성할 것이다. 작성할 때, 샘플 테스트 파일을 보면 큰 도움이 된다.

2. 테스트 코드 작성

테스트 파일은 .spec.js 로 끝난다. integration 폴더 안에 home.spec.js 파일을 만들고 아래와 같이 작성하자.

describe("Home Page", () => {
  it("홈페이지 접속하면 페이지 로고가 보인다", () => {
    cy.visit("/");
    cy.get("h2").first().should("have.text", "블라블라");
  });
});

it은 테스트 제목이며 describe는 모든 it을 포함하는 전체 테스트에 대한 설명이다. cy.visit() 으로 페이지 이동이 가능하며, get()으로 요소 선택, should()로 값을 비교할 수 있다. 홈페이지에 접속하고, h2 태그 중 첫번 째의 텍스트가 블라블라인지 확인하는 테스트 코드를 작성한 것이다.

아까 열렸던 창에서 작성한 테스트 파일을 볼 수 있다.

home.spec.js를 클릭 하면 또 다른 창이 열리면서 테스트가 진행된다.

테스트에 성공한 것이며, 실패했다면 원인과 위치를 확인할 수 있다.

it 아래에 두 번째 테스트 코드를 추가해보자.

  it("카테고리를 클릭했을 경우, 해당 카테고리로 이동한다", () => {
    cy.get("a").contains("땡철이").click();
    cy.url().should("include", "/categories/1");
  });

click()으로 클릭 이벤트를 발생시킬 수 있으며, get()안에 어떤 선택자를 입력해야 할지 모르겠다면, 직접 선택하는 방법도 있다.

  beforeEach(() => {
    cy.visit("/");
  });

describe 바로 아래에 beforeEach를 추가함으로써 각각의 테스트를 진행하기 전에 먼저 실행될 코드를 지정해줄 수도 있다. 더 복잡한 테스트 코드는 공식 홈페이지와 샘플코드를 참고해서 작성해보자.

3. 테스트 영상

앞서 yarn cypress open 으로 진행 과정을 단계별로 확인 가능 하다는 것을 알 수 있었다. 테스트 창을 전부 닫고 이번에는 yarn cypress run 을 입력해보자.

다른 테스트 라이브러리 처럼 터미널에서 알아서 테스트를 진행하고 결과를 알려준다. 차이점은 테스트 과정을 비디오 파일로 저장해준다는 것.

이것으로 Cypress E2E test 끝. 프로젝트도 끝.

📝회고 및 배운점

  1. api 호출 함수 종류가 많고, 가공해야할 데이터도 많아서 any 안쓰려고 고생좀 했다. 그러다 보니 타입지정에 조금 익숙해진 것 같다.
  2. SSR 구현이 처음인 팀원이 있어 참고할 ApiSample 페이지를 만들어 PR을 올렸는데 다들 좋아해주셨다.
  3. api 호출 에러가 나면 네트워크 탭부터 열어보는 습관이 생겼다.
  4. Cypress를 처음 사용해봤는데, 공식 홈페이지에 글과 영상으로 설명이 되어있어 편했다. E2E 테스트만 할래.

참고

드래그 기능
Cypress 공식 홈페이지

profile
FE Developer

0개의 댓글