首頁 > 軟體

詳解Jest 如何支援非同步及時間函數實現範例

2022-09-06 18:04:56

非同步支援

在前端開發中,我們會遇到很多非同步程式碼,那麼就需要測試框架對非同步必須支援,那如何支援呢?

Jest 支援非同步有兩種方式:回撥函數及 promise(async/await)

回撥函數 callback

const fetchUser = (cb) => {
  setTimeout(() => {
      cb('hello')
  }, 100)
}
// 必須要使用done,done表示執行done函數後,測試結束。如果沒有done,同步程式碼執行完後,測試就執行完了,測試不會等待非同步程式碼。
test('test callback', (done) => {
  fetchUser((data) => {
      expect(data).toBe('hello')
      done()
  })
})

需要注意的是,必須使用 done 來告訴測試用例什麼時候結束,即執行 done() 之後測試用例才結束。

promise

const userPromise = () => Promise.resolve('hello')
test('test promise', () => {
  // 必須要用return返回出去,否則測試會提早結束,也不會進入到非同步程式碼裡面進行測試
  return userPromise().then(data => {
    expect(data).toBe('hello')
  })
})
// async
test('test async', async () => {
  const data = await userPromise()
  expect(data).toBe('hello')
})

針對 promise,Jest 框架提供了一種簡化的寫法,即 expect 的resolvesrejects表示返回的結果:

const userPromise = () => Promise.resolve('hello')
test('test with resolve', () => {
  return expect(userPromise()).resolves.toBe('hello')
})
const rejectPromise = () => Promise.reject('error')
test('test with reject', () => {
  return expect(rejectPromise()).rejects.toBe('error')
})

Mock Timer

基本使用

假如現在有一個函數 src/utils/after1000ms.ts,它的作用是在 1000ms 後執行傳入的 callback

const after1000ms = (callback) => {
  console.log("準備計時");
  setTimeout(() => {
    console.log("午時已到");
    callback && callback();
  }, 1000);
};

如果不 Mock 時間,那麼我們就得寫這樣的用例:

describe("after1000ms", () => {
  it("可以在 1000ms 後自動執行函數", (done) => {
    after1000ms(() => {
      expect(...);
      done();
    });
  });
});

這樣我們得死等 1000 毫秒才能跑這完這個用例,這非常不合理,現在來看看官方的解決方法:

const fetchUser = (cb) => {
  setTimeout(() => {
      cb('hello')
  }, 1000)
}
// jest用來接管所有的時間函數
jest.useFakeTimers()
jest.spyOn(global, 'setTimeout')
test('test callback after one second', () => {
  const callback = jest.fn()
  fetchUser(callback)
  expect(callback).not.toHaveBeenCalled()
  // setTimeout被呼叫了,因為被jest接管了
  expect(setTimeout).toHaveBeenCalledTimes(1)
  expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000)
  // 跑完所有的時間函數
  jest.runAllTimers()
  expect(callback).toHaveBeenCalled()
  expect(callback).toHaveBeenCalledWith('hello')
})

runAllTimers是對所有的timer的進行執行,但是我們如果需要更細粒度的控制,可以使用 runOnlyPendingTimers:

const loopFetchUser = (cb: any) => {
  setTimeout(() => {
    cb('one')
    setTimeout(() => {
      cb('two')
    }, 2000)
  }, 1000)
}
jest.useFakeTimers()
jest.spyOn(global, 'setTimeout')
test('test callback in loop', () => {
  const callback = jest.fn()
  loopFetchUser(callback)
  expect(callback).not.toHaveBeenCalled()
  // jest.runAllTimers()
  // expect(callback).toHaveBeenCalledTimes(2)
  // 第一次時間函數呼叫完的時機
  jest.runOnlyPendingTimers()
  expect(callback).toHaveBeenCalledTimes(1)
  expect(callback).toHaveBeenCalledWith('one')
  // 第二次時間函數呼叫
  jest.runOnlyPendingTimers()
  expect(callback).toHaveBeenCalledTimes(2)
  expect(callback).toHaveBeenCalledWith('two')
})

我們還可以定義時間來控制程式的執行:

// 可以自己定義時間的前進,比如時間過去500ms後,函數呼叫情況
test('test callback with advance timer', () => {
  const callback = jest.fn()
  loopFetchUser(callback)
  expect(callback).not.toHaveBeenCalled()
  jest.advanceTimersByTime(500)
  jest.advanceTimersByTime(500)
  expect(callback).toHaveBeenCalledTimes(1)
  expect(callback).toHaveBeenCalledWith('one')
  jest.advanceTimersByTime(2000)
  expect(callback).toHaveBeenCalledTimes(2)
  expect(callback).toHaveBeenCalledWith('two')
})

模擬時鐘的機制

