jtwjs Dev Wiki
  • DEV_ROAD
    • 💪🏻 생존하기
    • Week 1
      • 개발 환경 세팅
      • 타입스크립트
      • 리엑트
      • Testing Library
      • Parcel & ESLint
    • Week 2
      • JSX
      • Virtual DOM
    • Week 3
      • React Component
      • React State
    • Week 4
      • Express
      • Fetch API & CORS
      • React Hook
      • useRef & Custom Hook
    • Week 5
      • TDD
      • React Testing Library
      • MSW
      • Playwrite
      • Snapshot
    • Week 6
      • Separtion of Concerns
      • Principle
      • DI, (Dependency Injection)
      • Reflect-metadata
      • TSyringe
      • External Store
      • Follow Redux
      • usestore-ts
      • useSyncExternalStore
    • Week 7
      • Routing
      • Routes
      • Router
      • Navigation
    • Week 8
      • Design System
      • Style Basics
      • CSS-in-JS
      • Styled-Components
      • Global Style & Theme
    • Week 9
      • 개발하기 전 준비
      • 상품 목록 페이지
      • 상품 상세 페이지
      • 장바구니 페이지
    • Week 10
      • 로그인
      • 로그아웃
      • 회원가입
      • 주문 목록 & 주문 상세
    • Week 11
      • 배송 정보 입력
      • 포트원 결제 요청
      • 배송 및 결제 정보 전달
    • Week 12
      • 관리자 웹사이트개발시작
  • DEV_NOTE
    • TypeScript
      • 기본적 문법
        • Enum
        • 다형성
          • Untitled
        • 구조적 타이핑
        • 제너릭 타입
        • 컨디셔널 타입
        • 함수 메서드 타이핑
        • infer로 타입스크립트의 추론 직접 활용
        • 재귀 타입
        • 템플릿 리터럴 타입
        • 추가적인 타입 검사 satisfies 연산자
        • 타입스크립트 건망증
        • 원시 자료형에도 브랜딩 기법 사용 가능
        • 타입 좁히기
        • 유용한 타입 만들기
        • 데코레이터 함수
        • 앰비언트 선언도 선언 병합이 된다.
        • 앰비언트 선언도 선언병합이 된다.
    • Testing
      • Unit Testing
      • 단위 테스트의 두 분파
      • 좋은 단위 테스트를 구성하는 4대 요소
      • 테스트 대역과 식별할 수 있는 동작
      • 단위 테스트 스타일
      • 가치 있는 단위 테스트를 위한 리팩토링
      • 통합 테스트
      • Cross Browsing Testing
      • 기능 테스트 종류
      • React Testing Pattern
      • 프론트엔드 테스트 입문
        • 테스트 범위
        • 단위 테스트 검증
        • Mock
        • UI 컴포넌트 테스트
        • 테스트 커버리지
        • 웹 통합 테스트
        • MSW
        • 스토리북
        • 시각적 회귀 테스트
        • E2E 테스트
        • Github Actions 설정
        • 깃허브 액션에서 E2E
      • 시프트 레프트
        • 테스트 기본중의 기본
        • 단위 테스트
        • 코드 복잡도
        • 리팩터링
        • 코드 리뷰
        • 통합 테스트 패턴
        • 시스템 테스트의 자동화
        • 탐색적 테스트
      • Test Tip
      • vitest
      • playwright
      • Test Data Generator
      • MSW
    • Algorithm
      • coding test
      • Data Structure
    • Next.js
      • Data Fetching
      • Hydration
      • Next 13
      • Optimization
      • Next 15
    • Tailwind
      • Tailwind CSS
      • Theme
      • Directives
      • Tool
      • Design System
    • Storybook
      • Storybook
      • CSF3
      • CDD
      • Headless Component
    • Funtional Programming
      • 함수형 프로그래밍
      • 참조 투명성
      • 부수효과
      • 함수 합성
      • 제너릭 타입 활용하기
      • 암묵적 입출력
      • 액션과 계산, 데이터
      • 계층형 설계
      • 호출 그래프
      • 함수형 설계
      • 불변성
      • 일급 함수
      • 함수형 도구
    • Git
      • Github Actions
      • Conflict
      • Branch 전략
    • Contents Format
      • Audio
    • 3D Graphic
      • 3D keyword
      • Three.js
      • Geometry
      • Material
      • Light
      • Camera
      • Decal
      • Rotation
      • Text
      • Shadow
      • Fog
      • Post Processing
      • Animation
      • Math
        • Vector Space
        • 벡터의 연산
        • 회전 계산
      • 3D 컨텐츠가 만들어지는 과정
      • R3F
      • Env
      • Scene
      • Transform
      • R3F
      • Interaction & Raycast
      • Rendering Algorithnm
      • Blender
      • Blender
    • Accessibility
      • 접근성이란
    • Interactive Web
      • Parallax
      • Canvas
      • requestAnimationFrame
      • Effect
      • HSL
      • React.js + Canvas
      • Matter.js
    • AWS
      • DevOps
      • Amplify
      • S3
      • 클라우드 컴퓨팅
        • 온프레미스와 클라우드
        • 클라우드 도입효과
        • 클라우드 컴퓨팅의 범위
        • 컴퓨팅 옵션
          • EC2 - Virtual Machin
          • ECS, EKS - Container
          • Lambda - Serverless
        • 네트워크 가상화
        • 스토리지
        • 데이터베이스
        • 데이터 수집
        • 머신 러닝 영역
        • IoT 영역
        • 블록체인 영역
      • 클라우드 아키텍처 설계
    • Network
      • Web Server & WAS
    • System Design
      • System Design
      • Component
      • 의존성을 배제한 개발
      • Error Handling
      • Architecture
        • 모노로틱 아키텍처
        • Clean Architecture
        • Layered Architecture
        • 이벤트 기반 아키텍처
      • 상황을 파악하는 메타인지
      • 중복 문제 해결하기
      • Monorepo Arhitecture
        • 모노레포 운영과 트러블슈팅
        • Module Federation
      • 코드 병목지점
      • API 대응
      • 공통 코드
      • Infra 구축
      • 모듈 기반의 개발 방식
      • Design System
        • 최소 수준의 아키텍처 설정
        • 더 효율적인 디자인시스템 만들기
        • 디자인 시스템과 UI 라이브러리 목적
        • 디자인 토큰
      • 효율적인 업무
        • 업무 프로세스 병목 파악
      • Clean Code
      • Design Pattern
        • CQRS Pattern
        • Strangler Fig Pattern
        • 데코레이터 패턴
        • 커맨드 패턴
        • 전략 패턴
        • 옵저버 패턴
      • A/B 테스팅
      • 대규모 리엑트 웹앱 개발
        • 복잡성 관리
        • 모듈성
        • 성능
        • 디자인 시스템
        • 데이터 패칭
        • 상태 관리
        • 국제화
        • 코드 조직화하기
        • 개인화 A/B 테스팅
        • 확장 가능한 웹 아키텍처
        • 테스팅
        • 툴링
        • 기술적 마이그레이션
        • 타입스크립트
        • 라우팅
        • 사용자 중심 API 디자인
        • 리액트 미래
    • Performance
      • React DevTools
      • Component 최적화
      • Page Load
      • API
    • MFA
      • MSA
      • MFA 도입하기
      • Monorepo
        • Monorepo Tool
        • Yarn Berry Workspace
        • Turborepo
      • MFA Composition
      • SPA 통합
      • Design System
      • Package Manager
        • Yarn
        • pnpm
      • Transpiler & Bundler
        • Babel
        • Rollup
        • esbuild
        • swc
        • Webpack
        • Vite
      • 분해와 통합을 위한 여러 기술 비교
    • State Management
      • Zustand
    • React v18
      • Automatic batching
      • Suspense
      • Transition
    • SEO
      • Search Engine Optimization
      • Open Graph Element
      • Metadata
    • FE Develop
      • Scrubbing
      • Clipboard
    • Refactoring
      • 리팩토링 깊게 들여다보기
      • 긴 코드 조각내기
      • 타입 코드 처리하기
      • 유사한 코드 융합하기
      • 데이터 보호
      • 코드 추가 및 제거
    • OAuth 2.0
    • Analytics
      • Mixpanel
    • ETC
      • VSCode
    • React Hook In Action
      • useContext & Provider
      • 커스텀 훅
      • 코드 분할하기 with Suspense, lazy
      • Suspense와 이미지 적재하기
      • useTransition, uesDeferredValue
      • SuspenseList
