เริ่มต้นที่ ทำความเข้าใจเกี่ยวกับ Test Double กันก่อน

สรุปเกี่ยวกับ Test Double
มัน คือ เทคนิคการเขียนโปรแกรมที่ใช้สร้าง ”ตัวแทน” ของ object จริง เพื่อใช้งานในการเขียน unit test ให้นึกภาพเป็น ”สตั้นแมน” ในภาพยนตร์ก็ได้ ที่เข้ามาแทนพระเอกในฉากเสี่ยงๆ Test double ก็เหมือนกัน มันจะทำหน้าที่เป็นตัวแทนของ object ที่เราไม่

Mock

ใน jest นั้น mock จะทำหน้านี้เป็นทั้ง mock, stub และ dummy ไปเลย แบบจบในตัวมันเลย เพราะฉนั้นหากเราพูดถึง mock ใน jest มันก็ คือ รวมทั้งหมดของ test double เลย ยกเว้น spy ไว้ตัวนึงที่ใน jest มีให้

ปกติแล้วเราจะใช้ mock เมื่อต้องการให้ code ของเราสามารถทำงานได้ โดยไม่ต้อง ไปเรียกใช้งาน function จริงๆ จากไฟล์อื่นๆ

มาลอง mock function กัน


Mocking function

สมมติว่าเรามีไฟล์ calculate.ts ที่มีการไปเรียกใช้งานฟังก์ชั่น add() จาก ไฟล์ math.ts อีกทีนึง

โดยเราจะเขียนโค๊ดง่ายๆ เลย คือ ให้ฟังก์ชั่น doubleSum() รับค่า x และ y จากนั้นไปเรียกใช้งานฟังก์ชั่น add() ที่อยู่ในไฟล์ math.ts แล้วเอาค่าที่ได้จาก add() มาบวกกันอีกที

import { add } from '../math'

export const doubleSum = (x: number, y: number): number => {
  const sum = add(x, y)
  return x + y + sum
}

calculate.ts

export const add = (x: number, y: number): number => x + y

math.ts

ทีนี้หากเราต้องการที่จะเขียนการทดสอบ เพื่อทดสอบ doubleSum() ก็สามารถเรียกใช้งานฟังก์ชั่น doubleSum() ได้ เลย

import { doubleSum } from './calculate'

test('should return 6 when call doubleSum and send 1 and 2', () => {
  const numberOne = 1
  const numberTwo = 2
  // 6 = 1 + 2 + 1 + 2
  const expected = 6 

  const result = doubleSum(numberOne, numberTwo)

  expect(result).toBe(expected)

})

calculate.test.ts

เวลาทำงานจริง ถ้าโค๊ดเราตรงมาแบบนี้มันก็ดีสิ แต่ในความเป็นจริงๆ แล้ว เราต้องระบุ scope ให้ได้ก่อนว่าเราต้องการจะทดสอบอะไร ตรงไหน

ในตัวอย่างๆ จริงๆ แล้วเราต้องการทดสอบว่า ฟังก์ชั่น doubleSum() นั้นทำงานได้ถูกต้องหรือเปล่า โดยเราไม่ได้สนใจว่า ฟังก์ชั่น add() ของ math.ts นั้นทำงานถูกต้องไหม เพราะเราได้ทดสอบมันแยกไปแล้ว

เราจึงจำเป็นที่ต้องตัดการทำงานของ add() ออก เพื่อจะทดสอบให้แน่ใจว่า code ในฟังก์ชั่น doubleSum() ทำงานได้ถูกต้องไหม

import { add } from '../math'

export const doubleSum = (x: number, y: number): number => {
  // ...
  return x + y + sum // เราสนใจตรงนี้
}

ในส่วนนี้แหละ mock จะเข้ามามีส่วนร่วมกับการทดสอบ

เริ่ม mock function ทายใน math.ts กัน

jest.mock('../../../src/function/math', () => ({
  add: jest.fn()
}))

mock math.ts

หากเราต้องการกำหนดค่าที่จะให้ add() return กลับไป ก็สามารถใช้ jest.fn().mockReturnValue(ค่าที่ต้องการให้ return) ได้ หรือจะใช้ jest.fn().mockImplementation((x, y) => x + y)

import { doubleSum } from './calculate'

// Implement in Mock
jest.mock('../../../src/function/math', () => ({
  add: jest.fn().mockImplementation((x, y) => {
    if (x === 1 && y === 2) return 20

    return x + y
  })
}))

describe('Basic Mock solution 1', () => {
  afterEach(() => {
    jest.restoreAllMocks()
  })

  test('should return 23 when call doubleSum and send 1 and 2', () => {
    const numberOne = 1
    const numberTwo = 2
    // if not mock return value (1 + 2) + (1 + 2) = 6
    const expected = 23
  
    const result = doubleSum(numberOne, numberTwo)
  
    expect(result).toBe(expected)
  })
  
  test('should return 23 when call doubleSum and send 10 and 2', () => {
    const numberOne = 10
    const numberTwo = 2
    const expected = 24
  
    const result = doubleSum(numberOne, numberTwo)
  
    expect(result).toBe(expected)
  })
})

