01204223/unit testing

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
หน้านี้เป็นส่วนหนึ่งของ 01204223

เราจะหัดใช้ Unit testing ในการควบคุมคุณภาพโปรแกรมที่เราเขียน ปกติถ้าเมื่อเราเขียนโปรแกรม เราจะทดสอบว่าโปรแกรมทำงานถูกต้องโดยทดลองรันและตรวจสอบผล แต่เมื่อโปรแกรมใหญ่ขึ้นเรื่อย ๆ เราจะไม่สามารถทดสอบทุก ๆ ส่วนที่เราเขียนมาได้ การพัฒนาระบบตรวจสอบอัตโนมัติเป็นแนวปฏิบัติพื้นฐานในการรักษาคุณภาพซอฟต์แวร์ รวมถึงอาจจะใช้ในการขับเคลื่อนการพัฒนาด้วยก็ได้

การทดสอบระบบมีได้หลายระดับ Unit testing เป็นการทดสอบซอฟต์แวร์ในหน่วยย่อย (unit) เช่น หนึ่งฟังก์ชัน หรือหนึ่ง component (ใน react)     ส่วน Integration testing หรือ End-to-end testing เป็นการทดสอบการทำงานของส่วนย่อย ๆ ที่เชื่อมต่อกัน    ในที่นี้เราจะเน้นแค่ unit testing ก่อน เพราะว่าเป็นจุดเริ่มต้นที่ดีรวมทั้งมักจะทำได้ง่ายกว่า

ในการพัฒนาเว็บแอพลิเคชัน แต่ละ unit ที่เราต้องการทดสอบมักทำงานร่วมกับส่วนอื่นๆ เช่น ถ้าพิจารณาส่วนหลังบ้าน โค้ดโดยมากก็จะทำงานกับฐานข้อมูล (database) ในขณะที่โค้ดหน้าบ้านก็จะต้องมีการเรียก API ดังนั้นการจะทดสอบแต่ละ unit จะต้องมีการสร้างหรือจำลองส่วนอื่น ๆ ของระบบเพื่อทำให้โค้ดที่เราต้องการทดสอบสามารถทำงานได้ และมีพฤติกรรมที่คงเส้นคงวาทำให้เราสามารถตรวจสอบความถูกต้องได้    เราจะได้ลองใช้เทคนิคที่เรียกว่า mock ในการจำลองการทำงานของส่วนอื่น ๆ ระหว่างที่โค้ดที่เราเทสทำงาน

ในส่วนที่ผ่านมา เวลาเราเขียน เราจะต้องเปิด flask server ไว้ และรัน npm run dev เพื่อทดสอบโค้ดที่เราทำ ในส่วนนี้ เราจะทำ unit test ที่จะทำให้เราสามารถทดสอบโค้ดได้โดยไม่ต้องเรียกผ่านทาง browser ดังนั้นก่อนจะทำ ถ้าเปิด flask server เอาไว้จากงานที่ผ่านมา สามารถปิดไปก่อนได้    อย่างไรก็ตาม ต่อให้ทดสอบมากแค่ไหน อย่างน้อยก่อนใช้จริง ก็อาจจะต้องนำมาเทสระบบภาพรวมทั้งหมดก่อนด้วย เพื่อความมั่นใจ

ทดสอบ React component

สำหรับส่วนหน้าบ้าน เราจะใช้ Vitest เป็นเครื่องมือในการรันเทสสำหรับ React component เราจะติดตั้ง vitest รวมทั้งไลบรารีอื่น ๆ โดยสั่ง

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

ในไดเร็กทอรี frontend

ไลบรารีที่เราติดตั้งมีหน้าที่ดังนี้

  • vitest เป็นระบบเทส
  • jsdom ใช้สำหรับจำลองหน้าจอ (dom) โดยไม่มี browser
  • @testing-library/xxx เป็นเครื่องมือต่าง ๆ สำหรับทดสอบ react