Powered by GitBook
On this page
  • 상품 상세보기 기능
  • User Scenario
  • 장바구니 상품 담기 기능
  • User Scenario
  1. DEV_ROAD
  2. Week 9

상품 상세 페이지

상품 상세보기 기능

상품 상세 정보를 얻어서 표시하는 화면

조회 API를 호출할 때 상품을 찾을 수 없는 경우와 API 오류가 발생한 경우를 고려하여 사용자에게 명확한 안내 메시지 제공

  • 상품을 찾을 수 없는 경우 → "해당 상품을 찾을 수 없습니다."

  • API 오류가 발생한 경우 → "오류가 발생했습니다. 잠시 후 다시 시도해주세요."

User Scenario

  • 상품 이름, 이미지, 옵션, 설명이 노출된다.

  • 장바구니에 상품을 담을 수 있는 버튼을 제공한다.

  • 사용자는 상품 상세페이지에서 상품 옵션과 수량을 선택하여 장바구니에 담을 수 있다.

Unit Test

ProductDetail.test.tsx

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

import { productDetails } from "@/fixtures";
import { withAllContexts, withRouter } from "@/tests/utils";
import { numberFormat } from "@/utils/numberFormat";

import ProductDetail from "./ProductDetail";

const context = describe;

describe("ProductDetail", () => {
  const detail = productDetails[0];

  function renderProductDetail(productId: string) {
    return render(
      withAllContexts(
        withRouter(
          <Route path="/:id" element={<ProductDetail />} />,
          `/${productId}`
        )
      )
    );
  }

  context("with an valid product id", () => {
    it("render correctly", async () => {
      renderProductDetail(detail.id);

      await waitFor(() =>
        expect(
          screen.getByRole("heading", { name: detail.name })
        ).toBeInTheDocument()
      );

      expect(
        screen.getByText(`${numberFormat(detail.price)}원`)
      ).toBeInTheDocument();

      const line = detail.description.split("\n");

      line.forEach((str) => {
        expect(screen.getByText(new RegExp(str))).toBeInTheDocument();
      });

      detail.images.forEach((img) => {
        expect(screen.getByRole("img")).toHaveAttribute("src", img.url);
      });
    });
  });

  context("with an invalid product id", () => {
    it('render text "해당 상품을 찾을 수 없습니다."', async () => {
      renderProductDetail("soso");

      await waitFor(() =>
        expect(
          screen.getByText("해당 상품을 찾을 수 없습니다.")
        ).toBeInTheDocument()
      );
    });
  });
});
E2E Test
/// <reference types="cypress" />
import "@testing-library/cypress/add-commands";

