Xây dựng ứng dụng bằng React sử dụng Typescript 2023: TDD với jest và @testing-library

Tôi từng có một bài viết vào năm 2019 nói về việc 

Xây dựng ứng dụng bằng React sử dụng Typescript

. Đây là bài viết cập nhật cho bài viết đó.
Từ những cập nhật của React, chúng ta sẽ ưu tiên sử dụng Function thay vì Class, bên cạnh đó template typescript cũng đã tích hợp sẵn các thư viện để thực hiện việc viết unit test (jest, @testing-library).
Trong bài viết này chúng ta sẽ đi xây dựng một ứng dụng chi tiết hơn: Tìm kiếm sản phẩm bằng cách gọi api. Chúng sẽ sẽ xây dựng ứng dụng theo phương pháp TDD (

Test-driven_development

).

Tạo dự án React mới với Typescript

Để bắt đầu, chúng ta sẽ sử dụng create-react-app để tạo một dự án React mới:

npx create-react-app react-ts-example --template typescript

Hãy mở thư mục mới tạo bằng IDE ưa thích của bạn, sau đó hãy khởi động dev server với câu lệnh npm start

Những thông báo ở terminal cũng gần giống với project javascript, điểm khác ở đây là thông báo code ts được compile sang code js.

Kiểu dữ liệu cho api response

Trong bài viết này, chúng ta sẽ xây dựng một ứng dụng đơn giản. Khi người dùng nhập từ khóa vào ô tìm kiếm và nhấn nút tìm kiếm, chúng ta sẽ gọi một API để lấy ra các sản phẩm liên quan đến từ khóa và hiển thị chúng trên màn hình.

Api được sử dụng là một mock api – dummyJSON, và chúng sẽ sử dụng api Search products.

Hãy cùng xem dữ liệu trả về của api trên:

{
  "products": [
    {
      "id": 1,
      "title": "iPhone 9",
      "description": "An apple mobile which is nothing like apple",
      "price": 549,
      "discountPercentage": 12.96,
      "rating": 4.69,
      "stock": 94,
      "brand": "Apple",
      "category": "smartphones",
      "thumbnail": "...",
      "images": ["..."]
    }
  ],
  "total": 4,
  "skip": 0,
  "limit": 4
}

Kết quả trả về chứ một mảng các product, và các thông tin khác.

Đầu tiên chúng ta sẽ tạo một interface để thể hiện kiểu Product, tạo file IProduct.ts trong thư mục src/shared/interfaces

export interface IProduct {
  id: number;
  title: string;
  description: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  brand: string;
  category: string;
  thumbnail: string;
  images: string[];
}

 

Tiếp theo là kiểu cho kết quả của api tìm kiếm – ISearchProductResponse.ts

import { IProduct } from "./IProduct";

export interface ISearchProductResponse {
  products: IProduct[];
  total: number;
  skip: number;
  limit: number;
}

Chúng ta có thư mục shared , đây là thư mục được dùng chung ở các components, nên để thuận tiện chúng ta sẽ tạo một alias đường dẫn import cho toàn bộ đối tượng của thư mục này. Chỉnh sửa file tsconfig.json