เมื่อเราสั่ง npm install แล้ว จะมีการเพิ่มรายการ package ต่าง ๆ รวมทั้งข้อมูลอื่น ในไฟล์ package.json และ package-lock.json ซึ่งทั้งสองไฟล์นี้จะเป็นไฟล์ที่จำเป็นในการนำโค้ดเราไปทำงานที่อื่น (นอกเหนือจากเครื่องเราเอง) เช่น รันใน container ตอนที่รัน github action

เมื่อติดตั้งเสร็จ ให้ไปแก้ไฟล์ package.json เพื่อระบุว่าเมื่อสั่ง test ให้เรียก vitest โดยดูในส่วน "scripts" และเพิ่มบรรทัดดังด้านล่างเข้าไป

// ในไฟล์ frontend/package.json

  "scripts": {
    // ละไว้
    // ** ระหว่างที่เพิ่ม อย่าลืมเติม , ท้ายบรรทัดก่อนหน้าด้วย
    "test": "vitest run"
  },

เมื่อแก้เสร็จ เราจะสามารถสั่งรันเทสได้โดยสั่ง

npm test

แต่เนื่องจากตอนนี้เราไม่มีเทสเคสเลย vitest จะบอกว่าไม่มี test files และจบการทำงาน (หมายเหตุ: เราสามารถสั่งให้ vitest ดูการแก้ไขไฟล์แล้วรันเทสใหม่ได้ แต่ในที่นี้เราเลือกจะให้รันเทสตอนที่เราสั่งก่อนเท่านั้น)

เราจะต้องไปเพิ่ม config ของ test ในไฟล์ vite.config.js ดังนี้

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  // ** เพิ่มส่วนด้านล่างนี้ **
  test: {
    globals: true,                          // ทำให้เรียกฟังก์ชันเกี่ยวกับการเทสได้โดยไม่ต้องประกาศ
    environment: 'jsdom',                   // รันเทสแบบไม่มี browser
    setupFiles: './src/setupTests.js',      // ระบุโค้ดสำหรับเตรียมต่าง ๆ
  },
})

จากนั้นให้เพิ่มไฟล์ src/setupTests.js ดังนี้

import '@testing-library/jest-dom/vitest'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'

afterEach(() => {
  cleanup()
})

โค้ดดังกล่าวจะทำให้มีการเรียกฟังก์ชัน cleanup เมื่อสิ้นสุดการรันในแต่ละเทสเคส

Test แรก

เพื่อความง่ายเราจะเริ่มจากการทดสอบ component TodoItem ก่อน เพราะว่าเป็น component ที่ไม่ได้มีการเรียก API

เราจะเก็บเทสทั้งหมดไว้ในไดเร็กทอรี frontent/src/__tests__/ ดังนั้นให้สร้างไฟล์ frontent/src/__tests__/TodoItem.test.jsx ดังนี้

import { render, screen } from '@testing-library/react'
import { expect } from 'vitest'
import TodoItem from '../TodoItem.jsx'

describe('TodoItem', () => {
  it('renders with no comments correctly', () => {
    // เดี๋ยวจะเพิ่มโค้ดตรงนี้
  });
});

โค้ดสำหรับเทสด้านบนยังไม่มีอะไร แต่ให้ลองเรียก

npm test

มาเพื่อดูผลลัพธ์ก่อน จะเห็นผลลัพธ์ดังนี้

> first-react-app@0.0.0 test
> vitest run


 RUN  v4.0.18 /home/jittat/prog/test/flask-react-todo-start/frontend

 ✓ src/__tests__/TodoItem.test.jsx (1 test) 4ms
   ✓ TodoItem (1)
     ✓ renders with no comments correctly 1ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  03:28:38
   Duration  1.15s (transform 63ms, setup 167ms, import 51ms, tests 4ms, environment 682ms)

สังเกตข้อความที่พิมพ์ออกมาในส่วน TodoItem จะมีลักษณะคล้ายกับ specification (TodoItem renders with no comments correctly) ในการเขียนเทส เราจะพยายามใส่คำอธิบายแต่ละเทสในลักษณะนี้ เพื่อให้เราสามารถอ่านผลลัพธ์ได้เข้าใจง่าย