describe("Product Detail", () => {
  beforeEach(() => {
    cy.visit("http://localhost:8081/products");
    cy.findByRole("link", { name: /CBCL 하트자수맨투맨/ }).click();
  });

  it("display product detail", () => {
    cy.findByText(/CBCL 하트자수맨투맨/).should("exist");
    cy.findByText(/편하게 입을 수 있는 맨투맨/).should("exist");
    cy.findByRole("img").should("exist");
    cy.findByRole("button", { name: "장바구니 담기" }).should("exist");
  });

  context("when change the quantity of a product", () => {
    it('default quantity is "1"', () => {
      cy.findByRole("textbox").should("have.value", "1");
    });

    it('changeable minimum qunatity is "1"', () => {
      cy.findByRole("textbox").should("have.value", "1");

      cy.findByRole("button", { name: "decrease" }).click();
      cy.findByRole("button", { name: "decrease" }).click();

      cy.findByRole("textbox").should("have.value", "1");
    });

    it('changeable maximum quantity is "10"', () => {
      Array.from({ length: 12 }).forEach(() => {
        cy.findByRole("button", { name: "increase" }).click();
      });

      cy.findByRole("textbox").should("have.value", "10");
    });

    it("the price changes as well", () => {
      cy.findByText(/128,000/).should("exist");

      cy.findByRole("button", { name: "increase" }).click();

      cy.findByText(/128,000/).should("not.exist");
      cy.findByText(/256,000/).should("exist");
    });
  });

  context("when change option", () => {
    it("display selected option", () => {
      cy.findByRole("combobox", { name: "컬러" }).click();
      cy.findByRole("option", { name: "grey" }).click();

      cy.findByText(/grey/).should("exist");
    });
  });

  context("when product no found", () => {
    it('display "해당 상품을 찾을 수 없습니다."', () => {
      cy.visit("http://localhost:8081/products/xxx");

      cy.findByText(/해당 상품을 찾을 수 없습니다./).should("exist");
    });
  });
});