{
"compilerOptions": { // ... "paths": { "@shared/*": ["src/shared/*"] } }, // ... }

Xây dựng các component

ListProducts

Bây giờ chúng ta có thể xây dựng các component. Bắt đầu với component đơn giản nhất, nó sẽ nhận vào một list các product và hiển thị list đó lên màn hình. Chúng ta sẽ đặt các component trong thư mục src/components

Một React component với Typescript sẽ có 2 điểm khác so với Javascript:

  1. Bạn sẽ không cần tới PropTypes để kiểm tra kiểu của Props vì chúng ta đang dùng Typescript
  2. Việc định nghĩa component sẽ hơi khác một chút

Hãy xem qua ListProducts component:

import { IProduct } from "@shared/interfaces/IProduct";
import React from "react";

export type ListProductsProps = {
  products: IProduct[];
};

const ListProducts: React.FC<ListProductsProps> = ({ products }) => {
  return <></>;
};

export default ListProducts;

Chúng ta định nghĩ interface ListProductsProps là kiểu của component properties. Bạn có thể đặt tên interface này tùy ý. Ở đây props chỉ yêu cầu truyền vào một mảng product thông qua thuộc tính products .

Tiếp theo chúng ta khai báo biến ListProducts có kiểu React.FC<ListProductsProps>. Với kiểu khai báo này, tham số đầu tiên của Function component sẽ có kiểu là ListProductsProps.

Vì chúng ta đang viết code theo phong cách TDD, nên chúng ta sẽ ưu tiên viết test trước khi viết production code. Mình sẽ tạo file ListProducts.test.tsx ngay cạnh file production code:

import { render, screen } from "@testing-library/react";
import ListProducts from "./ListProducts";
import { IProduct } from "@shared/interfaces/IProduct";

describe("ListProducts", () => {
  it("should render product list", () => {
    const products = [
      { id: 1, title: "Product 1" },
      { id: 2, title: "Product 2" },
    ] as IProduct[];

    render(<ListProducts products={products} />);

    const lists = screen.getAllByRole("listitem");
    expect(lists).toHaveLength(2);
    expect(lists[0]).toHaveTextContent(products[0].title);
    expect(lists[1]).toHaveTextContent(products[1].title);
  });
});

Kịch bản test khá đơn giản, khi chúng ta truyền cho component một mảng gồm 2 phần tử, chúng ta mong muốn sẽ có một list gồm 2 phần tử được hiển thị với chính xác tiêu đề.

Hãy chạy test với lệnh npm test bạn có thể nhận được một thông báo lỗi kiểu như:

Unable to find an accessible element with the role "listitem"

Giờ hãy cập nhật production code:

import { IProduct } from "@shared/interfaces/IProduct";
import React from "react";

export type ListProductsProps = {
  products: IProduct[];
};

const ListProducts: React.FC<ListProductsProps> = ({ products }) => {
  return (
    <ul>
      {products.map(({ id, title }) => (
        <li key={id}>{title}</li>
      ))}
    </ul>
  );
};

export default ListProducts;

Giờ khi chạy lại lệnh test bạn sẽ nhận được một thông báo “xanh”

PASS src/components/ListProduct.test.tsx

SearchForm

Trong bước tiếp theo, chúng ta sẽ xây dựng một thành phần gọi là SearchForm. Thành phần này sẽ chứa một form gồm một ô văn bản và một nút. Khi người dùng nhập văn bản vào ô văn bản và nhấn nút, chúng ta sẽ gọi một hàm “callback” được truyền vào, với tham số là văn bản người dùng đã nhập.

Đây là kịch bản test dành cho SearchForm component – SearchForm.test.tsx

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

describe("SearchForm", () => {
  let search: jest.Mock;

  beforeEach(() => {
    search = jest.fn();
  });

  it("should call search function when submitting with a keyword", () => {
    const keyword = "iPhone";

    render(<SearchForm search={search} />);
    fireEvent.change(screen.getByRole("textbox"), {
      target: { value: keyword },
    });
    fireEvent.click(screen.getByText("Search"));

    expect(search).toHaveBeenCalledWith(keyword);
  });
});

Sau khi chắc chắn có lỗi sai ở đâu đó (chưa khai báo component, chưa định nghĩa props, chạy test lỗi …), hãy dừng lại và sửa ngay những lỗi đó (có thể bằng cách cập nhật file production). Nếu bạn đã quen thì có thể hoàn thành file test một mạch, sau đó mới hoàn thiện production code.

Đây là file SearchForm.ts đã hoàn thành:

import React from "react";

export type SearchFormProps = {
  search(keyword: string): void;
};

const SearchForm: React.FC<SearchFormProps> = (props) => {
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);
    props.search(formData.get("keyword") as string);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="keyword" />
      <button type="submit">Search</button>
    </form>
  );
};

export default SearchForm;

App

Cuối cùng, chúng ta sẽ tạo một thành phần App để kết nối tất cả các thành phần khác. Khi người dùng nhập vào từ khóa và nhấn nút tìm kiếm, chúng ta sẽ gọi một API với từ khóa đó và hiển thị tất cả các sản phẩm liên quan trên màn hình.

