ผลต่างระหว่างรุ่นของ "01204223/unit testing"
Jittat (คุย | มีส่วนร่วม) |
Jittat (คุย | มีส่วนร่วม) |
||
| (ไม่แสดง 30 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน) | |||
| แถว 7: | แถว 7: | ||
ในการพัฒนาเว็บแอพลิเคชัน แต่ละ unit ที่เราต้องการทดสอบมักทำงานร่วมกับส่วนอื่นๆ เช่น ถ้าพิจารณาส่วนหลังบ้าน โค้ดโดยมากก็จะทำงานกับฐานข้อมูล (database) ในขณะที่โค้ดหน้าบ้านก็จะต้องมีการเรียก API ดังนั้นการจะทดสอบแต่ละ unit จะต้องมีการสร้างหรือจำลองส่วนอื่น ๆ ของระบบเพื่อทำให้โค้ดที่เราต้องการทดสอบสามารถทำงานได้ และมีพฤติกรรมที่คงเส้นคงวาทำให้เราสามารถตรวจสอบความถูกต้องได้ เราจะได้ลองใช้เทคนิคที่เรียกว่า [https://en.wikipedia.org/wiki/Mock_object mock] ในการจำลองการทำงานของส่วนอื่น ๆ ระหว่างที่โค้ดที่เราเทสทำงาน | ในการพัฒนาเว็บแอพลิเคชัน แต่ละ unit ที่เราต้องการทดสอบมักทำงานร่วมกับส่วนอื่นๆ เช่น ถ้าพิจารณาส่วนหลังบ้าน โค้ดโดยมากก็จะทำงานกับฐานข้อมูล (database) ในขณะที่โค้ดหน้าบ้านก็จะต้องมีการเรียก API ดังนั้นการจะทดสอบแต่ละ unit จะต้องมีการสร้างหรือจำลองส่วนอื่น ๆ ของระบบเพื่อทำให้โค้ดที่เราต้องการทดสอบสามารถทำงานได้ และมีพฤติกรรมที่คงเส้นคงวาทำให้เราสามารถตรวจสอบความถูกต้องได้ เราจะได้ลองใช้เทคนิคที่เรียกว่า [https://en.wikipedia.org/wiki/Mock_object mock] ในการจำลองการทำงานของส่วนอื่น ๆ ระหว่างที่โค้ดที่เราเทสทำงาน | ||
| − | == | + | ในส่วนที่ผ่านมา เวลาเราเขียน เราจะต้องเปิด flask server ไว้ และรัน npm run dev เพื่อทดสอบโค้ดที่เราทำ ในส่วนนี้ เราจะทำ unit test ที่จะทำให้เราสามารถทดสอบโค้ดได้โดยไม่ต้องเรียกผ่านทาง browser ดังนั้นก่อนจะทำ ถ้าเปิด flask server เอาไว้จากงานที่ผ่านมา สามารถปิดไปก่อนได้ อย่างไรก็ตาม ต่อให้ทดสอบมากแค่ไหน อย่างน้อยก่อนใช้จริง ก็อาจจะต้องนำมาเทสระบบภาพรวมทั้งหมดก่อนด้วย เพื่อความมั่นใจ |
| + | |||
| + | == เริ่มต้นทดสอบ React component: TodoItem == | ||
สำหรับส่วนหน้าบ้าน เราจะใช้ [https://vitest.dev/ Vitest] เป็นเครื่องมือในการรันเทสสำหรับ React component เราจะติดตั้ง vitest รวมทั้งไลบรารีอื่น ๆ โดยสั่ง | สำหรับส่วนหน้าบ้าน เราจะใช้ [https://vitest.dev/ Vitest] เป็นเครื่องมือในการรันเทสสำหรับ React component เราจะติดตั้ง vitest รวมทั้งไลบรารีอื่น ๆ โดยสั่ง | ||
| แถว 19: | แถว 21: | ||
* jsdom ใช้สำหรับจำลองหน้าจอ (dom) โดยไม่มี browser | * jsdom ใช้สำหรับจำลองหน้าจอ (dom) โดยไม่มี browser | ||
* @testing-library/xxx เป็นเครื่องมือต่าง ๆ สำหรับทดสอบ react | * @testing-library/xxx เป็นเครื่องมือต่าง ๆ สำหรับทดสอบ react | ||
| + | |||
| + | เมื่อเราสั่ง npm install แล้ว จะมีการเพิ่มรายการ package ต่าง ๆ รวมทั้งข้อมูลอื่น ในไฟล์ package.json และ package-lock.json ซึ่งทั้งสองไฟล์นี้จะเป็นไฟล์ที่จำเป็นในการนำโค้ดเราไปทำงานที่อื่น (นอกเหนือจากเครื่องเราเอง) เช่น รันใน container ตอนที่รัน github action | ||
เมื่อติดตั้งเสร็จ ให้ไปแก้ไฟล์ package.json เพื่อระบุว่าเมื่อสั่ง test ให้เรียก vitest โดยดูในส่วน "scripts" และเพิ่มบรรทัดดังด้านล่างเข้าไป | เมื่อติดตั้งเสร็จ ให้ไปแก้ไฟล์ package.json เพื่อระบุว่าเมื่อสั่ง test ให้เรียก vitest โดยดูในส่วน "scripts" และเพิ่มบรรทัดดังด้านล่างเข้าไป | ||
| แถว 36: | แถว 40: | ||
แต่เนื่องจากตอนนี้เราไม่มีเทสเคสเลย vitest จะบอกว่าไม่มี test files และจบการทำงาน (หมายเหตุ: เราสามารถสั่งให้ vitest ดูการแก้ไขไฟล์แล้วรันเทสใหม่ได้ แต่ในที่นี้เราเลือกจะให้รันเทสตอนที่เราสั่งก่อนเท่านั้น) | แต่เนื่องจากตอนนี้เราไม่มีเทสเคสเลย vitest จะบอกว่าไม่มี test files และจบการทำงาน (หมายเหตุ: เราสามารถสั่งให้ vitest ดูการแก้ไขไฟล์แล้วรันเทสใหม่ได้ แต่ในที่นี้เราเลือกจะให้รันเทสตอนที่เราสั่งก่อนเท่านั้น) | ||
| − | == | + | เราจะต้องไปเพิ่ม config ของ test ในไฟล์ vite.config.js ดังนี้ |
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | 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', // ระบุโค้ดสำหรับเตรียมต่าง ๆ | ||
| + | }, | ||
| + | }) | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | จากนั้นให้เพิ่มไฟล์ src/setupTests.js ดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import '@testing-library/jest-dom/vitest' | ||
| + | import { afterEach } from 'vitest' | ||
| + | import { cleanup } from '@testing-library/react' | ||
| + | |||
| + | afterEach(() => { | ||
| + | cleanup() | ||
| + | }) | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | โค้ดดังกล่าวจะทำให้มีการเรียกฟังก์ชัน cleanup เมื่อสิ้นสุดการรันในแต่ละเทสเคส | ||
| + | |||
| + | === Test แรก === | ||
| + | |||
| + | เพื่อความง่ายเราจะเริ่มจากการทดสอบ component TodoItem ก่อน เพราะว่าเป็น component ที่ไม่ได้มีการเรียก API | ||
| + | |||
| + | เราจะเก็บเทสทั้งหมดไว้ในไดเร็กทอรี frontent/src/__tests__/ ดังนั้นให้สร้างไฟล์ frontent/src/__tests__/TodoItem.test.jsx ดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import { render, screen } from '@testing-library/react' | ||
| + | import { expect } from 'vitest' | ||
| + | import TodoItem from '../TodoItem.jsx' | ||
| + | |||
| + | describe('TodoItem', () => { | ||
| + | it('renders with no comments correctly', () => { | ||
| + | // เดี๋ยวจะเพิ่มโค้ดตรงนี้ | ||
| + | }); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | โค้ดสำหรับเทสด้านบนยังไม่มีอะไร แต่ให้ลองเรียก | ||
| + | |||
| + | 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 ให้เป็นดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | // ** ละส่วน 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(); | ||
| + | }); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | สังเกตโครงสร้างของการเขียนเทสด้านบน ดังนี้ | ||
| + | * เราจะเรียก 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 เอง | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | 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 บนหน้าจอ | ||
| + | // | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ทดลองรันด้วย npm test ให้ผ่านก่อนจะทำต่อไป | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''🄶''' อย่าลืม commit งานที่ทำด้วย | ||
| + | }} | ||
| + | |||
| + | === ขับเคลื่อนด้วย test === | ||
| + | |||
| + | แนวทางการพัฒนาซอฟต์แวร์แบบหนึ่งที่เป็นที่นิยมคือ [https://en.wikipedia.org/wiki/Test-driven_development Test-Driven Developmen] หรือ TDD ภายใต้แนวคิดนี้ เราจะเขียนเทสก่อนเขียนโค้ด และให้เทสเป็นเครื่องมือในการขับเคลื่อนการพัฒนาซอฟต์แวร์ ในส่วนนี้เราจะทดลองกระบวนการดังกล่าวสักเล็กน้อย | ||
| + | |||
| + | ==== ขั้นที่ 1 ==== | ||
| + | |||
| + | เราจะปรับให้ TodoItem แสดงจำนวน comment และแสดงข้อความว่า No comments ถ้าไม่มี comment เลย แต่ก่อนที่เราจะทำดังกล่าว เราจะแก้ test case ก่อน จากนั้นค่อยไปแก้โค้ดให้ทำงานได้ตามที่เทสระบุ | ||
| + | |||
| + | ให้เพิ่มบรรทัด expect ด้านล่าง ลงในเทสเคส 'renders with no comments correctly' (เคสแรก) | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | it('renders with no comments correctly', () => { | ||
| + | // ... ละตอนต้นไว้ | ||
| + | expect(screen.getByText('No comments')).toBeInTheDocument(); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | จากนั้นให้แก้ component TodoItem ให้แสดงข้อความ No comments ถ้ารายการ todo.comments นั้นมีความยาวเป็น 0 (todo.comments.length == 0) เมื่อแก้แล้วให้ทดลองรัน npm test ถ้าไม่ผ่านให้แก้จนกว่าจะผ่าน | ||
| + | |||
| + | '''สังเกตว่าเราจะไม่ได้ไปทดลองรันใน browser เลย และเราสามารถทดสอบโดยยังไม่ได้เรียก flask run ด้วย''' | ||
| + | |||
| + | ==== ขั้นที่ 2 ==== | ||
| + | |||
| + | ข้อความ No comments ไม่ควรถูกแสดง ถ้าเรามี comments ดังนั้นในเทสเคสที่สอง เราจะตรวจว่าไม่มีข้อความดังกล่าว ให้เพิ่ม test case ด้านล่างลงไป (ควรใส่ไว้หลังเทสเคสแรก เพราะว่าอ่านรายการ test จะอ่านได้เป็นกลุ่มมากกว่า) | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | 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(); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้สังเกตวิธี 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 งานที่ทำด้วย | ||
| + | }} | ||
| + | |||
| + | == ทดสอบ App โดยการ mock api request == | ||
| + | |||
| + | เราจะทดสอบ component App อย่างไรก็ตามความซับซ้อนอยู่ที่ App มีการเรียก api ไปยัง backend ที่ผ่านมา เราจะทดสอบการทำงานหน้าบ้านได้ เราต้องคอยเรียก flask server ด้วยเสมอ นอกจากนี้ สถานะของฐานข้อมูลในส่วนหลังบ้าน ทำให้หน้าจอที่แสดงออกมานั้นเปลี่ยนไปมา ถ้าจะทดสอบให้ละเอียดเราต้องคิดว่าหน้าจอที่แสดงมานั้นถูกต้องหรือไม่เสมอ ๆ ซึ่งทำให้การทดสอบนั้นไม่สะดวก รวมทั้งอาจจะก่อให้เกิดความผิดพลาดในการทดสอบได้ง่าย | ||
| + | |||
| + | ในขั้นตอนนี้ เราจะเรียนรู้เครื่องมือใหม่ในการทดสอบคือ [https://en.wikipedia.org/wiki/Mock_object mock object] ซึ่งจะทำให้เราสามารถจำลองส่วนของซอฟต์แวร์ส่วนอื่น (ที่ไม่ต้องมีอยู่จริง) เพื่อใช้ประกอบการทดสอบ unit ของเราได้ | ||
| + | |||
| + | ด้านล่างเป็นโค้ด frontend/src/__tests__/App.test.jsx เราจะอธิบายแต่ละส่วนของโค้ดสำหรับทดสอบนี้ต่อไป | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | 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(); | ||
| + | }); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ถ้าใจร้อน นำโค้ดดังกล่าวไปสร้างไฟล์ จากนั้นเรียก npm test ดูก่อนได้ว่าผลลัพธ์ผ่านหมด | ||
| + | |||
| + | ตอนนี้ เรามาดูรายละเอียดกัน | ||
| + | |||
| + | ฟังก์ชัน mockResponse เป็นฟังก์ชันช่วยเหลือสำหรับใช้จำลองผลลัพธ์จากการเรียก fetch โดยมีสองส่วนคือส่วน ok และผลลัพธ์ json | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | const mockResponse = (body, ok = true) => | ||
| + | Promise.resolve({ | ||
| + | ok, | ||
| + | json: () => Promise.resolve(body), | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | สังเกตว่าในโค้ดของ App.jsx เวลาเราเรียก fetch แล้วเราจะตรวจสอบ response.ok แล้วจากนั้นจะอ่านค่าจาก response.json() ฟังก์ชันนี้ก็จะคืนค่าจำลองผลลัพธ์ในลักษณะดังกล่าว | ||
| + | |||
| + | สองส่วนด้านล่างจะเป็นการเตรียมการก่อนและหลังการเทส โดยเราระบุว่าก่อนจะเทส (beforeEach) จะแทนที่ fetch ด้วยฟังก์ชันที่เราจะไป mock และเมื่อเสร็จแต่ละเทส (afterEach) ให้ยกเลิกแทนที่ดังกล่าว รวมทั้งรีเซ็ต mock ทั้งหมดของเรา | ||
| + | <syntaxhighlight lang="javascript"> | ||
| + | beforeEach(() => { | ||
| + | vi.stubGlobal('fetch', vi.fn()); | ||
| + | }); | ||
| + | |||
| + | afterEach(() => { | ||
| + | vi.resetAllMocks(); | ||
| + | vi.unstubAllGlobals(); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เรามาดูฟังก์ชันในการทดสอบของเรากัน | ||
| + | <syntaxhighlight lang="javascript"> | ||
| + | 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(); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ส่วนสำคัญคือบรรทัดตอนบนที่ระบุ global.fetch.mockImplementationOnce เป็นการบอกว่าถ้ามีการเรียก fetch ให้คืน mockResponse ที่มี json ตามที่เราระบุ | ||
| + | |||
| + | ส่วนที่เหลือคือส่วน render และส่วน assertions ก็จะทำงานไม่ต่างจากเทสที่เราทำใน TodoItem | ||
| + | |||
| + | สังเกตว่าฟังก์ชันนี้ของเราเป็น async และในการตรวจสอบ screen จะต้องเรียกด้วย await เนื่องจากในการทำงานมีการเรียก fetch ด้านใน เราจะได้เห็นตัวอย่างอื่น ๆ เพิ่มเติมต่อไป | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''🄶''' ถ้าโค้ดทำงานได้ อย่าลืม commit งานที่ทำด้วย อย่าลืม add ไฟล์ App.test.jsx ด้วย | ||
| + | }} | ||
| + | |||
| + | == Github action == | ||
| + | |||
| + | เมื่อเราทำ unit test เรียบร้อยแล้ว เราสามารถใช้งาน github action ให้มีการรัน unit test เหล่านั้นโดยอัตโนมัติบน server ของ github เมื่อมีเหตุการณ์บางอย่างเกิดขึ้น เช่นมีการ push หรือทำ pull request หรืออาจจะมีการไปกดปุ่มให้ทำงานก็ได้ | ||
| + | |||
| + | : ''หมายเหตุ ในส่วนนี้ ตอนเขียนผมก็ให้ copilot สร้าง workflow ให้ ในการทำงานจริง พวกไฟล์ config เหล่านี้ เราไม่จำเป็นต้องรู้รายละเอียดลึกซึ้งทั้งหมด เพราะโดยมากจากสามารถใช้ AI generate ให้ได้อยู่แล้ว แต่เราจำเป็นจะต้องอ่าน เพื่อความมั่นใจว่าเราไม่ได้สั่งให้ไปทำอะไรแปลก ๆ หรือจะไปลบข้อมูลอะไรพวกนี้'' | ||
| + | |||
| + | === Workflow === | ||
| + | |||
| + | ในส่วนนี้เราจะทดลองใช้งาน github action เพื่อรันเทสเหล่านี้ ในการจะใช้ github action เราจะต้องสร้าง workflow ขึ้นมาก่อน workflow เหล่านี้จะอยู่ในไดเร็กทอรี .github/workflows | ||
| + | |||
| + | ให้สร้างไดเร็กทอรี .github/workflows (นับจากรากของ git repo เลย ไม่ใช่ใน frontend) จากนั้นสร้างไฟล์ frontend-tests.yml ดังด้านล่างในไดเร็กทอรีดังกล่าว | ||
| + | |||
| + | '''แฟ้ม .github/workflows/frontend-tests.yml''' | ||
| + | <syntaxhighlight lang="yaml"> | ||
| + | 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 | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ไฟล์ frontend-tests.yml นี้เป็น GitHub Actions workflow สำหรับ “รันทดสอบฝั่ง Frontend” อัตโนมัติ (และรันแบบ manual ได้ด้วย) โดยมีความหมายของแต่ละส่วนดังนี้ | ||
| + | |||
| + | '''ภาพรวม''' | ||
| + | |||
| + | Workflow นี้ใช้สำหรับรันชุดทดสอบของฝั่ง Frontend โดยจะทำงานอัตโนมัติเมื่อมีการเปลี่ยนแปลงภายใต้โฟลเดอร์ <code>frontend/</code> และสามารถสั่งรันด้วยมือได้ด้วย | ||
| + | |||
| + | * '''ชื่อ workflow''': <code>Frontend Tests</code> | ||
| + | * '''รันเมื่อ''': | ||
| + | ** มีการ <code>push</code> ที่กระทบไฟล์ใน <code>frontend/**</code> | ||
| + | ** มีการ <code>pull_request</code> ที่กระทบไฟล์ใน <code>frontend/**</code> | ||
| + | ** สั่งรันเองแบบ manual ผ่าน <code>workflow_dispatch</code> | ||
| + | * '''ทำอะไร''': Checkout โค้ด → ติดตั้ง Node.js → ติดตั้ง dependencies → รันเทสต์ (<code>npm test</code>) | ||
| + | |||
| + | '''การ Trigger (on)''' | ||
| + | |||
| + | <syntaxhighlight lang="yaml"> | ||
| + | on: | ||
| + | push: | ||
| + | paths: | ||
| + | - 'frontend/**' | ||
| + | pull_request: | ||
| + | paths: | ||
| + | - 'frontend/**' | ||
| + | workflow_dispatch: | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ; push | ||
| + | : รันเมื่อมีการ push โค้ดขึ้น repo แต่จะรันเฉพาะกรณีที่มีไฟล์เปลี่ยนใน path <code>frontend/**</code> | ||
| + | |||
| + | ; pull_request | ||
| + | : รันเมื่อมีการเปิด/อัปเดต Pull Request และมีไฟล์เปลี่ยนใน <code>frontend/**</code> | ||
| + | |||
| + | ; workflow_dispatch | ||
| + | : เปิดให้รัน workflow ได้เองจากหน้า GitHub (Actions → เลือก workflow → Run workflow) โดยไม่ต้องมี push/PR ใหม่ | ||
| + | |||
| + | '''Jobs''' มีงานเดียวคือ test-frontend | ||
| + | |||
| + | <syntaxhighlight lang="yaml"> | ||
| + | jobs: | ||
| + | test-frontend: | ||
| + | runs-on: ubuntu-latest | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | * <code>test-frontend</code> คือชื่อ job หลักที่ใช้รันทดสอบ | ||
| + | * <code>runs-on: ubuntu-latest</code> เลือกเครื่อง runner ของ GitHub ที่เป็น Ubuntu เวอร์ชันล่าสุด | ||
| + | |||
| + | '''ค่าเริ่มต้นของคำสั่ง (defaults)''' | ||
| + | |||
| + | <syntaxhighlight lang="yaml"> | ||
| + | defaults: | ||
| + | run: | ||
| + | working-directory: frontend | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ตั้งค่าให้ทุก step ที่ใช้ <code>run:</code> ไปทำงานในโฟลเดอร์ <code>frontend</code> โดยอัตโนมัติ (ไม่ต้อง <code>cd frontend</code> ทุกครั้ง) | ||
| + | |||
| + | '''Steps (ลำดับการทำงาน)''' | ||
| + | |||
| + | * Checkout repository | ||
| + | <syntaxhighlight lang="yaml"> | ||
| + | - name: Checkout repository | ||
| + | uses: actions/checkout@v4 | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ดึงซอร์สโค้ดจาก repo มาไว้บน runner เพื่อให้ขั้นตอนถัดไปเข้าถึงไฟล์ในโปรเจกต์ได้ | ||
| + | |||
| + | * Setup Node.js | ||
| + | <syntaxhighlight lang="yaml"> | ||
| + | - name: Use Node.js | ||
| + | uses: actions/setup-node@v4 | ||
| + | with: | ||
| + | node-version: '20' | ||
| + | cache: 'npm' | ||
| + | cache-dependency-path: 'frontend/package-lock.json' | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ** ติดตั้ง/ตั้งค่า Node.js เวอร์ชัน <code>20</code> | ||
| + | ** เปิดใช้ cache สำหรับ <code>npm</code> เพื่อให้การติดตั้งเร็วขึ้นในการรันครั้งถัดไป | ||
| + | ** ใช้ <code>frontend/package-lock.json</code> เป็นตัวอ้างอิงในการจัดการ cache (ถ้า lockfile ไม่เปลี่ยน มักจะ reuse cache ได้) | ||
| + | |||
| + | * Install dependencies | ||
| + | <syntaxhighlight lang="yaml"> | ||
| + | - name: Install dependencies | ||
| + | run: npm ci | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ติดตั้ง dependencies แบบ CI โดยยึดตาม <code>package-lock.json</code> อย่างเคร่งครัด (เหมาะกับงานบน CI เพราะ reproducible) | ||
| + | |||
| + | * Run tests | ||
| + | <syntaxhighlight lang="yaml"> | ||
| + | - name: Run tests | ||
| + | run: npm test | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | รันชุดทดสอบของ frontend ตามที่สคริปต์ <code>test</code> ถูกกำหนดไว้ใน <code>frontend/package.json</code> | ||
| + | |||
| + | '''หมายเหตุ''' | ||
| + | |||
| + | * Workflow นี้จะไม่รัน หากมีการเปลี่ยนแปลงเฉพาะไฟล์นอก <code>frontend/**</code> | ||
| + | * หากต้องการทดสอบทันทีโดยไม่ push/PR สามารถใช้ manual run ได้ด้วย <code>workflow_dispatch</code> | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''🄶''' เมื่อเพิ่มไฟล์ frontend-test.yml แล้ว ให้ add ลงใน git รวมทั้ง commit | ||
| + | }} | ||
| + | |||
| + | === ทดลองเรียกใช้งาน === | ||
| + | |||
| + | หลังจาก commit ไฟล์ workflow แล้ว ให้เรา push งานที่เราทำกลับไปที่ github จากนั้นให้กดไปดูส่วน Action จะเห็นดังรูปด้านล่าง (แต่ของนิสิตจะไม่มีรายการของการ run เพราะว่าเพิ่งเปิดเข้ามา) | ||
| + | |||
| + | [[Image:223-github-action-01.png|500px]] | ||
| + | |||
| + | สังเกตว่าจะยังไม่มีการทำงานใด ๆ เพราะว่าเราระบุใน workflow ว่าจะทำถ้ามีการ push เข้ามา หรือมีการกดสั่งจากหน้าจอ | ||
| + | |||
| + | เราจะไปสั่งให้ workflow ทำงานแบบ manual กันก่อน ในรายการด้านซ้าย จะเห็น workflow ''Frontend Tests'' อยู่ ถ้าเรากดที่งานนั้น จะเห็นปุ่ม '''Run workflow''' ด้านขวา ให้ลองกดรัน จากนั้น github action จะเริ่มทำงาน เราสามารถกดเข้าไปติดตามการทำงานได้ | ||
| + | |||
| + | เมื่อทำงานเสร็จ ถ้าเรากดเข้าไปจะเห็น job test-frontend อยู่ (น่าจะเป็นสีเขียว) ถ้ากดเข้าไปอีกจะเห็นผลลัพธ์การทำงานดังรูปด้านล่าง | ||
| + | |||
| + | [[Image:223-github-action-02-result1.png|400px]] | ||
| + | |||
| + | เราสามารถทดลองแก้ test ให้ fail ก่อน (ให้ลองหาวิธีทำเล่น ๆ ดู) จากนั้น commit และ push ขึ้นไป github อีกครั้งเพื่อดูผลการทำงาน เมื่อทำงานเสร็จน่าจะเห็นผลลัพธ์ว่าเทสล้มเหลวตามรูป ซึ่งจากจุดนี้เราสามารถกดเข้าไปดูรายละเอียดได้ | ||
| + | |||
| + | [[Image:223-github-action-03-result2.png|400px]] | ||
| + | |||
| + | แก้เทสให้กลับมาผ่านเหมือนเดิม commit และ push กลับไปอีกครั้ง ก่อนจะทำต่อไป | ||
| + | |||
| + | == ทดสอบกันต่อ == | ||
| + | === ทดสอบว่า TodoItem เรียก callback หลังมีการกดปุ่ม === | ||
| + | |||
| + | เราจะเพิ่มการทดสอบ component TodoItem ว่ามีการเรียก callback ตามที่คาดหวังไว้ ด้านล่างเป็นส่วนของการทดสอบปุ่ม Toggle ให้เพิ่มลงใน TodoItem.test.jsx | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | it('makes callback to toggleDone when Toggle button is clicked', () => { | ||
| + | const onToggleDone = vi.fn(); | ||
| + | render( | ||
| + | <TodoItem | ||
| + | todo={baseTodo} | ||
| + | toggleDone={onToggleDone} /> | ||
| + | ); | ||
| + | const button = screen.getByRole('button', { name: /toggle/i }); | ||
| + | button.click(); | ||
| + | expect(onToggleDone).toHaveBeenCalledWith(baseTodo.id); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ข้อสังเกต | ||
| + | * ในข้อนี้เราไม่ได้มีการ mock อะไร แต่เราส่งฟังก์ชัน onToggleDone ที่เป็น vi.fn() ไปให้ใน prop toggleDone | ||
| + | * เราคาดว่า (expect) ฟังก์ชันดังกล่าวจะถูกเรียกโดย component เมื่อมีการกดปุ่ม ฟังก์ชันจาก vi.fn จะมีคุณสมบัติพิเศษคือมันจะจำการเรียกต่าง ๆ ไว้ทำให้เราสามารถ assert ได้ในภายหลัง | ||
| + | * เราแกะปุ่ม Toggle มาจาก screen.getByRole (ดูรายละเอียดในโค้ด) | ||
| + | * สั่งกดปุ่ม button.click() | ||
| + | * สุดท้ายเรา assert ว่าฟังก์ชัน onToggleDone ที่ส่งไป <code>.toHaveBeenCalledWith(baseTodo.id)</code> | ||
| + | |||
| + | ทดลองรันด้วย npm test ถ้าไม่ผ่าน อาจจะดู error และลองตรวจสอบว่า TodoItem ของเราใช้ข้อความหรือสัญลักษณ์อะไรในปุ่ม toggle | ||
| + | |||
| + | ให้ทดลองเพิ่มเทสเคสปุ่ม delete จากโครงด้านล่าง ในส่วนของการเลือกปุ่ม delete ให้ดูโค้ดใน TotoItem และใส่ให้สอดคล้องกัน | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | it('makes callback to deleteTodo when delete button is clicked', () => { | ||
| + | // | ||
| + | // *** TODO: เขียนเอง | ||
| + | // | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | แก้และทดลองรันด้วย npm test จนกว่าจะผ่าน | ||
| + | |||
| + | ในส่วนถัดไป เราจะทดสอบการเรียก addNewComment แต่ในกรณีนี้เราต้องพิมพ์ข้อความลงในกล่องข้อความ (ที่จัดการโดย state newComment) โดยเราจะต้องใช้ library เพิ่มเติม ให้ import userEvent ด้วยโค้ดด้านล่าง (ใส่ตอนต้น TodoItem.test.jsx) | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import userEvent from '@testing-library/user-event' | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ด้านล่างเป็นโครงสำหรับโค้ดที่ทดสอบการเรียก addNewComment ให้เติมจนใช้งานได้ | ||
| + | <syntaxhighlight lang="javascript"> | ||
| + | it('makes callback to addNewComment when a new comment is added', async () => { | ||
| + | const onAddNewComment = vi.fn(); | ||
| + | render( | ||
| + | // | ||
| + | // TODO: เติม component และ prop | ||
| + | // | ||
| + | ); | ||
| + | |||
| + | // พิมพ์ข้อความลงใน textbox | ||
| + | const input = screen.getByRole('textbox'); | ||
| + | await userEvent.type(input, 'New comment'); | ||
| + | |||
| + | // | ||
| + | // TODO: เติมโค้ดสำหรับเรียกให้กดปุ่มด้วย | ||
| + | // | ||
| + | expect(onAddNewComment).toHaveBeenCalledWith(baseTodo.id, 'New comment'); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ทดลองรันด้วย npm test และปรับแก้จนกว่าจะทดสอบผ่านทั้งหมด | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''🄶''' ถ้าโค้ดทำงานได้ ให้ commit งานที่ทำ และ push ไป github จากนั้นอย่าลืมไปดูผลการทำงานของ github action ที่เราใส่ไว้ (เทสควรผ่านหมด) | ||
| + | }} | ||
| + | |||
| + | === ทดสอบ App === | ||
| + | เราจะเขียนเทสเคสเพิ่มเติมสำหรับ component App ก่อนอื่นเราจะขอย้ายค่าคงที่สำหรับทดสอบก่อน (TodoItem ทดสอบ) | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | // ... ละด้านบน | ||
| + | |||
| + | // *** ย้ายมาที่นี่ | ||
| + | const todoItem1 = { id: 1, title: 'First todo', done: false, comments: [] }; | ||
| + | const todoItem2 = { id: 2, title: 'Second todo', done: false, comments: [ | ||
| + | { id: 1, message: 'First comment' }, | ||
| + | { id: 2, message: 'Second comment' }, | ||
| + | ] }; | ||
| + | |||
| + | const originalTodoList = [ | ||
| + | todoItem1, | ||
| + | todoItem2, | ||
| + | ] | ||
| + | |||
| + | describe('App', () => { | ||
| + | // ... ละส่วนอื่นไว้ | ||
| + | |||
| + | it('renders correctly', async() => { | ||
| + | // *** ให้คืน mockResponse(originalTodoList) เลย ลบของเก่าออก | ||
| + | global.fetch.mockImplementationOnce(() => | ||
| + | mockResponse(originalTodoList) | ||
| + | ); | ||
| + | |||
| + | // ... ละไว้ | ||
| + | }); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เราจะทดสอบการกด Toggle ที่ todo item แรก ด้านล่างเป็นโค้ด พร้อมทั้ง comment ที่อธิบายการทำงาน | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | it('toggles done on a todo item', async() => { | ||
| + | // เตรียมค่าสำหรับคืนหลังกด toggle done แล้ว | ||
| + | const toggledTodoItem1 = { ...todoItem1, done: true }; | ||
| + | |||
| + | // mock fetch --- สังเกตว่าจะมีการเรียก fetch สองครั้ง จากการ init และจากการกดปุ่ม | ||
| + | // สำหรับการเรียกแต่ละครั้งเราจะสามารถโปรแกรมคำตอบแยกกันได้ โดยเรียก mockImplementationOnce หลายครั้ง | ||
| + | // กล่าวคือ รอบแรกคืนรายการทั้งหมด รอบที่สองคืนค่า todo item ที่แก้ค่าแล้ว | ||
| + | global.fetch | ||
| + | .mockImplementationOnce(() => mockResponse(originalTodoList)) | ||
| + | .mockImplementationOnce(() => mockResponse(toggledTodoItem1)); | ||
| + | |||
| + | render(<App />); | ||
| + | |||
| + | // assert ก่อนว่าของเดิม todo item แรกไม่ได้มีคลาส done | ||
| + | expect(await screen.findByText('First todo')).not.toHaveClass('done'); | ||
| + | |||
| + | // หาปุ่ม จะเจอ 2 ปุ่ม (เพราะว่ามี 2 todo item) | ||
| + | const toggleButtons = await screen.findAllByRole('button', { name: /toggle/i }) | ||
| + | // เลือกกดปุ่มแรก | ||
| + | toggleButtons[0].click(); | ||
| + | |||
| + | // ตรวจสอบว่า todo item นั้นเปลี่ยนคลาสเป็น done แล้ว | ||
| + | expect(await screen.findByText('First todo')).toHaveClass('done'); | ||
| + | }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ทดลองรันด้วย npm test ถ้าเทสไม่ผ่านให้แก้ไข | ||
| + | |||
| + | สังเกตว่าในการคืนค่า fetch ที่เรา mock ไป เราไม่ได้สนใจเลยว่า URL ที่เรียกจะเป็นอย่างไร เราสามารถ assert เพิ่มเติมได้ URL จะต้องมีรูปแบบที่ถูกต้อง เช่น มีคำว่า toggle/1 เป็นต้น โดยเพิ่ม assertion ลงไปในเทสเคสดังกล่าวดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | expect(global.fetch).toHaveBeenLastCalledWith(expect.stringMatching(/1\/toggle/), { method: 'PATCH' }); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ในโค้ดข้างต้นการเรียก toHaveBeenLastCalledWith เราสามารถส่งว่าให้ match parameter ด้วย string matching ได้ ทำให้เราไม่ต้องระบุ URL เต็มในการตรวจสอบ | ||
| + | |||
| + | ให้ทดลองรันด้วย npm test อีกครั้ง แก้จนเทสเคสผ่านทั้งหมด | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''🄶''' ถ้าโค้ดทำงานได้ ให้ commit งานที่ทำ และ push ไป github จากนั้นอย่าลืมไปดูผลการทำงานของ github action ที่เราใส่ไว้ (เทสควรผ่านหมด) | ||
| + | }} | ||
| + | |||
| + | == ต่อจากนี้ == | ||
| + | * เราสามารถเทสส่วนอื่นๆ ของ App ได้ ถ้าสนใจอาจจะทดลองทำดู | ||
| + | * เรายังไม่ได้ทำ unit test ในส่วน flask backend สังเกตว่าในขณะที่ในการ testing react เราต้อง mock พวก api request ถ้าเป็นการเทส backend แบบ unit test เราจะต้อง mock SQLAlchemy เพื่อให้คืนค่าตามที่เราต้องการ ''ซึ่งทำได้ยากมาก'' ดังนั้น เราจะทดสอบ backend โดยทำเป็น integration test แทน คือเราจะสร้าง test database เพื่อมาทดสอบแทน เราจะเรียนเรื่องนี้ถัดไป | ||
รุ่นแก้ไขปัจจุบันเมื่อ 06:25, 13 กุมภาพันธ์ 2569
- หน้านี้เป็นส่วนหนึ่งของ 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: TodoItem
สำหรับส่วนหน้าบ้าน เราจะใช้ 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 งานที่ทำด้วย
ทดสอบ App โดยการ mock api request
เราจะทดสอบ component App อย่างไรก็ตามความซับซ้อนอยู่ที่ App มีการเรียก api ไปยัง backend ที่ผ่านมา เราจะทดสอบการทำงานหน้าบ้านได้ เราต้องคอยเรียก flask server ด้วยเสมอ นอกจากนี้ สถานะของฐานข้อมูลในส่วนหลังบ้าน ทำให้หน้าจอที่แสดงออกมานั้นเปลี่ยนไปมา ถ้าจะทดสอบให้ละเอียดเราต้องคิดว่าหน้าจอที่แสดงมานั้นถูกต้องหรือไม่เสมอ ๆ ซึ่งทำให้การทดสอบนั้นไม่สะดวก รวมทั้งอาจจะก่อให้เกิดความผิดพลาดในการทดสอบได้ง่าย
ในขั้นตอนนี้ เราจะเรียนรู้เครื่องมือใหม่ในการทดสอบคือ mock object ซึ่งจะทำให้เราสามารถจำลองส่วนของซอฟต์แวร์ส่วนอื่น (ที่ไม่ต้องมีอยู่จริง) เพื่อใช้ประกอบการทดสอบ unit ของเราได้
ด้านล่างเป็นโค้ด frontend/src/__tests__/App.test.jsx เราจะอธิบายแต่ละส่วนของโค้ดสำหรับทดสอบนี้ต่อไป
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();
});
});
ถ้าใจร้อน นำโค้ดดังกล่าวไปสร้างไฟล์ จากนั้นเรียก npm test ดูก่อนได้ว่าผลลัพธ์ผ่านหมด
ตอนนี้ เรามาดูรายละเอียดกัน
ฟังก์ชัน mockResponse เป็นฟังก์ชันช่วยเหลือสำหรับใช้จำลองผลลัพธ์จากการเรียก fetch โดยมีสองส่วนคือส่วน ok และผลลัพธ์ json
const mockResponse = (body, ok = true) =>
Promise.resolve({
ok,
json: () => Promise.resolve(body),
});
สังเกตว่าในโค้ดของ App.jsx เวลาเราเรียก fetch แล้วเราจะตรวจสอบ response.ok แล้วจากนั้นจะอ่านค่าจาก response.json() ฟังก์ชันนี้ก็จะคืนค่าจำลองผลลัพธ์ในลักษณะดังกล่าว
สองส่วนด้านล่างจะเป็นการเตรียมการก่อนและหลังการเทส โดยเราระบุว่าก่อนจะเทส (beforeEach) จะแทนที่ fetch ด้วยฟังก์ชันที่เราจะไป mock และเมื่อเสร็จแต่ละเทส (afterEach) ให้ยกเลิกแทนที่ดังกล่าว รวมทั้งรีเซ็ต mock ทั้งหมดของเรา
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();
});
ส่วนสำคัญคือบรรทัดตอนบนที่ระบุ global.fetch.mockImplementationOnce เป็นการบอกว่าถ้ามีการเรียก fetch ให้คืน mockResponse ที่มี json ตามที่เราระบุ
ส่วนที่เหลือคือส่วน render และส่วน assertions ก็จะทำงานไม่ต่างจากเทสที่เราทำใน TodoItem
สังเกตว่าฟังก์ชันนี้ของเราเป็น async และในการตรวจสอบ screen จะต้องเรียกด้วย await เนื่องจากในการทำงานมีการเรียก fetch ด้านใน เราจะได้เห็นตัวอย่างอื่น ๆ เพิ่มเติมต่อไป
🄶 ถ้าโค้ดทำงานได้ อย่าลืม commit งานที่ทำด้วย อย่าลืม add ไฟล์ App.test.jsx ด้วย
Github action
เมื่อเราทำ unit test เรียบร้อยแล้ว เราสามารถใช้งาน github action ให้มีการรัน unit test เหล่านั้นโดยอัตโนมัติบน server ของ github เมื่อมีเหตุการณ์บางอย่างเกิดขึ้น เช่นมีการ push หรือทำ pull request หรืออาจจะมีการไปกดปุ่มให้ทำงานก็ได้
- หมายเหตุ ในส่วนนี้ ตอนเขียนผมก็ให้ copilot สร้าง workflow ให้ ในการทำงานจริง พวกไฟล์ config เหล่านี้ เราไม่จำเป็นต้องรู้รายละเอียดลึกซึ้งทั้งหมด เพราะโดยมากจากสามารถใช้ AI generate ให้ได้อยู่แล้ว แต่เราจำเป็นจะต้องอ่าน เพื่อความมั่นใจว่าเราไม่ได้สั่งให้ไปทำอะไรแปลก ๆ หรือจะไปลบข้อมูลอะไรพวกนี้
Workflow
ในส่วนนี้เราจะทดลองใช้งาน github action เพื่อรันเทสเหล่านี้ ในการจะใช้ github action เราจะต้องสร้าง workflow ขึ้นมาก่อน workflow เหล่านี้จะอยู่ในไดเร็กทอรี .github/workflows
ให้สร้างไดเร็กทอรี .github/workflows (นับจากรากของ git repo เลย ไม่ใช่ใน frontend) จากนั้นสร้างไฟล์ frontend-tests.yml ดังด้านล่างในไดเร็กทอรีดังกล่าว
แฟ้ม .github/workflows/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 ได้)
- ติดตั้ง/ตั้งค่า Node.js เวอร์ชัน
- 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
🄶 เมื่อเพิ่มไฟล์ frontend-test.yml แล้ว ให้ add ลงใน git รวมทั้ง commit
ทดลองเรียกใช้งาน
หลังจาก commit ไฟล์ workflow แล้ว ให้เรา push งานที่เราทำกลับไปที่ github จากนั้นให้กดไปดูส่วน Action จะเห็นดังรูปด้านล่าง (แต่ของนิสิตจะไม่มีรายการของการ run เพราะว่าเพิ่งเปิดเข้ามา)
สังเกตว่าจะยังไม่มีการทำงานใด ๆ เพราะว่าเราระบุใน workflow ว่าจะทำถ้ามีการ push เข้ามา หรือมีการกดสั่งจากหน้าจอ
เราจะไปสั่งให้ workflow ทำงานแบบ manual กันก่อน ในรายการด้านซ้าย จะเห็น workflow Frontend Tests อยู่ ถ้าเรากดที่งานนั้น จะเห็นปุ่ม Run workflow ด้านขวา ให้ลองกดรัน จากนั้น github action จะเริ่มทำงาน เราสามารถกดเข้าไปติดตามการทำงานได้
เมื่อทำงานเสร็จ ถ้าเรากดเข้าไปจะเห็น job test-frontend อยู่ (น่าจะเป็นสีเขียว) ถ้ากดเข้าไปอีกจะเห็นผลลัพธ์การทำงานดังรูปด้านล่าง
เราสามารถทดลองแก้ test ให้ fail ก่อน (ให้ลองหาวิธีทำเล่น ๆ ดู) จากนั้น commit และ push ขึ้นไป github อีกครั้งเพื่อดูผลการทำงาน เมื่อทำงานเสร็จน่าจะเห็นผลลัพธ์ว่าเทสล้มเหลวตามรูป ซึ่งจากจุดนี้เราสามารถกดเข้าไปดูรายละเอียดได้
แก้เทสให้กลับมาผ่านเหมือนเดิม commit และ push กลับไปอีกครั้ง ก่อนจะทำต่อไป
ทดสอบกันต่อ
ทดสอบว่า TodoItem เรียก callback หลังมีการกดปุ่ม
เราจะเพิ่มการทดสอบ component TodoItem ว่ามีการเรียก callback ตามที่คาดหวังไว้ ด้านล่างเป็นส่วนของการทดสอบปุ่ม Toggle ให้เพิ่มลงใน TodoItem.test.jsx
it('makes callback to toggleDone when Toggle button is clicked', () => {
const onToggleDone = vi.fn();
render(
<TodoItem
todo={baseTodo}
toggleDone={onToggleDone} />
);
const button = screen.getByRole('button', { name: /toggle/i });
button.click();
expect(onToggleDone).toHaveBeenCalledWith(baseTodo.id);
});
ข้อสังเกต
- ในข้อนี้เราไม่ได้มีการ mock อะไร แต่เราส่งฟังก์ชัน onToggleDone ที่เป็น vi.fn() ไปให้ใน prop toggleDone
- เราคาดว่า (expect) ฟังก์ชันดังกล่าวจะถูกเรียกโดย component เมื่อมีการกดปุ่ม ฟังก์ชันจาก vi.fn จะมีคุณสมบัติพิเศษคือมันจะจำการเรียกต่าง ๆ ไว้ทำให้เราสามารถ assert ได้ในภายหลัง
- เราแกะปุ่ม Toggle มาจาก screen.getByRole (ดูรายละเอียดในโค้ด)
- สั่งกดปุ่ม button.click()
- สุดท้ายเรา assert ว่าฟังก์ชัน onToggleDone ที่ส่งไป
.toHaveBeenCalledWith(baseTodo.id)
ทดลองรันด้วย npm test ถ้าไม่ผ่าน อาจจะดู error และลองตรวจสอบว่า TodoItem ของเราใช้ข้อความหรือสัญลักษณ์อะไรในปุ่ม toggle
ให้ทดลองเพิ่มเทสเคสปุ่ม delete จากโครงด้านล่าง ในส่วนของการเลือกปุ่ม delete ให้ดูโค้ดใน TotoItem และใส่ให้สอดคล้องกัน
it('makes callback to deleteTodo when delete button is clicked', () => {
//
// *** TODO: เขียนเอง
//
});
แก้และทดลองรันด้วย npm test จนกว่าจะผ่าน
ในส่วนถัดไป เราจะทดสอบการเรียก addNewComment แต่ในกรณีนี้เราต้องพิมพ์ข้อความลงในกล่องข้อความ (ที่จัดการโดย state newComment) โดยเราจะต้องใช้ library เพิ่มเติม ให้ import userEvent ด้วยโค้ดด้านล่าง (ใส่ตอนต้น TodoItem.test.jsx)
import userEvent from '@testing-library/user-event'
ด้านล่างเป็นโครงสำหรับโค้ดที่ทดสอบการเรียก addNewComment ให้เติมจนใช้งานได้
it('makes callback to addNewComment when a new comment is added', async () => {
const onAddNewComment = vi.fn();
render(
//
// TODO: เติม component และ prop
//
);
// พิมพ์ข้อความลงใน textbox
const input = screen.getByRole('textbox');
await userEvent.type(input, 'New comment');
//
// TODO: เติมโค้ดสำหรับเรียกให้กดปุ่มด้วย
//
expect(onAddNewComment).toHaveBeenCalledWith(baseTodo.id, 'New comment');
});
ให้ทดลองรันด้วย npm test และปรับแก้จนกว่าจะทดสอบผ่านทั้งหมด
🄶 ถ้าโค้ดทำงานได้ ให้ commit งานที่ทำ และ push ไป github จากนั้นอย่าลืมไปดูผลการทำงานของ github action ที่เราใส่ไว้ (เทสควรผ่านหมด)
ทดสอบ App
เราจะเขียนเทสเคสเพิ่มเติมสำหรับ component App ก่อนอื่นเราจะขอย้ายค่าคงที่สำหรับทดสอบก่อน (TodoItem ทดสอบ)
// ... ละด้านบน
// *** ย้ายมาที่นี่
const todoItem1 = { id: 1, title: 'First todo', done: false, comments: [] };
const todoItem2 = { id: 2, title: 'Second todo', done: false, comments: [
{ id: 1, message: 'First comment' },
{ id: 2, message: 'Second comment' },
] };
const originalTodoList = [
todoItem1,
todoItem2,
]
describe('App', () => {
// ... ละส่วนอื่นไว้
it('renders correctly', async() => {
// *** ให้คืน mockResponse(originalTodoList) เลย ลบของเก่าออก
global.fetch.mockImplementationOnce(() =>
mockResponse(originalTodoList)
);
// ... ละไว้
});
});
เราจะทดสอบการกด Toggle ที่ todo item แรก ด้านล่างเป็นโค้ด พร้อมทั้ง comment ที่อธิบายการทำงาน
it('toggles done on a todo item', async() => {
// เตรียมค่าสำหรับคืนหลังกด toggle done แล้ว
const toggledTodoItem1 = { ...todoItem1, done: true };
// mock fetch --- สังเกตว่าจะมีการเรียก fetch สองครั้ง จากการ init และจากการกดปุ่ม
// สำหรับการเรียกแต่ละครั้งเราจะสามารถโปรแกรมคำตอบแยกกันได้ โดยเรียก mockImplementationOnce หลายครั้ง
// กล่าวคือ รอบแรกคืนรายการทั้งหมด รอบที่สองคืนค่า todo item ที่แก้ค่าแล้ว
global.fetch
.mockImplementationOnce(() => mockResponse(originalTodoList))
.mockImplementationOnce(() => mockResponse(toggledTodoItem1));
render(<App />);
// assert ก่อนว่าของเดิม todo item แรกไม่ได้มีคลาส done
expect(await screen.findByText('First todo')).not.toHaveClass('done');
// หาปุ่ม จะเจอ 2 ปุ่ม (เพราะว่ามี 2 todo item)
const toggleButtons = await screen.findAllByRole('button', { name: /toggle/i })
// เลือกกดปุ่มแรก
toggleButtons[0].click();
// ตรวจสอบว่า todo item นั้นเปลี่ยนคลาสเป็น done แล้ว
expect(await screen.findByText('First todo')).toHaveClass('done');
});
ให้ทดลองรันด้วย npm test ถ้าเทสไม่ผ่านให้แก้ไข
สังเกตว่าในการคืนค่า fetch ที่เรา mock ไป เราไม่ได้สนใจเลยว่า URL ที่เรียกจะเป็นอย่างไร เราสามารถ assert เพิ่มเติมได้ URL จะต้องมีรูปแบบที่ถูกต้อง เช่น มีคำว่า toggle/1 เป็นต้น โดยเพิ่ม assertion ลงไปในเทสเคสดังกล่าวดังนี้
expect(global.fetch).toHaveBeenLastCalledWith(expect.stringMatching(/1\/toggle/), { method: 'PATCH' });
ในโค้ดข้างต้นการเรียก toHaveBeenLastCalledWith เราสามารถส่งว่าให้ match parameter ด้วย string matching ได้ ทำให้เราไม่ต้องระบุ URL เต็มในการตรวจสอบ
ให้ทดลองรันด้วย npm test อีกครั้ง แก้จนเทสเคสผ่านทั้งหมด
🄶 ถ้าโค้ดทำงานได้ ให้ commit งานที่ทำ และ push ไป github จากนั้นอย่าลืมไปดูผลการทำงานของ github action ที่เราใส่ไว้ (เทสควรผ่านหมด)
ต่อจากนี้
- เราสามารถเทสส่วนอื่นๆ ของ App ได้ ถ้าสนใจอาจจะทดลองทำดู
- เรายังไม่ได้ทำ unit test ในส่วน flask backend สังเกตว่าในขณะที่ในการ testing react เราต้อง mock พวก api request ถ้าเป็นการเทส backend แบบ unit test เราจะต้อง mock SQLAlchemy เพื่อให้คืนค่าตามที่เราต้องการ ซึ่งทำได้ยากมาก ดังนั้น เราจะทดสอบ backend โดยทำเป็น integration test แทน คือเราจะสร้าง test database เพื่อมาทดสอบแทน เราจะเรียนเรื่องนี้ถัดไป


