今天來看 useElementVisibility 的單元測試,測試中會用到 Day24 看到的 useElementVisibility 以及 Day23 的 useIntersectionObserver,可以參照著看,了解主要用法與細節。以下如果有跟上述 API 有關的程式,就稍微帶過就好~
測試結構:
// src/compositions/useElementVisibility.test.js
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { useElementVisibility } from '@/compositions/useElementVisibility'
describe('useElementVisibility', () => {
let el
beforeEach(() => {
el = document.createElement('div')
})
// 測試案例都會放在這個層級
})
在跑每個測試案例之前,會透過 beforeEach
把 el 設定成 div 元素。以下講的案例都會放在註解的層級。
// src/compositions/useElementVisibility.test.js
it('should work when el is not an element', async () => {
const visible = useElementVisibility(null)
expect(visible.value).toBeFalsy()
})
useElementVisibility 把 null 傳給 useIntersectionObserver,useIntersectionObserver 對 taret
參數做的處理:
// src/compositions/useIntersectionObserver.js
const targets = computed(() => {
const _target = toValue(target)
return (Array.isArray(_target) ? _target : [_target]).map(unrefElement).filter(notNullish)
})
// 在核心邏輯中有 if (!targets.length) return 的判斷,所以不會進到計算
filter(notNullish)
會把 null 值 filter 掉。
// src/compositions/useElementVisibility.test.js
it('should work when window is undefined', () => {
const visible = useElementVisibility(el, { window: null })
expect(visible.value).toBeFalsy()
})
useElementVisibility 把 { window: null }
傳給 useIntersectionObserver,useIntersectionObserver 對 window
參數做的處理:
// src/compositions/useElementVisibility.test.js
const isSupported = useSupported(() => window && 'IntersectionObserver' in window)
// isSupported.value 為 false 的話,不會成功建立 watch
// src/compositions/useElementVisibility.test.js
it('should work when threshold is undefined', () => {
const visible = useElementVisibility(el, { threshold: null })
expect(visible.value).toBeFalsy()
})
threshold 就沒有做什麼處理,會一路往下傳,傳給 IntersectionObserver 這個 Web API,看起來效果跟沒傳一樣。
接下來的案例都會做 useIntersectionObserver 的相關驗證,一樣會放在測試結構中註解的層級,不過有多包一層 describe
。先來看一下結構:
// src/compositions/useElementVisibility.test.js
// ...略
describe('when internally using useIntersectionObserver', async () => {
beforeAll(() => {
vi.resetAllMocks()
vi.mock('@/compositions/useIntersectionObserver')
})
const { useIntersectionObserver } = await import('@/compositions/useIntersectionObserver')
// 接下來的測試案例都會放在這個層級
})
這邊使用 beforeAll
在這個 describe 裡面的所以測試執行前,先 resetAllMocks 並且對 useIntersectionObserver 這個模組進行 mock。接下來對 useIntersectionObserver 用了動態載入 ,看起來是想確定等模組被 mock 完後才拿被 mock 過的 useIntersectionObserver 來測試。
// src/compositions/useElementVisibility.test.js
it('should call useIntersectionObserver internally', () => {
expect(useIntersectionObserver).toHaveBeenCalledTimes(0)
useElementVisibility(el)
expect(useIntersectionObserver).toHaveBeenCalledTimes(1)
})
這個案例滿單純的,測試 useElementVisibility 執行後,內部的 useIntersectionObserver 是否有被執行。
// src/compositions/useElementVisibility.test.js
it('passes the given element to useIntersectionObserver', () => {
useElementVisibility(el)
// for TS
// expect(vi.mocked(useIntersectionObserver).mock.lastCall?.[0]).toBe(el)
// for JS
expect(useIntersectionObserver.mock.lastCall?.[0]).toBe(el)
})
這邊原始碼是用 TS 版本的 vi.mocked
來做驗證,是 TS 型別 helper,可以觀察使用 vi.mocked
後,IDE 的提示變得很完整。
測試的部分是透過 lastCall[0]
來判斷最後一次呼叫時帶入的參數是否為 el
。
// src/compositions/useElementVisibility.test.js
it('passes a callback to useIntersectionObserver that sets visibility to false only when isIntersecting is false', () => {
const isVisible = useElementVisibility(el)
const callback = useIntersectionObserver.mock.lastCall?.[1]
const callMockCallbackWithIsIntersectingValue = isIntersecting => callback?.([{ isIntersecting, time: 1 }], {})
// It should be false initially
expect(isVisible.value).toBe(false)
// It should still be false if the callback doesn't get an isIntersecting = true
callMockCallbackWithIsIntersectingValue(false)
expect(isVisible.value).toBe(false)
// But it should become true if the callback gets an isIntersecting = true
callMockCallbackWithIsIntersectingValue(true)
expect(isVisible.value).toBe(true)
// And it should become false again if isIntersecting = false
callMockCallbackWithIsIntersectingValue(false)
expect(isVisible.value).toBe(false)
})
這段有點複雜,這邊透過 mock.lastCall?.[1]
取到我們傳給 useIntersectionObserver 的第二個參數,也就是 callback,這個 callback Day24 有提到:
// src/compositions/useElementVisibility.js
(intersectionObserverEntries) => {
let isIntersecting = elementIsVisible.value
// Get the latest value of isIntersecting based on the entry time
let latestTime = 0
for (const entry of intersectionObserverEntries) {
if (entry.time >= latestTime) {
latestTime = entry.time
isIntersecting = entry.isIntersecting
}
}
elementIsVisible.value = isIntersecting
},
接著看測試案例中的 const callMockCallbackWithIsIntersectingValue = isIntersecting => callback?.([{ isIntersecting, time: 1 }], {})
這段。
現在我們可以透過 callMockCallbackWithIsIntersectingValue(true)
來控制 intersectionObserverEntries
這個參數應該要是什麼樣子,這樣應該也比較清楚為什麽需要 time: 1
了,因為我們有 if (entry.time >= latestTime)
這個判斷,詳情可以再參考昨天的文章。
可以控制 callback 的 intersectionObserverEntries
參數後,就可以測試這個測試 isVisible
是否有正確切換。
// src/compositions/useElementVisibility.test.js
it('uses the latest version of isIntersecting when multiple intersection entries are given', () => {
const isVisible = useElementVisibility(el)
const callback = vi.mocked(useIntersectionObserver).mock.lastCall?.[1]
const callMockCallbackWithIsIntersectingValues = (...entries) => {
callback?.(entries, {})
}
// It should be false initially
expect(isVisible.value).toBe(false)
// It should take the latest value of isIntersecting
callMockCallbackWithIsIntersectingValues(
{ isIntersecting: false, time: 1 },
{ isIntersecting: false, time: 2 },
{ isIntersecting: true, time: 3 },
)
expect(isVisible.value).toBe(true)
// It should take the latest even when entries are out of order
callMockCallbackWithIsIntersectingValues(
{ isIntersecting: true, time: 1 },
{ isIntersecting: false, time: 3 },
{ isIntersecting: true, time: 2 },
)
expect(isVisible.value).toBe(false)
})
跟上面那個案例很像,一樣是透過 callMockCallbackWithIsIntersectingValues
來控制參數來達成我們測試目的。Day24 最後有花一些篇幅來討論為什麽原始碼實作要用 entry.time
來判斷,而不是直接拿第 0 筆來使用,因為 Intersection Observer API 的特性,要拿到最新的 entry 需要透過 entry.time 來判斷。細節可以在參考昨天那篇。
所以驗證 isVisible
最後的值都是 time 最大的那筆物件中的 isIntersecting
對應到的值,因為那筆才是最新狀態。
it('passes the given window to useIntersectionObserver', () => {
const mockWindow = {}
useElementVisibility(el, { window: mockWindow })
expect(vi.mocked(useIntersectionObserver).mock.lastCall?.[2]?.window).toBe(mockWindow)
})
it('uses the given scrollTarget as the root element in useIntersectionObserver', () => {
const mockScrollTarget = document.createElement('div')
useElementVisibility(el, { scrollTarget: mockScrollTarget })
expect(vi.mocked(useIntersectionObserver).mock.lastCall?.[2]?.root).toBe(mockScrollTarget)
})
這兩個測試很像,針對第三個 options 參數做測試,測試傳入的參數跟實際上被呼叫的是否相同。
GitHub PR:https://github.com/RhinoLee/30days_vue/pull/24/files
useElementVisibility 的單元測試到這邊告一段落,大部分案例都還滿單純,就後來那兩個控制 callback 的案例比較複雜一點,有時候會被 callback 弄到迷路 XD 而且這種測試方式要我自己想出來可能也沒辦法,又上了一課的感覺。
這幾天接連看了 useScroll API、useIntersectionObserver API、useElementVisibility API,都是為了接下來的 useInfiniteScroll API,明天就從這支 API 繼續~