App.test.tsx

import { IProduct } from "@shared/interfaces/IProduct";
import { ISearchProductResponse } from "@shared/interfaces/ISearchProductResponse";
import axios from "axios";
import { render, fireEvent, screen, waitFor } from "@testing-library/react";
import React from "react";
import App from "./App";

describe("App", () => {
  it("displays the search form and the list of products", async () => {
    render(<App />);

    expect(screen.getByTestId("search-form")).toBeInTheDocument();
    expect(screen.getByTestId("list-products")).toBeInTheDocument();
  });

  it("searches for products when the search form is submitted", async () => {
    const response = { products: [] as IProduct[] } as ISearchProductResponse;
    jest.spyOn(axios, "get").mockResolvedValue({ data: response });
    const setProducts = jest.fn();
    jest.spyOn(React, "useState").mockImplementation(() => [[], setProducts]);

    render(<App />);

    const searchInput = screen.getByTestId("search-input");
    const searchForm = screen.getByTestId("search-form");

    // Set the value of the search input
    fireEvent.change(searchInput, { target: { value: "keyword" } });

    // Submit the search form
    fireEvent.submit(searchForm);

    // Assert that the axios.get function was called with the correct URL
    expect(axios.get).toHaveBeenCalledWith(
      "https://dummyjson.com/products/search?q=keyword"
    );

    // Wait for the setProducts function to have been called
    await waitFor(() => {
      expect(setProducts).toHaveBeenCalledWith(response.products);
    });
  });
});

Test này sẽ kiểm tra xem component có hiển thị component SearchForm và ListProducts và cũng kiểm tra xem hàm tìm kiếm đã được gọi khi submit form và có truyền đúng tham số cho hàm axios.get không.

Và đây là file App.tsx

import { IProduct } from "@shared/interfaces/IProduct";
import { ISearchProductResponse } from "@shared/interfaces/ISearchProductResponse";
import axios from "axios";
import React from "react";
import "./App.css";
import ListProducts from "./components/ListProducts";
import SearchForm from "./components/SearchForm";

function App() {
  const [products, setProducts] = React.useState<IProduct[]>([]);

  async function search(keyword: string): Promise<void> {
    const { data: response } = await axios.get<ISearchProductResponse>(
      `https://dummyjson.com/products/search?q=${keyword}`
    );
    setProducts(response.products);
  }

  return (
    <div className="App">
      <SearchForm search={search} />
      <ListProducts products={products} />
    </div>
  );
}

export default App;
Giải thích code bằng GPTChat

Tổng kết

Ứng dụng đã hoàn thành, nó đã có thể thực hiện được các chức năng mà chúng ta đặt ra. Và khi chạy test với options --coverage chúng ta sẽ thấy tất cả logic code của chúng ta đã được cover bởi unit test.

➜ npm run test -- --coverage                    

> [email protected] test
> react-scripts test --coverage
 PASS  src/components/ListProduct.test.tsx
 PASS  src/components/SearchForm.test.tsx
 PASS  src/App.test.tsx
-------------------|---------|----------|---------|---------|-------------------
File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------------|---------|----------|---------|---------|-------------------
All files          |     100 |      100 |     100 |     100 |                   
 src               |     100 |      100 |     100 |     100 |                   
  App.tsx          |     100 |      100 |     100 |     100 |                   
 src/components    |     100 |      100 |     100 |     100 |                   
  ListProducts.tsx |     100 |      100 |     100 |     100 |                   
  SearchForm.tsx   |     100 |      100 |     100 |     100 |                   
-------------------|---------|----------|---------|---------|-------------------

Test Suites: 3 passed, 3 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        9.279 s
Ran all test suites related to changed files.

Đặc biệt, các đoạn code và giải thích trong bài viết được viết bởi công cụ ChatGPT, nghĩa là tôi chỉ là người đưa ra ý tưởng, công cụ sẽ viết code và giải thích toàn bộ, tôi chỉ cần hiệu chỉnh lại một chút xíu. Thật ngạc nhiên phải không?

Mã nguồn của bài viết được đăng tại Github.