[React Basics] Hướng dẫn Testing trong React: Đảm bảo ứng dụng hoạt động hoàn hảo

Trong thế giới phát triển phần mềm hiện đại, việc xây dựng một ứng dụng React tuyệt vời không chỉ dừng lại ở việc viết code cho tính năng chạy đúng. Để đảm bảo ứng dụng của bạn ổn định, dễ bảo trì và có thể mở rộng trong tương lai, testing (kiểm thử) là một công đoạn không thể thiếu.

Hướng dẫn Testing trong React

Bài viết này sẽ giúp bạn từ một người mới bắt đầu có thể tự tin áp dụng các chiến lược testing hiệu quả nhất vào dự án React của mình. Chúng ta sẽ cùng nhau khám phá từ những khái niệm cơ bản nhất đến các kỹ thuật nâng cao, kèm theo ví dụ thực tế dễ hiểu.

Tại sao Testing lại quan trọng đến vậy? 🤔

Hãy tưởng tượng bạn vừa ra mắt một tính năng mới và người dùng bắt đầu phàn nàn về lỗi. Bạn lao vào sửa lỗi này, nhưng lại vô tình làm hỏng một tính năng khác. Vòng lặp sửa lỗi - tạo lỗi mới này thật sự là một cơn ác mộng. Testing giúp chúng ta phá vỡ vòng lặp đó bằng cách:

  • Đảm bảo chất lượng: Phát hiện lỗi sớm trong quy trình phát triển, trước khi chúng đến tay người dùng.
  • Tăng sự tự tin khi refactor code: Bạn có thể tự tin cải tiến, tối ưu hóa code mà không sợ làm hỏng các tính năng hiện có. Bộ test sẽ là "lưới an toàn" cho bạn.
  • Cải thiện kiến trúc code: Viết code có khả năng test tốt (testable code) thường đồng nghĩa với việc viết code có kiến trúc rõ ràng, module hóa và dễ hiểu hơn.
  • Tài liệu sống: Các bài test mô tả chính xác cách component hay hàm của bạn nên hoạt động, trở thành một nguồn tài liệu sống động và luôn cập nhật.

Kim tự tháp Testing: Chiến lược kiểm thử hiệu quả

Không phải tất cả các loại test đều được tạo ra như nhau. Kim tự tháp testing là một mô hình kinh điển giúp chúng ta phân bổ nỗ lực một cách hợp lý:

Kim tự tháp Testing: Chiến lược kiểm thử hiệu quả

  • Unit Tests (Kiểm thử đơn vị) - Nền tảng: Đây là loại test phổ biến nhất. Chúng kiểm tra từng "đơn vị" code nhỏ nhất một cách độc lập (ví dụ: một component, một hàm). Chúng chạy rất nhanh, viết đơn giản và cung cấp phản hồi tức thì.
  • Integration Tests (Kiểm thử tích hợp) - Tầng giữa: Kiểm tra sự tương tác và kết hợp giữa nhiều đơn vị với nhau. Ví dụ, kiểm tra xem khi người dùng nhấn nút "Thêm vào giỏ hàng" thì component giỏ hàng có được cập nhật đúng hay không. Chúng chậm hơn Unit Test nhưng đảm bảo các phần của ứng dụng hoạt động tốt khi kết hợp lại.
  • End-to-End (E2E) Tests - Đỉnh kim tự tháp: Mô phỏng lại toàn bộ luồng hành vi của người dùng trên ứng dụng, từ đầu đến cuối. Ví dụ: một kịch bản test E2E có thể là: mở trang web -> đăng nhập -> tìm kiếm sản phẩm -> thêm vào giỏ hàng -> thanh toán. Chúng chạy chậm nhất và phức tạp nhất nhưng lại mang lại sự tự tin cao nhất về hoạt động tổng thể của hệ thống.

Nguyên tắc vàng: Hãy viết thật nhiều Unit Test, một số lượng vừa phải Integration Test và chỉ một vài E2E Test quan trọng nhất.

Các công cụ tốt nhất trong làng Testing React

Hệ sinh thái React cung cấp rất nhiều công cụ mạnh mẽ để hỗ trợ việc testing. Dưới đây là những cái tên nổi bật nhất bạn cần biết:

1. Jest: Khung xương sống của Testing

Jest là một framework testing JavaScript được phát triển bởi Facebook. Nó cực kỳ phổ biến và thường được tích hợp sẵn khi bạn tạo dự án React bằng Create React App.

Jest

  • Tất cả trong một: Jest cung cấp mọi thứ bạn cần: một test runner (trình chạy test), thư viện assertion (để kiểm tra kết quả), và khả năng tạo "mock" (giả lập) dữ liệu/hàm.
  • Snapshot Testing: Một tính năng độc đáo cho phép bạn "chụp ảnh" cấu trúc của một component. Ở những lần chạy test sau, Jest sẽ so sánh snapshot mới với cái đã lưu để đảm bảo UI không bị thay đổi ngoài ý muốn.
  • Tốc độ: Jest tối ưu hóa việc chạy test song song, mang lại tốc độ phản hồi nhanh chóng.

2. React Testing Library: Triết lý Testing đúng đắn

Được xây dựng dựa trên Jest, React Testing Library không tập trung vào cách triển khai (implementation details) của component, mà tập trung vào cách người dùng tương tác với nó.

React Testing Library

  • Triết lý: "Test phần mềm của bạn theo cách người dùng sử dụng nó." Thay vì kiểm tra state hay props của component, bạn sẽ tìm các phần tử trên trang (như người dùng), tương tác với chúng (click, gõ chữ) và kiểm tra kết quả hiển thị trên màn hình.
  • Tự tin hơn: Cách tiếp cận này giúp các bài test của bạn ít bị "giòn" hơn. Bạn có thể refactor code bên trong component thoải mái, miễn là hành vi của nó đối với người dùng cuối không thay đổi, test sẽ vẫn pass.

3. Cypress: "Bá chủ" của Test End-to-End

Khi nói đến E2E testing, Cypress là một trong những lựa chọn hàng đầu. Nó chạy trực tiếp trong trình duyệt, cho phép bạn thấy chính xác những gì test đang làm một cách trực quan.

Cypress

  • Trải nghiệm tuyệt vời: Giao diện của Cypress rất thân thiện, có khả năng "du hành thời gian" (time travel) để debug từng bước trong kịch bản test.
  • Đáng tin cậy: Cypress tự động đợi các element xuất hiện, giúp loại bỏ các lỗi test không ổn định (flaky tests) do vấn đề về thời gian.

Bắt tay vào viết Test: Từ lý thuyết đến thực hành

Hãy cùng xem qua các ví dụ cụ thể cho từng loại test.

Ví dụ 1: Unit Test với Jest và React Testing Library

Giả sử chúng ta có một component Counter đơn giản:

// Counter.js
import React, { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <h1>Bộ đếm</h1>
      <p data-testid="count-display">{count}</p>
      <button onClick={() => setCount(count + 1)}>Tăng</button>
      <button onClick={() => setCount(count - 1)}>Giảm</button>
    </div>
  )
}

export default Counter

Bây giờ, hãy viết test cho nó:

// Counter.test.js
import { render, screen, fireEvent } from '@testing-library/react'
import Counter from './Counter'

// Test case 1: Component nên được render với giá trị ban đầu là 0
test('renders with initial count of 0', () => {
  render(<Counter />)
  // screen.getByTestId tìm element có thuộc tính data-testid="count-display"
  const countDisplay = screen.getByTestId('count-display')
  // expect(...).toHaveTextContent(...) là một assertion của Jest
  expect(countDisplay).toHaveTextContent('0')
})

// Test case 2: Khi nhấn nút "Tăng", giá trị nên là 1
test('increments count when increment button is clicked', () => {
  render(<Counter />)
  // Tìm nút "Tăng" dựa trên nội dung text của nó
  const incrementButton = screen.getByRole('button', { name: /tăng/i })

  // fireEvent mô phỏng hành động click của người dùng
  fireEvent.click(incrementButton)

  const countDisplay = screen.getByTestId('count-display')
  expect(countDisplay).toHaveTextContent('1')
})