장바구니 상품 담기 기능

장바구니에 상품을 담는다라는 것은 정확히는 상품 그 자체가 장바구니에 담기는것이 아니라, 해당 상품과 관련된 다양한 옵션 정보, 수량 등이 함께 조합되어 장바구니의 아이템으로 구성되는 것을 의미한다.

User Scenario

  • 장바구니에 담기 버튼을 클릭하면 선택한 상품의 옵션, 수량 값이 장바구니에 추가된다.

  • 장바구니에 담을 상품의 옵션과 수량을 변경할 수 있다.

    • 상품 수량의 기본값은 1이다.

    • 수량은 1 ~ 10 범위 내에서 선택이 가능하다.

  • 수량이 변경될 때 마다 상품 가격도 변경된다.

  • 장바구니 담기가 성공하면 '장바구니에 담았습니다' 텍스트가 노출된다.

Unit Test

ProductFormStore.test.tsx

import { productDetails } from "@/fixtures";

import ProductFormStore from "./ProductFormStore";

const context = describe;

describe("ProductFormStore", () => {
  let store: ProductFormStore;

  beforeEach(() => {
    store = new ProductFormStore();
  });

  describe("changeOptionItem", () => {
    const [product] = productDetails;
    const [option1, option2] = product.options;

    it("sets option item", () => {
      store.changeOptionItem({
        id: option1.id,
        itemId: option1.items[0].id,
      });

      store.changeOptionItem({
        id: option2.id,
        itemId: option1.items[0].id,
      });

      expect(store.selectedOptionItems).toEqual([
        {
          id: option1.id,
          itemId: option1.items[0].id,
        },
        {
          id: option2.id,
          itemId: option1.items[0].id,
        },
      ]);
    });

    context("with same option", () => {
      it("replace option item", () => {
        store.changeOptionItem({
          id: option1.id,
          itemId: option1.items[0].id,
        });

        store.changeOptionItem({
          id: option1.id,
          itemId: option1.items[1].id,
        });

        expect(store.selectedOptionItems).toEqual([
          { id: option1.id, itemId: option1.items[1].id },
        ]);
      });
    });
  });

  describe("getSelectedOptionItem", () => {
    const [product] = productDetails;
    const [option] = product.options;

    context("when the value of that option is selected", () => {
      it("returns the corresponding option item", () => {
        store.changeOptionItem({
          id: option.id,
          itemId: option.items[0].id,
        });

        expect(store.getSelectedOptionItem(option)).toEqual(option.items[0]);
      });
    });

    context("when no value selected", () => {
      it("returns undefined", () => {
        expect(store.getSelectedOptionItem(option)).toBeUndefined();
      });
    });
  });

  describe("changeQuantity", () => {
    context("with correct value", () => {
      it("changes quantity", () => {
        store.changeQuantity(3);

        expect(store.quantity).toBe(3);
      });
    });

    context("when value is less than 1", () => {
      it("doesn't changes quantity", () => {
        store.changeQuantity(-1);
        expect(store.quantity).toBe(1);
      });
    });

    context("when value is greater than 10", () => {
      it("doesn't changes quantity", () => {
        store.changeQuantity(11);
        expect(store.quantity).toBe(1);
      });
    });
  });
});