Jest 是如何模擬 setTimeout 等時間函數的呢?

我們從上面這個用例多少能猜得出:Jest  "好像"  用了一個陣列記錄 callback,然後在 jest.runAllTimers 時把陣列裡的 callback 都執行, 虛擬碼可能是這樣的:

setTimeout(callback) // Mock 的背後 -> callbackList.push(callback)
jest.runAllTimers() // 執行 -> callbackList.forEach(callback => callback())

可是話說回來,setTimeout 本質上不也是用一個 "小本本" 記錄這些 callback,然後在 1000ms 後執行的麼?

那麼,我們可以提出這樣一個猜想:呼叫 jest.useFakeTimers 時,setTimeout 並沒有把 callback 記錄到 setTimeout 的 "小本本" 上,而是記在了 Jest 的 "小本本" 上!

所以,callback 執行的時機也從  "1000ms 後"  變成了  Jest 執行 "小本本" 之時 。而 Jest 提供給我們的就是執行這個 "小本本" 的時機就是執行runAllTimers的時機。

典型案例

學過 Java 的同學都知道 Java 有一個 sleep 方法,可以讓程式睡上個幾秒再繼續做別的。雖然 JavaScript 沒有這個函數, 但我們可以利用 Promise 以及 setTimeout 來實現類似的效果。

const sleep = (ms: number) => {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  })
}

理論上,我們會這麼用:

console.log('開始'); // 準備
await sleep(1000); // 睡 1 秒
console.log('結束'); // 睡醒

在寫測試時,我們可以寫一個 act 內部函數來構造這樣的使用場景:

import sleep from "utils/sleep";
describe('sleep', () => {
  beforeAll(() => {
    jest.useFakeTimers()
    jest.spyOn(global, 'setTimeout')
  })
  it('可以睡眠 1000ms', async () => {
    const callback = jest.fn();
    const act = async () => {
      await sleep(1000)
      callback();
    }
    act()
    expect(callback).not.toHaveBeenCalled();
    jest.runAllTimers();
    expect(callback).toHaveBeenCalledTimes(1);
  })
})

上面的用例很簡單:在 "快進時間" 之前檢查 callback 沒有被呼叫,呼叫 jest.runAllTimers 後,理論上 callback 會被執行一次。

然而,當我們跑這個用例時會發現最後一行的 expect(callback).toHaveBeenCalledTimes(1); 會報錯,發現根本沒有呼叫,呼叫次數為0:

問題分析

這就涉及到 javascript 的事件迴圈機制了。

首先來複習下 async / await, 它是 Promise 的語法糖,async 會返回一個 Promise,而 await 則會把剩下的程式碼包裹在 then 的回撥裡,比如:

await hello()
console.log(1)
// 等同於
hello().then(() => {
  console.log(1)
})

重點:await後面的程式碼相當於放在promise.then的回撥中

這裡用了 useFakeTimers,所以 setTimeout 會替換成了 Jest 的 setTimeout(被 Jest 接管)。當執行 jest.runAllTimers()後,也就是執行resolve

const sleep = (ms: number) => {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  })
}

此時會把 await後面的程式碼推入到微任務佇列中。

然後繼續執行本次宏任務中的程式碼,即expect(callback).toHaveBeenCalledTimes(1),這時候callback肯定沒有執行。本次宏任務執行完後,開始執行微任務佇列中的任務,即執行callback

解決方法

describe('sleep', () => {
  beforeAll(() => {
    jest.useFakeTimers()
    jest.spyOn(global, 'setTimeout')
  })
  it('可以睡眠 1000ms', async () => {
    const callback = jest.fn()
    const act = async () => {
      await sleep(1000)
      callback()
    }
    const promise = act()
    expect(callback).not.toHaveBeenCalled()
    jest.runAllTimers()
    await promise
    expect(callback).toHaveBeenCalledTimes(1)
  })
})

async函數會返回一個promise,我們在promise前面加一個await,那麼後面的程式碼就相當於:

await promise
expect(callback).toHaveBeenCalledTimes(1)
等價於
promise.then(() => {
    expect(callback).toHaveBeenCalledTimes(1)
})

所以,這個時候就能正確的測試。

總結

Jest 對於非同步的支援有兩種方式:回撥函數和promise。其中回撥函數執行後,後面必須執行done函數,表示此時測試才結束。同理,promise的方式必須要通過return返回。

Jest 對時間函數的支援是接管真正的時間函數,把回撥函數新增到一個陣列中,當呼叫runAllTimers()時就執行陣列中的回撥函數。

最後通過一個典型案例,結合非同步和setTimeout來實踐真實的測試。

以上就是詳解Jest 如何支援非同步及時間函數實現範例的詳細內容,更多關於Jest 支援非同步時間函數的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com