상품 상세 페이지

상품 상세보기 기능

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

조회 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");
      });
    });
  });
});

Last updated