ในการทดสอบ render TodoItem เราจะต้องมี object todo ไว้เพื่อทดสอบ เราจะแก้ไฟล์ TodoItem.test.jsx ให้เป็นดังนี้

// ** ละส่วน import
const baseTodo = {             // ** TodoItem พื้นฐานสำหรับทดสอบ
  id: 1,
  title: 'Sample Todo',
  done: false,
  comments: [],
};

describe('TodoItem', () => {
  it('renders with no comments correctly', () => {    
    // *** โค้ดสำหรับเทสที่เพิ่มเข้ามา
    render(
      <TodoItem todo={baseTodo} />
    );
    expect(screen.getByText('Sample Todo')).toBeInTheDocument();
  });
});

สังเกตโครงสร้างของการเขียนเทสด้านบน ดังนี้

  • เราจะเรียก render
  • เราใช้ฟังก์ชัน expect เพื่อตรวจสอบผลลัพธ์ สังเกตรูปแบบของชื่อฟังก์ชันต่าง ๆ ที่ออกแบบมาให้อ่านได้รู้เรื่อง เช่น toBeInTheDocument     ส่วนที่เราเขียน expect นี้ จะเรียกว่า assertion ซึ่งเป็นการระบุผลลัพธ์ที่ต้องการ

โครงสร้างของการเขียนเทสแทบทั้งหมดจะมีลักษณะคล้ายกัน โดยแบ่งเป็นสามส่วนคือ (1) เตรียมการ (2) เรียกใช้สิ่งที่ต้องการเทส (3) ตรวจสอบผล

ให้ลองเรียก npm test และดูผลลัพธ์

เราจะลองแก้เทสให้ล้มเหลวเพื่อทดลองดูผลลัพธ์ ให้แก้ข้อความที่ตรวจว่ามีหรือไม่ในบรรทัด expect ให้เป็น 'Sample TodoX' แล้วทดลองรัน npm test อีกครั้ง ให้สังเกต error ที่รายงาน (จะยาวสักหน่อย)

เมื่อทดลองรันแล้ว ให้แก้เทสกลับให้เป็นแบบเดิม เพื่อให้เทสรันผ่านได้ (อย่าลืมสั่ง npm test อีกครั้ง)

🄶 ถ้าทุกอย่างใช้งานได้ อย่าลืม commit งานที่ทำด้วย

ในขั้นตอนนี้มีไฟล์ใหม่ ๆ เพิ่มมาหลายไฟล์ อย่าลืม git add ก่อนที่จะ commit

นอกจากนี้ในขั้นตอนถัด ๆ ไปเราจะใช้ github action ดังนั้นจำเป็นที่จะต้อง commit งานลงใน repository ด้วย

TodoItem แบบที่มี comments