// Test case 3: Khi nhấn nút "Giảm", giá trị nên là -1
test('decrements count when decrement button is clicked', () => {
  render(<Counter />)
  const decrementButton = screen.getByRole('button', { name: /giảm/i })

  fireEvent.click(decrementButton)

  const countDisplay = screen.getByTestId('count-display')
  expect(countDisplay).toHaveTextContent('-1')
})

Trong ví dụ này, chúng ta đã:

  1. Render component bằng render của React Testing Library.
  2. Tìm các element trên màn hình bằng các truy vấn của screen (ví dụ: getByTestId, getByRole).
  3. Mô phỏng hành động của người dùng bằng fireEvent.
  4. Kiểm tra kết quả hiển thị bằng expect của Jest.

Ví dụ 2: Test một hàm gọi API (Integration Test)

Thường thì component của bạn sẽ gọi API để lấy dữ liệu. Chúng ta cần "mock" (giả lập) hàm gọi API để test không phụ thuộc vào server thật.

// UserProfile.js
import React, { useState, useEffect } from 'react'
import axios from 'axios'

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    axios.get(`https://api.example.com/users/${userId}`).then((response) => {
      setUser(response.data)
      setLoading(false)
    })
  }, [userId])

  if (loading) {
    return <div>Đang tải...</div>
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

Và đây là bài test:

// UserProfile.test.js
import { render, screen, waitFor } from '@testing-library/react'
import axios from 'axios'
import UserProfile from './UserProfile'

// Giả lập module 'axios'
jest.mock('axios')

test('fetches and displays user data', async () => {
  const mockUser = { name: 'John Doe', email: 'john.doe@example.com' }

  // Thiết lập để khi axios.get được gọi, nó sẽ trả về dữ liệu giả lập
  axios.get.mockResolvedValue({ data: mockUser })

  render(<UserProfile userId="1" />)

  // Ban đầu, màn hình hiển thị "Đang tải..."
  expect(screen.getByText(/đang tải.../i)).toBeInTheDocument()

  // Đợi cho đến khi dữ liệu được hiển thị
  // findByText sẽ đợi element xuất hiện
  const userName = await screen.findByText('John Doe')
  const userEmail = await screen.findByText('john.doe@example.com')

  expect(userName).toBeInTheDocument()
  expect(userEmail).toBeInTheDocument()

  // Đảm bảo "Đang tải..." đã biến mất
  expect(screen.queryByText(/đang tải.../i)).not.toBeInTheDocument()
})

Ví dụ 3: Testing Custom Hook với React Testing Library

Custom Hook là một cách tuyệt vời để tái sử dụng logic trong React. Testing chúng cũng rất quan trọng. Giả sử chúng ta có một hook useToggle để quản lý trạng thái boolean.

// hooks/useToggle.js
import { useState, useCallback } from 'react'

export const useToggle = (initialState = false) => {
  const [state, setState] = useState(initialState)

  const toggle = useCallback(() => setState((prevState) => !prevState), [])

  return [state, toggle]
}

Để test hook này, chúng ta không render một component UI, mà render chính hook đó bằng một hàm đặc biệt từ React Testing Library là renderHook.

// hooks/useToggle.test.js
import { renderHook, act } from '@testing-library/react'
import { useToggle } from './useToggle'

test('should use initial state', () => {
  // renderHook trả về một đối tượng, trong đó `result.current`
  // chứa giá trị mà hook trả về ([state, toggle])
  const { result } = renderHook(() => useToggle(true))

  // Kiểm tra trạng thái ban đầu
  expect(result.current[0]).toBe(true)
})

test('should toggle the state', () => {
  const { result } = renderHook(() => useToggle(false))

  // Lấy ra hàm toggle từ kết quả của hook
  const toggleFunction = result.current[1]

  // `act` đảm bảo rằng tất cả các cập nhật state
  // được xử lý trước khi chúng ta thực hiện assertion
  act(() => {
    toggleFunction()
  })

  // Kiểm tra trạng thái sau khi gọi toggle
  expect(result.current[0]).toBe(true)

  act(() => {
    toggleFunction()
  })

  // Kiểm tra trạng thái sau khi gọi toggle lần nữa
  expect(result.current[0]).toBe(false)
})

Ví dụ 4: Testing tương tác phức tạp (Form Validation)

Hãy xem xét một form đăng nhập đơn giản với validation.

// LoginForm.js
import React, { useState } from 'react'

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = (e) => {
    e.preventDefault()
    if (!email || !password) {
      setError('Email và mật khẩu không được để trống.')
      return
    }
    onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>
      <div>
        <label htmlFor="password">Mật khẩu</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">Đăng nhập</button>
    </form>
  )
}