AddToCartForm.test.tsx

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

import { productDetails } from "@/fixtures";
import { withAllContexts } from "@/tests/utils";
import { numberFormat } from "@/utils/format";

import AddToCartForm from "./AddToCartForm";

const [product] = productDetails;
const { options } = product;

const store = {
  selectedOptionItems: options.map((opt) => ({
    id: opt.id,
    itemId: opt.items[0].id,
  })),
  quantity: 1,
  totalPrice: jest.fn(),
  getSelectedOptionItem: jest.fn(),
  changeOptionItem: jest.fn(),
  clear: jest.fn(),
};

jest.mock("@/hooks/useProductFormStore", () => () => store);

const context = describe;

describe("AddToCartForm", () => {
  function renerAddToCartForm() {
    return render(withAllContexts(<AddToCartForm product={product} />));
  }

  beforeEach(() => {
    jest.clearAllMocks();
    store.totalPrice.mockImplementation(
      (price: number) => store.quantity * price
    );
  });

  it("renders correctly", () => {
    renerAddToCartForm();

    expect(screen.getAllByRole("combobox")).toHaveLength(options.length);

    const totalPrice = store.quantity * product.price;
    expect(screen.getByText(`${numberFormat(totalPrice)}원`));
  });

  context("when selectedItem is changed", () => {
    it("changeOptionItem is called", async () => {
      renerAddToCartForm();

      const selectEl = screen.getByRole("combobox", { name: "Color" });
      await userEvent.click(selectEl);

      const optionEl = screen.getByRole("option", { name: "Black" });
      await userEvent.click(optionEl);

      expect(store.changeOptionItem).toBeCalled();
    });
  });
});

Quantity.test.tsx

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

import { withAllContexts } from "@/tests/utils";

import Quantity from "./Quantity";

const store = {
  quantity: 4,
  changeQuantity: jest.fn(),
};

jest.mock("@/hooks/useProductFormStore", () => () => store);

const context = describe;

describe("Quantity", () => {
  function renderQuantity() {
    return render(withAllContexts(<Quantity />));
  }

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it("renders quantity", () => {
    renderQuantity();

    expect(screen.getByRole("textbox")).toHaveValue("4");
  });

  context('with "+" button is clicked', () => {
    it("couchangeQuantity is called", async () => {
      renderQuantity();

      const decreaseBtn = screen.getByRole("button", { name: "decrease" });

      await userEvent.click(decreaseBtn);

      expect(store.changeQuantity).toHaveBeenCalledWith(3);
    });
  });

  context('with "-" button is clicked', () => {
    it("changeQuantity is called", async () => {
      renderQuantity();

      const increaseBtn = screen.getByRole("button", { name: "increase" });

      await userEvent.click(increaseBtn);

      expect(store.changeQuantity).toHaveBeenCalledWith(5);
    });
  });
});
E2E Test
/// <reference types="cypress" />
import "@testing-library/cypress/add-commands";

describe("Product Detail", () => {
  beforeEach(() => {
    cy.visit("http://localhost:8081/products");
    cy.findByRole("link", { name: /CBCL 하트자수맨투맨/ }).click();
  });

  describe("add to cart", () => {
    context("when none of the options are selected", () => {
      it('alert will pop up with the message "옵션을 선택해주세요"', () => {
        cy.findByRole("button", { name: "장바구니 담기" }).click();

        cy.on("window:alert", (txt) => {
          expect(txt).to.contains("옵션을 선택해주세요");
        });
      });
    });

    context("when product to cart is succeed", () => {
      it('display "장바구니에 담았습니다', () => {
        cy.findByRole("combobox", { name: "컬러" }).click();
        cy.findByRole("option", { name: "grey" }).click();
        cy.findByRole("combobox", { name: "사이즈" }).click();
        cy.findByRole("option", { name: "ONE" }).click();

        cy.findByRole("button", { name: "장바구니 담기" }).click();

        cy.findByText(/장바구니에 담았습니다/).should("exist");
      });
    });
  });
});
Previous상품 목록 페이지Next장바구니 페이지

Last updated 1 year ago