상품 목록 페이지

상품 목록 보기 기능

상품 목록을 얻어서 표시하는 화면

  1. 상품 목록 패칭

  2. 상품 목록 리스팅

User Scenario

  • 카테고리 별로 상품 목록이 보여져야 한다.

  • 카테고리를 선택하지 않으면 전체 상품이 노출된다.

  • 각 상품은 상품명과 가격이 노출된다.

  • 상품을 클릭하면 상품 상세 페이지로 넘어간다.

Null Object Pattern

  • 객체지향 디자인패턴 중 하나로 객체가 null 값을 반환할 때 생길 수 있는 문제를 해결하기 위해 사용됨

  • NullPointerException 같은 예외를 피하기 위해 null 대신 객체의 인터페이스를 충족하지만 값이 없는 객체를 반환한다.

Intl Object 다양한 나라의 언어에 맞는 문자열, 숫자, 시간, 날짜 비교와 관련된 포맷팅 기능을 제공한다.

Unit Test

ProductList.test.tsx

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

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

import ProductList from "./ProductList";

const context = describe;

describe("ProductList", () => {
  it("render correctly", async () => {
    render(
      withAllContexts(withRouter(<Route path="/" element={<ProductList />} />))
    );

    await Promise.all(
      products.map(async (product) => {
        await waitFor(() =>
          expect(screen.getByText(product.name)).toBeInTheDocument()
        );
        await waitFor(() =>
          expect(screen.getByText(product.price)).toBeInTheDocument()
        );
      })
    );

    await waitFor(() => expect(screen.getAllByRole("link")).toHaveLength(2));
  });

  context("when product item is clicked", () => {
    const product = products[0];

    it("navigate to the product detail page", async () => {
      function DetailPage() {
        const params = useParams();
        return <pre>{JSON.stringify(params)}</pre>;
      }

      render(
        withAllContexts(
          withRouter(
            <>
              <Route path="/" element={<ProductList />} />
              <Route path="/:id" element={<DetailPage />} />
            </>
          )
        )
      );

      await waitFor(() => screen.getByText(product.name));
      const linkBtn = screen.getAllByRole("link")[0];

      userEvent.click(linkBtn);

      await waitFor(() =>
        expect(screen.getByText(new RegExp(product.id))).toBeInTheDocument()
      );
    });
  });
});

Product.test.tsx

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

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

import Product from "./Product";

describe("Product", () => {
  const product = products[0];
  it("render correctly", () => {
    render(withAllContexts(<Product product={product} />));

    expect(screen.getByRole("img")).toHaveAttribute(
      "src",
      product.thumbnail.url
    );

    expect(screen.getByText(product.name)).toBeInTheDocument();
    expect(screen.getByText(product.price)).toBeInTheDocument();
  });
})
E2E Test
/// <reference types="cypress" />
import "@testing-library/cypress/add-commands";

describe("Product List", () => {
  beforeEach(() => {
    cy.visit("http://localhost:8081");
  });

  context("when product link btn is clicked", () => {
    it("display products from all categories", () => {
      cy.findByRole("link", { name: "Products" }).click();

      cy.findByText(/CBCL 하트자수맨투맨/).should("exist");
      cy.findByText(/밴딩스커트/).should("exist");
      cy.findByText(/CBCL EARRING Silver/).should("exist");
    });
  });

  context("when product is clicked", () => {
    it("navigate product detail page", () => {
      cy.findByRole("link", { name: "Products" }).click();

      cy.findByRole("link", { name: /CBCL 하트자수맨투맨/ }).click();

      cy.findByText("상품 상세 페이지").should("exist");
    });
  });
});

상품 카테고리 목록

외부에서 상품 카테고리를 주입받아(API Fetching) 헤더에 표시

해당 데이터 관리를 어디서 하느냐에 따라 외부(API)에서 주입받는 데이터, 내부에서 관리하는 데이터로 구분할 수 있다.

즉, 어드민 페이지에서 상품 카테고리를 관리한다면 외부에서 데이터를 주입받아 표시하는 형태로 진행한다.

자주 바뀌지 않고 클라이언트에서 즉시 수정하는게 편한 데이터는 클라이언트 내부에서 관리하는 것이 좋다.

User Scenario

  • 어드민에서 지정한 카테고리 목록들이 표시된다.

  • 카테고리 버튼을 클릭하면 해당하는 상품 목록 페이지로 넘어간다.

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

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

import Header from "./Header";

describe("Header", () => {
  function renderHeader() {
    return render(
      withAllContexts(withRouter(<Route path="/" element={<Header />} />))
    );
  }

  it("render correctly", () => {
    renderHeader();

    expect(
      screen.getByRole("heading", { level: 1, name: "Shop" })
    ).toBeInTheDocument();
    expect(screen.getByRole("link", { name: "Products" })).toBeInTheDocument();
    expect(screen.getByRole("link", { name: "Cart" })).toBeInTheDocument();
  });

  it("render categories", async () => {
    renderHeader();

    await Promise.all(
      categories.map(async (ctg) => {
        await waitFor(() =>
          expect(
            screen.getByRole("link", { name: ctg.name })
          ).toBeInTheDocument()
        );
      })
    );
  });
});
E2E Test
/// <reference types="cypress" />
import "@testing-library/cypress/add-commands";

describe("Product List", () => {
  beforeEach(() => {
    cy.visit("http://localhost:8081");
  });
  
  context("when category btn is clicked", () => {
    it("display product from  categories", () => {
      cy.findByRole("link", { name: "top" }).click();

      cy.findByText(/밴딩스커트/).should("not.exist");
      cy.findByText(/CBCL 하트자수맨투맨/).should("exist");

      cy.findByRole("link", { name: "acc" }).click();

      cy.findByText(/CBCL 하트자수맨투맨/).should("not.exist");
      cy.findByText(/CBCL EARRING Silver/).should("exist");
      cy.findByText(/62000/).should("exist");
    });
  });
});

Last updated