Bài test sẽ mô phỏng việc người dùng điền vào form và submit.

// LoginForm.test.js
import { render, screen, fireEvent } from '@testing-library/react'
import LoginForm from './LoginForm'

test('shows an error message if fields are empty on submit', () => {
  render(<LoginForm />)

  const submitButton = screen.getByRole('button', { name: /đăng nhập/i })
  fireEvent.click(submitButton)

  // Tìm thông báo lỗi
  const errorMessage = screen.getByText(
    /email và mật khẩu không được để trống/i,
  )
  expect(errorMessage).toBeInTheDocument()
})

test('does not show error and calls onSubmit when form is filled correctly', () => {
  // jest.fn() tạo ra một hàm "gián điệp" để chúng ta có thể
  // kiểm tra xem nó có được gọi hay không
  const mockOnSubmit = jest.fn()
  render(<LoginForm onSubmit={mockOnSubmit} />)

  // Sử dụng getByLabelText để tìm input liên kết với label
  const emailInput = screen.getByLabelText(/email/i)
  const passwordInput = screen.getByLabelText(/mật khẩu/i)
  const submitButton = screen.getByRole('button', { name: /đăng nhập/i })

  // Mô phỏng người dùng gõ vào input
  fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
  fireEvent.change(passwordInput, { target: { value: 'password123' } })

  fireEvent.click(submitButton)

  // Kiểm tra rằng thông báo lỗi KHÔNG xuất hiện
  const errorMessage = screen.queryByText(
    /email và mật khẩu không được để trống/i,
  )
  expect(errorMessage).not.toBeInTheDocument()

  // Kiểm tra rằng hàm onSubmit đã được gọi với đúng dữ liệu
  expect(mockOnSubmit).toHaveBeenCalledTimes(1)
  expect(mockOnSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  })
})

Ví dụ 5: Chinh phục End-to-End Test với Cypress

Giờ là phần được mong chờ nhất! E2E test với Cypress sẽ kiểm tra toàn bộ luồng hoạt động của ứng dụng từ góc nhìn của người dùng. Cú pháp của Cypress rất dễ đọc và gần với ngôn ngữ tự nhiên.

Giả sử chúng ta có một ứng dụng TODO list đơn giản. Người dùng có thể:

  1. Truy cập trang.
  2. Thêm một công việc mới.
  3. Đánh dấu một công việc là đã hoàn thành.
  4. Lọc để chỉ xem các công việc chưa hoàn thành.

Cấu trúc file của Cypress: Các file test của Cypress thường nằm trong thư mục cypress/e2e.

// cypress/e2e/todo_app.cy.js