เราจะเพิ่ม test case ดังด้านล่าง (เติมโค้ดลงไปด้านในส่วนของฟังก์ชันที่ส่งให้ describe     ให้เขียน assertion ว่ามีข้อความจาก comments เอง

  it('renders with comments correctly', () => {
    const todoWithComment = {
      ...baseTodo,
      comments: [
        {id: 1, message: 'First comment'},
        {id: 2, message: 'Another comment'},
      ]
    };
    render(
      <TodoItem todo={todoWithComment} />
    );
    expect(screen.getByText('Sample Todo')).toBeInTheDocument();
    //
    // *** TODO: ให้เพิ่ม assertion ว่ามีข้อความ First comment และ Another comment บนหน้าจอ
    //
  });

ทดลองรันด้วย npm test ให้ผ่านก่อนจะทำต่อไป

🄶 อย่าลืม commit งานที่ทำด้วย

ขับเคลื่อนด้วย test

แนวทางการพัฒนาซอฟต์แวร์แบบหนึ่งที่เป็นที่นิยมคือ Test-Driven Developmen หรือ TDD     ภายใต้แนวคิดนี้ เราจะเขียนเทสก่อนเขียนโค้ด และให้เทสเป็นเครื่องมือในการขับเคลื่อนการพัฒนาซอฟต์แวร์ ในส่วนนี้เราจะทดลองกระบวนการดังกล่าวสักเล็กน้อย

ขั้นที่ 1

เราจะปรับให้ TodoItem แสดงจำนวน comment และแสดงข้อความว่า No comments ถ้าไม่มี comment เลย    แต่ก่อนที่เราจะทำดังกล่าว เราจะแก้ test case ก่อน จากนั้นค่อยไปแก้โค้ดให้ทำงานได้ตามที่เทสระบุ

ให้เพิ่มบรรทัด expect ด้านล่าง ลงในเทสเคส 'renders with no comments correctly' (เคสแรก)

  it('renders with no comments correctly', () => {
    // ... ละตอนต้นไว้
    expect(screen.getByText('No comments')).toBeInTheDocument();
  });

จากนั้นให้แก้ component TodoItem ให้แสดงข้อความ No comments ถ้ารายการ todo.comments นั้นมีความยาวเป็น 0 (todo.comments.length == 0)    เมื่อแก้แล้วให้ทดลองรัน npm test ถ้าไม่ผ่านให้แก้จนกว่าจะผ่าน

สังเกตว่าเราจะไม่ได้ไปทดลองรันใน browser เลย และเราสามารถทดสอบโดยยังไม่ได้เรียก flask run ด้วย

ขั้นที่ 2

ข้อความ No comments ไม่ควรถูกแสดง ถ้าเรามี comments ดังนั้นในเทสเคสที่สอง เราจะตรวจว่าไม่มีข้อความดังกล่าว    ให้เพิ่ม test case ด้านล่างลงไป (ควรใส่ไว้หลังเทสเคสแรก เพราะว่าอ่านรายการ test จะอ่านได้เป็นกลุ่มมากกว่า)

  it('does not show no comments message when it has a comment', () => {
    const todoWithComment = {
      ...baseTodo,
      comments: [
        {id: 1, message: 'First comment'},
      ]
    };
    render(
      <TodoItem todo={todoWithComment} />
    );
    expect(screen.queryByText('No comments')).not.toBeInTheDocument();
  });

ให้สังเกตวิธี assert ว่าไม่มีข้อความ จะเห็นว่าเราเรียกฟังก์ชัน .not คั่นไว้ก่อนจะเรียก toBeInTheDocument

ถ้าโค้ดในส่วนที่แล้วเขียนถูกต้อง เมื่อสั่ง npm test น่าจะไม่พบ error ใดๆ

ขั้นที่ 3

ในกรณีที่มี comment เราอยากเห็นจำนวน comment แสดงด้วย ให้เพิ่ม assertion ด้านล่างลงใน test case 'renders with comments correctly'

   expect(screen.getByText(/2/)).toBeInTheDocument();

สังเกตว่าเราใส่ /2/ ซึ่งเป็น regular expression แทนที่จะใส่ string ลงไปตรง ๆ เพื่อให้การ match ทำกับ substring ด้วย (ถ้าใช้ string '2' การ match จะทำกับทั้งสตริง อาจจะหาไม่เจอ)

ให้ทดลองเรียก npm test เพื่อดูว่าเทสเคสจะ fail (ขั้นตอนนี้ใช้เพื่อทดสอบว่าเราเขียนเทสที่มีผล) จากนั้นให้แก้ component TodoItem จนกระทั่งเทสผ่าน

ประโยชน์ของการแยก component. สังเกตว่า ข้อดีอีกประการจากการแยก component TodoItem คือความสะดวกในการทำ unit testing     ถ้าทุกอย่างยังรวมอยู่ใน App component เราจะไม่สามารถ render TodoItem แยกออกมาเพื่อทดสอบได้ และในขั้นตอนถัดไปเราจะเห็นได้ว่าการทดสอบ App นั้นซับซ้อนกว่ามาก เพราะว่ามีการเรียก api

🄶 ถ้าโค้ดทำงานได้ อย่าลืม commit งานที่ทำด้วย

การ mock api request

เราจะทดสอบ component App อย่างไรก็ตามความซับซ้อนอยู่ที่ App มีการเรียก api ไปยัง backend     ที่ผ่านมา เราจะทดสอบการทำงานหน้าบ้านได้ เราต้องคอยเรียก flask server ด้วยเสมอ นอกจากนี้ สถานะของฐานข้อมูลในส่วนหลังบ้าน ทำให้หน้าจอที่แสดงออกมานั้นเปลี่ยนไปมา ถ้าจะทดสอบให้ละเอียดเราต้องคิดว่าหน้าจอที่แสดงมานั้นถูกต้องหรือไม่เสมอ ๆ ซึ่งทำให้การทดสอบนั้นไม่สะดวก รวมทั้งอาจจะก่อให้เกิดความผิดพลาดในการทดสอบได้ง่าย

ในขั้นตอนนี้ เราจะเรียนรู้เครื่องมือใหม่ในการทดสอบคือ mock object ซึ่งจะทำให้เราสามารถจำลองส่วนของซอฟต์แวร์ส่วนอื่น (ที่ไม่ต้องมีอยู่จริง) เพื่อใช้ประกอบการทดสอบ unit ของเราได้

import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import App from '../App.jsx'

const mockResponse = (body, ok = true) =>
  Promise.resolve({
    ok,
    json: () => Promise.resolve(body),
});

describe('App', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn());
  });

  afterEach(() => {
    vi.resetAllMocks();
    vi.unstubAllGlobals();
  });

  it('renders correctly', async() => {
    global.fetch.mockImplementationOnce(() =>
      mockResponse([
        { id: 1, title: 'First todo', done: false, comments: [] },
        { id: 2, title: 'Second todo', done: false, comments: [
          { id: 1, message: 'First comment' },
          { id: 2, message: 'Second comment' },
        ] },
      ]),
    );

    render(<App />);

    expect(await screen.findByText('First todo')).toBeInTheDocument();
    expect(await screen.findByText('Second todo')).toBeInTheDocument();
    expect(await screen.findByText('First comment')).toBeInTheDocument();
    expect(await screen.findByText('Second comment')).toBeInTheDocument();
  });
});

