Jest คืออะไร? + เริ่มต้นเขียน Test ด้วย Jest กันดีกว่า
สวัสดีครับ วันนี้จะมาเขียนบทความเกี่ยวกับการเขียน Test และการใช้ library ที่ชื่อว่า Jest มาช่วยให้เราเขียน Unit Test กับ JavaScript ได้สะดวก และง่ายขึ้นกันครับ สำหรับบทความนี้ก็จะร่ายยาวไปตั้งแต่เริ่มต้นเลยว่า ทำไมเราต้องเขียนเทส มีประโยชน์อะไร? และการใช้งาน Jest พร้อม Source Code
ทำไมต้องเขียน Test?
ทำไมต้องเขียน Test? เชื่อว่าหลายๆคนต้องสงสัยกันแน่ๆ Deadline ก็ต้องรีบส่ง แค่ implement feature ที่ต้องการก็แทบไม่ทันแล้ว จะเอาเวลาที่ไหนไปนั่งเขียน Test กันละ? เสียเวลาเพิ่มอีก แต่ลูกค้าก็ได้ feature เท่าเดิม จะเสียเวลาทำไม รีบทำ รีบปิดงาน 😅
ข้อดีของเทสคือ
- เรามั่นใจในโค๊ดของเรามากขึ้น
- ง่ายต่อการเปลี่ยนแปลง หรือ refactor โค๊ด
- เป็น document ในตัวเอง
และการเขียนโค๊ดก็มีหลายระดับ หรือหลายหลักการ ไม่ว่าจะเป็น Unit Test, Integration Test, UI Test, E2E Test, อื่นๆ เต็มไปหมด ซึ่ง Unit Test เป็นส่วนที่ง่ายในการเทสมากที่สุด และประหยัดเวลาที่สุด ฉะนั้นหากใครมองว่ามันเสียเวลา อย่างน้อยขอแค่ Unit Test ก็พอครับ ใครไม่เคยเขียน Unit Test ก็มาเริ่มกันเลยครับ
หรือหลักการเขียนเทส ก็มีอยู่เยอะครับ TDD, BDD, ATDD, etc. เป็นเรื่อง advance หรือแล้วแต่ทีม มีปัจจัยต่างๆอีกมากมายครับ
ผมจะไม่ได้ไป focus หลักการ Red Green Refactor หรืออะไรพวกนั้นนะครับ ว่าต้องเขียนเทสก่อน ค่อย implement code แต่เราอาจจะสลับกันก็ได้ เชียนโค๊ดก่อน ค่อยเทสให้ผ่าน จุดประสงค์ของการเทสจริงๆ คือเทสไปทำไม และรู้ว่าควรเทสอะไรมากกว่า คือไม่ว่าจะเทสก่อน หรือเขียนโค๊ด เอาเป็นว่า pass ก็โอเคแล้วเนาะ
Jest คืออะไร?
Jest เป็น JavaScript Framework สำหรับเอาไว้เขียน Test เป็น Open Source ที่พัฒนาโดย Facebook ซึ่งมี helper มี function ต่างๆ ให้เราใช้ ทำให้ง่ายต่อการเขียน Test มากๆ สามารถเขียนเทสได้ทั้ง React, Vue, Angular หรือ JavaScript ทั่วๆไป เรียกว่ามือใหม่หัดเทส ก็เข้าใจได้ครับ
ข้อดีคือ
- ไม่ต้อง Config อะไรเลย แค่ติดตั้ง Jest
- มี Snapshop test
- Mock function หรือ spy ได้ (มีอธิบายเพิ่มเติมในบทความ)
Getting Started
เริ่มต้นโดยการสร้างโปรเจ็คขึ้นมาก่อน ผมใช้ ชื่อว่า jest-101
จากนั้น init มันด้วย npm
mkdir jest-101 && cd jest-101
npm init
จากนั้นทำการติดตั้ง Jest
npm install jest --save-dev
จากนั้นในส่วน scripts ของ package.json
เพิ่ม test ลงไป เพื่อรัน jest
{
"scripts": {
"test": "jest"
}
}
ทดสอบรัน test ง่ายๆด้วยคำสั่ง
npm test
จะได้ผลลัพธ์แบบนี้
2 files checked.
testMatch: **/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x) - 0 matches
testPathIgnorePatterns: /node_modules/ - 2 matches
testRegex: - 0 matches
หรือหากใครไม่เพิ่ม script ก็สามารถรันเทสผ่าน jest ตรงๆได้ด้วย (install global npm install -g jest
)
jest
จะได้ผลลัพธ์เหมือนกันครับ เพราะตอนนี้เรายังไม่มีทั้งโค๊ด และตัวเทส โฟลเดอร์เทสเรา จะไว้ในชื่อ __tests__
ก็ได้ หรือไว้ข้างนอกก็ได้เช่นกัน ขอแค่มีชื่อ .spec
หรือ .test
ต่อท้าย โดยเราสามารถตั้งชื่อได้โดยใช้ชื่อไฟล์ต่อท้าย เช่น app.test.js
หรือ app.spec.js
เริ่มต้น Test แรก
สมมติ เราได้ requirement มา ให้เขียน function สำหรับคำนวนเกรด จากคะแนนที่ได้ โดย
- requirement บอกว่า ทุกๆคนจะได้เกรด A ไม่ว่าจะได้คะแนนเท่าไหร่
ก็มาเริ่มโค๊ดกัน ผมเริ่มจากเขียนไฟล์ test ขึ้นมาก่อน ชื่อ app.test.js
มีโค๊ดดังนี้
const { getScore } = require('./app')
it('should get A', () => {
const result = getScore()
// Assertion
expect(result).toEqual('A')
})
และสร้างไฟล์ app.js
ขึ้นมาเปล่าๆก่อน ยังไม่มี function ครับ
ทดสอบรัน test ดู
npm test
จะไม่ผ่าน ❌ และมีผลลัพธ์แบบนี้
ต่อมาผมเขียน function getScore
ตาม requirement เพื่อ return A
ไม่ว่าจะได้คะแนนเท่าไหร่ แบบนี้
exports.getScore = score => 'A'
ทดสอบรัน Test อีกครั้ง Test เราผ่านแล้ว! ✅
it() และ test()
it(message, fn)
: เป็น function ที่รับ 2 arguments คือ string ที่เราต้องการบอกว่าจะ test อะไร และ function สำหรับ เทส โดยเราสามารถใช้ได้ทั้ง it()
และ test()
เช่น
it('should works', function () {})
// มีค่าเท่ากับ
it('should works', () => {})
// หรือ
test('works', () => {})
Matchers
Matchers เป็น function ที่เอาไว้เปรียบเทียบค่าที่เราต้องการเทส ตัวอย่างเช่น ด้านบน ที่ผมเทส getScore()
ผมใช้ toEqual()
เพื่อเปรียบเทียบว่า ถ้าเราเรียก function getScore()
เราจะได้ output เท่ากับ toEqual('A')
หรือเปล่า ซึ่ง matchers จะใช้คู่กับ expect()
นอกจาก toEqual()
ก็ยังมี matchers ต่างๆที่มีประโยชน์ ตัวอย่างเช่น
toBe
: เอาไว้เปรียบเทียบแบบ strict equal แบบใช้===
toBeNull
: ไว้เช็คกรณีnull
toMatch
: เอาไว้เช็ค string ว่า match RegEx มั้ยtoContain
: เอาไว้เช็คว่า array มีค่าที่ต้องการมั้ยtoThrow
: เอาไว้เช็คว่าโปรแกรม throw erro มั้ย
นอกจากนี้ ยังมี Matchers อื่นๆอีกเพียบ สามารถอ่านเพิ่มเติมได้ที่ Jest - Using Matchers
Add more test
ต่อมา Requirement ที่ได้เปลี่ยนครับ จากทุกๆคนไม่ว่าได้คะแนนเท่าไหร่ ก็ได้เกรด A ตอนนี้ เฉพาะคนได้ 50คะแนนขึ้นไปได้ A คนได้น้อยกว่า 50 ได้ F เราก็ต้องปรับ app.test.js
ใหม่ครับ โดยมี 2 tests แบบนี้
const { getScore } = require('./app')
it('should get grade', () => {
expect(getScore(50)).toEqual('A')
expect(getScore(10)).toEqual('F')
})
แน่นอนว่าไม่ผ่าน Test เพราะเรายังไม่ได้แก้ function ตอนนี้มัน return A
หมด ไม่ว่าจะใส่คะแนนไปเท่าไหร่
จาก test result เราจะเห็นสีเขียว และสีแดง และส่วน Expected "F"
และ Received "A"
ก็คือโค๊ดเรา expect ให้มัน return "F" แต่โค๊ดของเราเป็น "A" ฉะนั้นเราก็ต้องเปลี่ยนแปลงโค๊ดเรา เราก็มาแก้ app.js
ใหม่ เป็นแบบนี้ซะ เพื่อให้ผ่านเทส
exports.getScore = score => {
if (score < 50) {
return 'F'
}
return 'A'
}
ทีนี้ ตัว app.test.js
เราสามารถแยก it()
เป็น 2 กรณีก็ได้ เพื่อให้เข้าใจว่าเราจะเทสอะไร (ขึ้นอยู่กับเป้าหมายในการเทส ไม่มีตายตัว) เช่น แบบนี้ก็ไม่ผิด
const { getScore } = require('./app')
it('should get A', () => {
expect(getScore(50)).toEqual('A')
expect(getScore(100)).toEqual('A')
})
it('should get F', () => {
expect(getScore(1)).toEqual('F')
expect(getScore(49)).toEqual('F')
})
ลองรัน Test ใหม่ npm test
ต่อมา requirement เปลี่ยนอีกละ? ก็ทำการแก้โค๊ด และ เพิ่ม test case ไปอีก วนทำซ้ำไปเรื่อยๆ :)
Async Function
ด้านบนเป็น Example test ที่มันง่าย ไม่มีอะไรซ้ำซ้อน ต่อมา เรื่อง Async function ว่าเราจะเทสยังไง โดยปกติ function เราส่วนมาก หรือ Node.js ก็มักจะมี async เข้ามาเกี่ยวข้องใช่มั้ยครับ เราจะเทสมันยังไง
จริงๆก็ไม่ยากเลย สามารถใช้ async/await ช่วยได้ ตัวอย่างเช่น
it('should works with async', async () => {
const response = await fetchSomeData()
expect(response).toEqual('success')
})
หรือการใช้ resolves เช่น
it('should works with resolves', async () => {
await expect(fetchSomeData()).resolves.toEqual('success')
})
เพิ่มโค๊ด สำหรับ fetchSomeData()
ในไฟล์ app.js
โดย fetchSomeData
เป็น function ที่ return Promise และ callback เป็น success นะครับ
exports.fetchSomeData = () => new Promise(resolve => resolve('success'))
Mock Function
ต่อมาเรื่อง Mock function เราจะทำการ mock function ขึ้นมา เพื่ออะไร เพื่อเอาไว้เทส function โดยที่เราอยากเปลี่ยนแปลงค่าของ function ที่เราต้องการ เช่น fetchSomeData()
function จริงๆ มัน return success
เราอยากจะเปลี่ยนเป็น error
เป็น hello world
หรืออื่นๆ เพื่อเทส เราก็ใช้ mock ได้ โดยไม่จำเป็นต้องแก้ไขโค๊ดเราครับ
เราลอง Test แบบไม่ mock กันก่อนดีกว่า แล้วดูผลลัพธ์นะครับ โดยผมแก้ fetchSomeData()
นิดหน่อย ให้มัน delay ไว้ 3 วินาที (ไฟล์ app.js
)
exports.fetchSomeData = () => {
return new Promise(resolve => {
setTimeout(() => resolve('success'), 3000)
})
}
ลองรันเทสดูใหม่ จะเห็นว่า มันจะช้า เพราะต้อง รอ 3วิ 2เทส รวมแล้วก็ 6วินาทีกว่า
ในการเทสจริง เราจะไม่เรียก fetchSomeData()
ตรงๆ ลองคิดกรณีเปลี่ยนเป็นการยิง api หรือ fetch data
ถ้าเราไม่ mock เราจะเทสไม่ได้เลย กรณี
- เราไม่ได้ต่อ internet เพราะมันต้องไป fetch data
- เทสจะช้ามาก เพราะต้อง call api ทุกๆ test case
- เทสที่ไป connect db หรือไปเซฟค่า ก็ไม่ดีแน่ ถ้าทุกๆครั้งที่ run test แล้วไปกระทบ DB
- Unit Test ควรจะเทสแค่ function และการทำงานของมัน ไม่ควรมี action กับส่วนอื่นๆ ซึ่งมันจะกลายเป็น integrated test มากกว่า unit test
เราเลยต้องมีการ mock function แทน หรือแค่อยากเปลี่ยนผลลัพธ์ของ function เราก็แค่ mock ค่าที่จะส่งกลับ โดยไม่กระทบ function จริงที่เขียนเลย
jest.fn()
Jest มี mock function ให้เรียกใช้งานด้วยคำสั่ง jest.fn()
ตัวอย่าง เช่น
// app.test.js
const { fetchSomeData } = require('./app')
it('should works with jest.fn()', () => {
let myMock = fetchSomeData
myMock = jest.fn()
expect(myMock()).toEqual(undefined)
})
ด้านบน ผม assign myMock
ด้วย function fetchSomeData
จากนั้น ให้ myMock
เป็น mock function ด้วย jest.fn()
แล้วลอง match ดู จะเห็นว่า เวลาเรียก myMock()
มันจะไม่ได้ค่าเป็น "success" แบบที่ fetchSomeData()
ทำ
เปรียบเทียบกรณีไม่ได้ mock โดย assign ค่าไปไว้ที่ myMock
(คอมเม้น jest.fn()
ไว้อยู่)
it('should works with jest.fn()', async () => {
let myMock = fetchSomeData
// myMock = jest.fn()
await expect(myMock()).resolves.toEqual('success')
})
อยาก mock ว่าให้มัน return 'hello world' ถ้าเรียก fetchSomeData
ก็สามาถใช้ mockReturnValue
ได้ ตัวอย่างเช่น เทสนี้
it('should works with mockReturnValue', () => {
const myMock = jest.fn() // สร้าง myMock เป็น mock function
myMock.mockReturnValue('hello world') // mock ค่าตอน return ให้มัน
expect(myMock()).toEqual('hello world')
})
ส่วนกรณี Promise เราก็สามารถ mock ได้ด้วย mockResolvedValue
เช่นกัน
it('should works with jest.fn() and mockResolvedValue', async () => {
let myMock = fetchSomeData
myMock = jest.fn()
myMock.mockResolvedValue('success from mock data')
await expect(myMock()).resolves.toEqual('success from mock data')
})
จะเห็นว่า ผลลัพธ์เราจะไม่ได้เป็น success
แล้ว แต่จะเป็นค่าที่เราต้องการแทน และมันก็ไม่ได้ไปเรียก fetchSomeData
จริงๆ ทำให้ประหยัดเวลา รอไปได้ รวมถึง ถ้าเราอยากเทสเคสอื่นๆ เราก็ไม่จำเป็นต้องแก้ไข function จริงๆเลย ก็สามารถ เทสได้
ซึ่งจริงๆ Mock function ยังสามารถ mock ได้ทั้ง module, สร้าง folder mock รายละเอียดเผื่อใครอยากอ่านเพิ่มเติมได้ครับ Jest - Mock Functions
spyOn
spyOn จริงๆแล้วมันก็คล้ายๆกับ mock function ด้วย jest.fn()
แต่สามารถ tracking object ได้ รวมถึง restore mock หรือกลับไปใช้ function จริงที่ไม่ได้ mock ได้ รวมถึง spyOn ใน jest เป็น spied method (ซึ่งอาจจะแต่งตางจาก framework อื่นอยู่บ้าง)
ตัวอย่าง เช่น ผมมี function login()
เพื่อเช็คว่า fetchSomeData()
นั้น จะได้ response เป็น success มั้ย ถ้าได้ ให้ตอบ true ถ้าไม่ใช่ ตอบ false แบบนี้
spyOn()
ใน Jest จะไม่ได้ override implementation ภายใน method เราต้องทำการ mockImplementation หรือ mockValue เอง
exports.fetchSomeData = () => {
return new Promise(resolve => {
setTimeout(() => resolve('success'), 3000)
})
}
exports.login = async () => {
const response = await fetchSomeData()
return response === 'success'
}
ทีนี้ถ้าจะ test login
ผม implement แบบนี้ ไฟล์ app.test.js
const { getScore, login } = require('./app')
it('should login', async () => {
const isLoggedIn = await login()
expect(isLoggedIn).toBe(true)
})
ซึ่งใน login()
มันมีเรียก fetchSomeData()
ด้วย ซึ่งลองเทสแบบที่เราไมไ่ด้ mock หรือ spyOn อะไรทั้งสิ้น และดูผลลัพธ์
PASS ./app.test.js
✓ should login (3008ms)
มันมีการ call fetchSomeData()
จริงๆ เพื่อเทส (ใช้เวลาไป 3008ms) ซึ่งเราจะ Unit Test แค่เฉพาะ login()
ว่าเวิคมั้ย ไม่อยากให้ไป call ฟังค์ชั่นอื่น
นอกจากเรา mock function หรือ mock value แล้ว เราก็สามารถ spyOn คือตามชื่อเลย แอบ spy แอบจับตามองมัน (tracking) มัน สามารถรู้ได้ว่า function ที่เรา spy นะ ถูกเรียกไปกี่ครั้ง ถูก call ด้วยการส่ง argument อะไรไปบ้าง พวกนี้เป็นต้น
it('spyOn and custom response', async () => {
const spy = jest.spyOn(app, 'fetchSomeData').mockResolvedValue('fail')
const isLoggedIn = await app.login()
expect(isLoggedIn).toBe(false)
expect(spy).toHaveBeenCalled()
app.fetchSomeData.mockRestore()
})
ตัวอย่างการใช้ spyOn
เพื่อ spy ดูฟังค์ชั่น fetchSomeData
ถูก call ด้วย toHaveBennCalled()
เมื่อเรียก function login()
แถมไม่ได้ต่อ function จริง ใช้วิธี mock ให้มันเป็นค่า fail
ทำให้ isLoggedIn
เป็น false แน่นอน เทสก็เลยผ่าน
app.fetchSomeData.mockRestore()
- เอาไว้คืนค่าฟังค์ชั่นเดิม เนื่องจาก spyOn และ mock value มีผลต่อ test case อื่นด้วย ก็เลยต้อง reset ค่าเริ่มต้นเป็น function จริง
Setup & Tear down
โดยปกติเวลาเราเขียนเทส เรามักจะทำอะไรซ้ำๆกัน เช่น setup ทุกๆเทส setup ครั้งแรก เช่น create database, reset database อะไรพวกนี้เป็นต้น ตัว Jest ก็มี Setup & Teardown มาให้เราเช่นกัน
beforeAll(() => {
// call before all test
})
afterAll(() => {
// call after all test
})
หรือเราสามารถ setup แต่ละ test case ได้ ด้วย
beforeEach(() => {
// call every before test case
})
afterEach(() => {
// call every after test case
})
นอกจากนี้เรายังสามารถจัดกลุ่ม Test ด้วย describe()
ได้ เช่น
describe('score', () => {
it('should work', () => {})
})
describe('another group', () => {
it('should work', () => {})
})
ตัวอย่าง เช่น สร้าง describe
สำหรับ mock function จนไฟล์ app.test.js
เป็นแบบนี้
// const { getScore, login } = require('./app')
const app = require('./app')
const { fetchSomeData, getScore, login } = require('./app')
it('should get A', () => {
expect(getScore(50)).toEqual('A')
expect(getScore(100)).toEqual('A')
})
it('should get F', () => {
expect(getScore(1)).toEqual('F')
expect(getScore(49)).toEqual('F')
})
it('should login', async () => {
const isLoggedIn = await login()
expect(isLoggedIn).toBe(true)
})
it('should works with async', async () => {
const response = await fetchSomeData()
expect(response).toEqual('success')
})
it('should works with resolves', async () => {
await expect(fetchSomeData()).resolves.toEqual('success')
})
describe('mock functions', () => {
it('should works with jest.fn()', () => {
let myMock = fetchSomeData
myMock = jest.fn()
expect(myMock()).toEqual(undefined)
})
it('should works with mockReturnValue', () => {
const myMock = jest.fn() // สร้าง myMock เป็น mock function
myMock.mockReturnValue('hello world') // mock ค่าตอน return ให้มัน
expect(myMock()).toEqual('hello world')
})
it('should works with jest.fn() and mockResolvedValue', async () => {
let myMock = fetchSomeData
myMock = jest.fn()
myMock.mockResolvedValue('success from mock data')
await expect(myMock()).resolves.toEqual('success from mock data')
})
it('spyOn and custom response', async () => {
const spy = jest.spyOn(app, 'fetchSomeData').mockResolvedValue('fail')
const isLoggedIn = await app.login()
expect(isLoggedIn).toBe(false)
expect(spy).toHaveBeenCalled()
app.fetchSomeData.mockRestore()
})
})
สุดท้าย ลองรันเทสทั้งหมดดู ✅✅✅
Conclusion
สำหรับใครยังไม่เคยเขียนเทส หรือยังงงๆไม่รู้ว่าจะเริ่มยังไง หลังจากอ่านแล้ว ก็ลองทำความเข้าใจ ลองฝึกทำ ไม่ยากเกินความสามารถครับ แล้วการเขียน Test จะเป็นเรื่องสนุกและมองมันเป็นส่วนหนึ่งในโค๊ดของเราครับ ไม่ได้มองว่าต้องเขียนแยกระหว่าง feature กับ test หวังว่าบทความนี้จะมีประโยชน์ไม่มากก็น้อย บทความต่อไปๆ จะยกตัวอย่าง mock และ spyOn มากขึ้น และพา test กับ Vue.js และ React รอติดตามนะครับ :)
Happy Coding
- Authors
- Name
- Chai Phonbopit
- Website
- @Phonbopit