// `describe` dùng để nhóm các test case liên quan
describe('Todo Application', () => {
  // `beforeEach` sẽ chạy trước mỗi test case (`it`) trong khối describe
  // Rất hữu ích để thực hiện các hành động lặp lại như truy cập trang
  beforeEach(() => {
    // `cy.visit` để điều hướng đến một URL
    cy.visit('http://localhost:3000') // Thay bằng URL ứng dụng của bạn
  })

  // `it` định nghĩa một test case cụ thể
  it('should allow a user to add a new todo item', () => {
    const newItem = 'Học Cypress'

    // `cy.get` tìm kiếm một element bằng CSS selector.
    // `.type()` mô phỏng hành động gõ phím.
    cy.get('[data-testid="todo-input"]').type(newItem)

    // `cy.contains` tìm một element chứa text cụ thể.
    // `.click()` mô phỏng hành động click chuột.
    cy.contains('Thêm').click()

    // Assertion: Kiểm tra xem công việc mới đã xuất hiện trong danh sách chưa.
    // `should('contain', ...)` để kiểm tra element có chứa text mong muốn.
    // `should('have.length', 1)` để kiểm tra có đúng 1 element được tìm thấy.
    cy.get('[data-testid="todo-list"] li')
      .should('have.length', 1)
      .and('contain', newItem)
  })

  it('should allow a user to mark a todo as completed', () => {
    const newItem = 'Viết E2E test'
    cy.get('[data-testid="todo-input"]').type(newItem)
    cy.contains('Thêm').click()

    // Tìm công việc vừa thêm
    cy.contains(newItem)
      // Tìm checkbox bên trong nó
      .parent()
      .find('input[type="checkbox"]')
      // Đánh dấu vào checkbox
      .check()

    // Assertion: Kiểm tra xem công việc đó có class 'completed' hay không
    // (Giả sử bạn dùng CSS để gạch ngang text)
    cy.contains(newItem).parents('li').should('have.class', 'completed')
  })

  it('should filter todos to show only active ones', () => {
    // Thêm 2 công việc
    cy.get('[data-testid="todo-input"]').type('Công việc 1')
    cy.contains('Thêm').click()
    cy.get('[data-testid="todo-input"]').type('Công việc 2')
    cy.contains('Thêm').click()

    // Hoàn thành công việc 1
    cy.contains('Công việc 1').parent().find('input[type="checkbox"]').check()

    // Click vào bộ lọc "Chưa hoàn thành"
    cy.contains('Chưa hoàn thành').click()

    // Assertion:
    // - Danh sách chỉ nên có 1 công việc.
    // - Công việc 'Công việc 2' nên hiển thị.
    // - Công việc 'Công việc 1' không nên tồn tại trong DOM.
    cy.get('[data-testid="todo-list"] li').should('have.length', 1)
    cy.contains('Công việc 2').should('be.visible')
    cy.contains('Công việc 1').should('not.exist')
  })
})

Cách chạy test Cypress:

  1. Mở terminal trong thư mục dự án.
  2. Chạy lệnh: npx cypress open
  3. Giao diện Cypress Test Runner sẽ hiện lên, bạn chỉ cần chọn file test để chạy và xem nó thực thi trực tiếp trên trình duyệt!

Kết luận: Testing trong React không phải là việc làm thêm

Testing trong React không phải là một công việc làm thêm, mà là một phần không thể tách rời của quy trình phát triển chuyên nghiệp. Bằng cách áp dụng kim tự tháp testing và sử dụng các công cụ mạnh mẽ như Jest, React Testing LibraryCypress, bạn có thể xây dựng những ứng dụng không chỉ giàu tính năng mà còn cực kỳ ổn định và đáng tin cậy.

Hãy bắt đầu từ những Unit Test nhỏ nhất cho các component của bạn, dần dần tiến tới Integration Test và cuối cùng là các kịch bản E2E quan trọng. Việc đầu tư thời gian vào testing hôm nay chính là bạn đang tiết kiệm thời gian và công sức sửa lỗi cho ngày mai.

Chúc bạn may mắn trên hành trình chinh phục những ứng dụng React tuyệt vời nhất!

Bài viết liên quan

[React Basics] Thuộc tính key trong React: Hiểu rõ và sử dụng hiệu quả

Bạn đã thực sự hiểu về thuộc tính key trong React? Tìm hiểu vai trò, cách dùng hiệu quả và các ví dụ thực tế giúp code của bạn sạch và tối ưu hơn.

[React Basics] Dynamic Routes trong React: Bí quyết tạo nên ứng dụng linh hoạt

Bạn muốn tạo các trang web với URL linh hoạt? Bài viết này sẽ hướng dẫn bạn cách xây dựng Dynamic Routes trong React Router một cách chi tiết và dễ hiểu.

[React Basics] Tối ưu Fetch API trong React: Xử lý dữ liệu bất đồng bộ

Tìm hiểu các phương pháp hiệu quả để xử lý dữ liệu bất đồng bộ với Fetch API trong React. Nâng cao kỹ năng quản lý state, xử lý lỗi và tối ưu hiệu suất ứng dụng của bạn.

[React Basics] Cách deploy ứng dụng React dễ dàng trong 5 phút

Bạn đang gặp khó khăn khi deploy ứng dụng React? Xem ngay hướng dẫn chi tiết từng bước để deploy app React một cách dễ dàng và nhanh chóng.