🄶 ถ้าโค้ดทำงานได้ อย่าลืม commit งานที่ทำด้วย อย่าลืม add ไฟล์ App.test.jsx ด้วย

Github action

เมื่อเราทำ unit test เรียบร้อยแล้ว เราสามารถใช้งาน github action ให้มีการรัน unit test เหล่านั้นโดยอัตโนมัติบน server ของ github เมื่อมีเหตุการณ์บางอย่างเกิดขึ้น เช่นมีการ push หรือทำ pull request หรืออาจจะมีการไปกดปุ่มให้ทำงานก็ได้

ในส่วนนี้เราจะทดลองใช้งาน github action เพื่อรันเทสเหล่านี้    ในการจะใช้ github action เราจะต้องสร้าง workflow ขึ้นมาก่อน workflow เหล่านี้จะอยู่ในไดเร็กทอรี .github/workflows

ให้สร้างไดเร็กทอรี .github/workflows (นับจากรากของ git repo เลย ไม่ใช่ใน frontend) จากนั้นสร้างไฟล์ frontend-tests.yml ดังด้านล่างในไดเร็กทอรีดังกล่าว

name: Frontend Tests

on:
  push:
    paths:
      - 'frontend/**'
  pull_request:
    paths:
      - 'frontend/**'
  workflow_dispatch:

jobs:
  test-frontend:
    runs-on: ubuntu-latest

    defaults:
      run:
        working-directory: frontend

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: 'frontend/package-lock.json'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

