상품 상세 페이지
상품 상세보기 기능
상품 상세 정보를 얻어서 표시하는 화면
조회 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