calculate.test.ts

หากเราต้องการที่จะ implement ตัว return ใน mock ของเราให้แยกเป็นเป็นแต่ละการทดสอบเลย เราสามารถทำได้แบบนี้

import { doubleSum } from './calculate'

// 1. Import Math.ts
import * as math from './math'

// 2. Create mock
jest.mock('../../../src/function/math')

describe('Basic Mock solution 2', () => {
  test('should return 20 when call doubleSum and send 1 and 2', () => {
    // Arrange
    const numberOne = 1
    const numberTwo = 2
    const expected = 20

    // 3. Implement Mock in it.
    const addMock = math.add as jest.Mock
    addMock.mockReturnValue(17)

    const total = doubleSum(numberOne, numberTwo)

    expect(addMock).toHaveBeenCalled()
    expect(total).toBe(expected)
  })

  test('should return 110 when call doubleSum and send 1 and 2', () => {
    // Arrange
    const numberOne = 99
    const numberTwo = 1
    const expected = 100

    // 3. Implement Mock in test
    const addMock = math.add as jest.Mock
    addMock.mockReturnValue(0)

    const total = doubleSum(numberOne, numberTwo)

    expect(addMock).toHaveBeenCalled()
    expect(total).toBe(expected)
  })
})

mock add() in test

หาเราใช้ javascript ก็ไม่จำเป็นต้องใส่ as jest.Mock ต่อท้าย const addMock = math.add

ถ้าอยากตรวจสอบว่า addMock มีการเรียกใช้งานหรือเปล่าให้ใช้ expect(addMock).toHaveBeenCalled()

ตรวจสอบว่า addMock มีการเรียกที่ครั้ง ให้ใช้ expect(addMock).toHaveBeenCalledTimes(1) และต้องเพิ่ม jest.restoreAllMocks() เข้าไปด้วย เพื่อให้มันเคลียค่า mock ที่เก็บไว้ในแต่ละครั้ง ไม่อย่างอย่างนั้นมันจะนับจำนวนครั้งไปเรื่อยๆ

  afterEach(() => {
    jest.restoreAllMocks()
  })

restore mock


Mock library

นอกจากที่จะ mock ฟังก์ชั่นที่เราได้เขียนขึ้นเองแล้ว บางครั้งเราอาจจำเป็นต้อง mock library บางตัวเพื่อคุม output ที่ได้จาก library ที่ใช้งานอยู่ mock ใน jest นั้นก็สามารถทำได้เหมือนกัน

เช่น สมมติว่า เรามีฟังก์ชั่น getUserByUserName() ที่มีการเรียกใช้งานฟังก์ชั่น findIndex() ที่เป็นของ lodash แบบนี้

import _ from 'lodash'

export const getUserByUserName = (keyword: string) => {
  var users = [
    { user: 'barney', birthday: '1980-01-01', age: 47 },
    { user: 'fred', birthday: '1990-01-01', age: 37 },
    { user: 'pebbles', birthday: '2000-01-01', age: 27 }
  ]

  const index = _.findIndex(users, (o) => {
    return o.user == keyword
  })

  return users[index]
}

user.ts

ที่นี้สมมติว่าเราไม่ต้องการให้ getUserByUserName() ไม่ต้องไปเรียก findIndex() จริง และกำหนดให้ return ค่าที่เราต้องการใช้ในแต่ละชุดการทดสอบเลย

import _ from 'lodash'
import { getUserByUserName } from './user'

// Normal without mock will throw error
// jest.mock('lodash')

describe('mock', () => {
  afterEach(() => {
    jest.restoreAllMocks()
  })

  // 1. Normal without mock
  it('Should be index from keyword without mock', () => {
    const keyword = 'barney'

    const actual = getUserByUserName(keyword)

    expect(actual).toMatchObject({
      user: 'barney',
      birthday: '1980-01-01',
      age: 47
    })
  })

  // 2. With mock
  it('Should be index from keyword with mock', () => {
    const keyword = 'barney'

    // Mock lodash
    jest.mock('lodash')
    const mockFindIndex = jest.fn().mockReturnValue(2) // pebbles
    _.findIndex = mockFindIndex // Override lodash (optional, if needed)

    const actual = getUserByUserName(keyword)

    expect(mockFindIndex).toHaveBeenCalled()
    expect(actual).toMatchObject({
      user: 'pebbles',
      birthday: '2000-01-01',
      age: 27
    })
  })
})

user.test.ts

นี้เป็นตัวอย่างการ mock อย่างง่ายๆ และ mock นั้นมีฟังก์ชั่นให้ได้ลองใช้กันอีกเยอะสามารถเข้าไปดูได้ที่ document ของ jest ได้เลย

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

การใช้งาน spy ใน Jest
จากบทความที่แล้ว ที่เป็นเรื่องของ mock function ใน jest การใช้งาน Mock ใน Jestเริ่มต้นที่ ทำความเข้าใจเกี่ยวกับ Test Double กันก่อน สรุปเกี่ยวกับ Test Doubleมัน คือ เทคนิคการเขียนโปรแกรมที่ใช้สร้าง ”ตัวแทน” ของ object จริง เพื่อใช้