ไฟล์ frontend-tests.yml นี้เป็น GitHub Actions workflow สำหรับ “รันทดสอบฝั่ง Frontend” อัตโนมัติ (และรันแบบ manual ได้ด้วย) โดยมีความหมายของแต่ละส่วนดังนี้

ภาพรวม

Workflow นี้ใช้สำหรับรันชุดทดสอบของฝั่ง Frontend โดยจะทำงานอัตโนมัติเมื่อมีการเปลี่ยนแปลงภายใต้โฟลเดอร์ frontend/ และสามารถสั่งรันด้วยมือได้ด้วย

  • ชื่อ workflow: Frontend Tests
  • รันเมื่อ:
    • มีการ push ที่กระทบไฟล์ใน frontend/**
    • มีการ pull_request ที่กระทบไฟล์ใน frontend/**
    • สั่งรันเองแบบ manual ผ่าน workflow_dispatch
  • ทำอะไร: Checkout โค้ด → ติดตั้ง Node.js → ติดตั้ง dependencies → รันเทสต์ (npm test)

การ Trigger (on)

on:
  push:
    paths:
      - 'frontend/**'
  pull_request:
    paths:
      - 'frontend/**'
  workflow_dispatch:
push
รันเมื่อมีการ push โค้ดขึ้น repo แต่จะรันเฉพาะกรณีที่มีไฟล์เปลี่ยนใน path frontend/**
pull_request
รันเมื่อมีการเปิด/อัปเดต Pull Request และมีไฟล์เปลี่ยนใน frontend/**
workflow_dispatch
เปิดให้รัน workflow ได้เองจากหน้า GitHub (Actions → เลือก workflow → Run workflow) โดยไม่ต้องมี push/PR ใหม่

Jobs มีงานเดียวคือ test-frontend

jobs:
  test-frontend:
    runs-on: ubuntu-latest
  • test-frontend คือชื่อ job หลักที่ใช้รันทดสอบ
  • runs-on: ubuntu-latest เลือกเครื่อง runner ของ GitHub ที่เป็น Ubuntu เวอร์ชันล่าสุด

ค่าเริ่มต้นของคำสั่ง (defaults)

defaults:
  run:
    working-directory: frontend

ตั้งค่าให้ทุก step ที่ใช้ run: ไปทำงานในโฟลเดอร์ frontend โดยอัตโนมัติ (ไม่ต้อง cd frontend ทุกครั้ง)

Steps (ลำดับการทำงาน)

  • Checkout repository
- name: Checkout repository
  uses: actions/checkout@v4

ดึงซอร์สโค้ดจาก repo มาไว้บน runner เพื่อให้ขั้นตอนถัดไปเข้าถึงไฟล์ในโปรเจกต์ได้

  • Setup Node.js
- name: Use Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'
    cache-dependency-path: 'frontend/package-lock.json'
    • ติดตั้ง/ตั้งค่า Node.js เวอร์ชัน 20
    • เปิดใช้ cache สำหรับ npm เพื่อให้การติดตั้งเร็วขึ้นในการรันครั้งถัดไป
    • ใช้ frontend/package-lock.json เป็นตัวอ้างอิงในการจัดการ cache (ถ้า lockfile ไม่เปลี่ยน มักจะ reuse cache ได้)
  • Install dependencies
- name: Install dependencies
  run: npm ci

ติดตั้ง dependencies แบบ CI โดยยึดตาม package-lock.json อย่างเคร่งครัด (เหมาะกับงานบน CI เพราะ reproducible)

  • Run tests
- name: Run tests
  run: npm test

รันชุดทดสอบของ frontend ตามที่สคริปต์ test ถูกกำหนดไว้ใน frontend/package.json

หมายเหตุ

  • Workflow นี้จะไม่รัน หากมีการเปลี่ยนแปลงเฉพาะไฟล์นอก frontend/**
  • หากต้องการทดสอบทันทีโดยไม่ push/PR สามารถใช้ manual run ได้ด้วย workflow_dispatch