Jest คืออะไร? + เริ่มต้นเขียน Test ด้วย Jest กันดีกว่า

Jest คืออะไร? + เริ่มต้นเขียน Test ด้วย Jest กันดีกว่า

990฿คอร์สสอนสร้างเว็บไซต์ HTML/CSS สำหรับมือใหม่ + พร้อม Workshop


สวัสดีครับ วันนี้จะมาเขียนบทความเกี่ยวกับการเขียน 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

จะไม่ผ่าน ❌ และมีผลลัพธ์แบบนี้

Test first failed

ต่อมาผมเขียน function getScore ตาม requirement เพื่อ return A ไม่ว่าจะได้คะแนนเท่าไหร่ แบบนี้

exports.getScore = score => 'A'

ทดสอบรัน Test อีกครั้ง Test เราผ่านแล้ว! ✅

Test first pass

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')
})

Result

แน่นอนว่าไม่ผ่าน 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

Jest test 02

ต่อมา 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วินาทีกว่า

Jest test mock

ในการเทสจริง เราจะไม่เรียก 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()
  })
})

สุดท้าย ลองรันเทสทั้งหมดดู ✅✅✅

All test pass

Conclusion

สำหรับใครยังไม่เคยเขียนเทส หรือยังงงๆไม่รู้ว่าจะเริ่มยังไง หลังจากอ่านแล้ว ก็ลองทำความเข้าใจ ลองฝึกทำ ไม่ยากเกินความสามารถครับ แล้วการเขียน Test จะเป็นเรื่องสนุกและมองมันเป็นส่วนหนึ่งในโค๊ดของเราครับ ไม่ได้มองว่าต้องเขียนแยกระหว่าง feature กับ test หวังว่าบทความนี้จะมีประโยชน์ไม่มากก็น้อย บทความต่อไปๆ จะยกตัวอย่าง mock และ spyOn มากขึ้น และพา test กับ Vue.js และ React รอติดตามนะครับ :)

Happy Coding

Source Code

Chai Phonbopit

Chai Phonbopit: Software Engineer แห่งหนึ่ง • ผู้ชายธรรมดาๆ ที่ชื่นชอบ Node.js, JavaScript, React และ Open Source มีงานอดิเรกเป็น Acoustic Guitar และ Football นอกจากเขียนบล็อคที่เว็บนี้แล้ว ก็มีเขียนที่ https://medium.com/@Phonbopit ครับ