跳到內容

模擬

撰寫測試時,您遲早會需要建立內部或外部服務的「假」版本。這通常稱為模擬。Vitest 提供實用函式,透過其vi 輔助程式來協助您。您可以import { vi } from 'vitest'全域性存取它(當全域設定啟用時)。

警告

在每次測試執行前後,請務必清除或還原模擬,以撤銷執行之間的模擬狀態變更!有關更多資訊,請參閱 mockReset 文件。

如果您想深入了解,請查看 API 部分,否則請繼續閱讀以深入了解模擬的世界。

日期

有時您需要控制日期,以確保測試時的一致性。Vitest 使用 @sinonjs/fake-timers 套件來操作計時器以及系統日期。您可以在 這裡 詳細瞭解特定 API。

範例

js
import { , , , , ,  } from 'vitest'

const  = [9, 17]

function () {
  const  = new ().()
  const [, ] = 

  if ( >  &&  < )
    return { : 'Success' }

  return { : 'Error' }
}

('purchasing flow', () => {
  (() => {
    // tell vitest we use mocked time
    .()
  })

  (() => {
    // restoring date after each test run
    .()
  })

  ('allows purchases within business hours', () => {
    // set hour within business hours
    const  = new (2000, 1, 1, 13)
    .()

    // access Date.now() will result in the date set above
    (()).({ : 'Success' })
  })

  ('disallows purchases outside of business hours', () => {
    // set hour outside business hours
    const  = new (2000, 1, 1, 19)
    .()

    // access Date.now() will result in the date set above
    (()).({ : 'Error' })
  })
})

函式

模擬函式可以分成兩個不同的類別:偵測與模擬

有時您只需要驗證特定函式是否已被呼叫(以及可能傳遞哪些參數)。在這些情況下,我們只需要偵測,您可以直接使用 vi.spyOn()在此處閱讀更多資訊)。

然而,偵測只能幫助您偵測函式,它們無法變更這些函式的實作。在我們確實需要建立函式的偽造(或模擬)版本時,可以使用 vi.fn()在此處閱讀更多資訊)。

我們使用 Tinyspy 作為模擬函式的基礎,但我們有自己的包裝器使其相容於 jestvi.fn()vi.spyOn() 共用相同的方法,但只有 vi.fn() 的傳回結果可以呼叫。

範例

js
import { , , , ,  } from 'vitest'

function ( = .. - 1) {
  return .[]
}

const  = {
  : [
    { : 'Simple test message', : 'Testman' },
    // ...
  ],
  , // can also be a `getter or setter if supported`
}

('reading messages', () => {
  (() => {
    .()
  })

  ('should get the latest message with a spy', () => {
    const  = .(, 'getLatest')
    (.()).('getLatest')

    (.()).(
      .[.. - 1],
    )

    ().(1)

    .(() => 'access-restricted')
    (.()).('access-restricted')

    ().(2)
  })

  ('should get with a mock', () => {
    const  = .().()

    (()).(.[.. - 1])
    ().(1)

    .(() => 'access-restricted')
    (()).('access-restricted')

    ().(2)

    (()).(.[.. - 1])
    ().(3)
  })
})

更多

全域變數

您可以使用 vi.stubGlobal 輔助函式來模擬不存在於 jsdomnode 中的全域變數。它會將全域變數的值放入 globalThis 物件中。

ts
import {  } from 'vitest'

const  = .(() => ({
  : .(),
  : .(),
  : .(),
  : .(),
}))

.('IntersectionObserver', )

// now you can access it as `IntersectionObserver` or `window.IntersectionObserver`

模組

模擬模組會觀察在其他程式碼中呼叫的第三方函式庫,讓您可以測試參數、輸出,甚至重新宣告其實作。

請參閱 vi.mock() API 區段,以取得更深入的詳細 API 說明。

自動模擬演算法

如果你的程式碼正在匯入模擬的模組,而沒有任何相關的 __mocks__ 檔案或此模組的 factory,Vitest 會透過呼叫模組並模擬每個匯出,來模擬模組本身。

適用以下原則

  • 所有陣列都將清空
  • 所有基本型別和集合都將保持不變
  • 所有物件都將深度複製
  • 類別及其原型的所有執行個體都將深度複製

虛擬模組

Vitest 支援模擬 Vite 虛擬模組。它的運作方式與 Jest 中虛擬模組的處理方式不同。你不需要傳遞 virtual: truevi.mock 函式,而是需要告訴 Vite 該模組存在,否則它會在解析期間失敗。你可以透過以下幾種方式來做到這一點

  1. 提供別名
ts
// vitest.config.js
export default {
  test: {
    alias: {
      '$app/forms': resolve('./mocks/forms.js')
    }
  }
}
  1. 提供一個解析虛擬模組的外掛程式
ts
// vitest.config.js
export default {
  plugins: [
    {
      name: 'virtual-modules',
      resolveId(id) {
        if (id === '$app/forms')
          return 'virtual:$app/forms'
      }
    }
  ]
}

第二種方法的好處是你可以動態建立不同的虛擬進入點。如果你將多個虛擬模組重新導向到單一檔案,則所有模組都將受到 vi.mock 的影響,因此請務必使用唯一的識別碼。

模擬陷阱

請注意,無法模擬在同一個檔案的其他方法內呼叫的方法。例如,在以下程式碼中

ts
export function foo() {
  return 'foo'
}

export function foobar() {
  return `${foo()}bar`
}

無法從外部模擬 foo 方法,因為它被直接參照。因此,此程式碼不會對 foobar 內部的 foo 呼叫產生影響(但它會影響其他模組中的 foo 呼叫)

ts
import { vi } from 'vitest'
import * as mod from './foobar.js'

// this will only affect "foo" outside of the original module
vi.spyOn(mod, 'foo')
vi.mock('./foobar.js', async (importOriginal) => {
  return {
    ...await importOriginal<typeof import('./foobar.js')>(),
    // this will only affect "foo" outside of the original module
    foo: () => 'mocked'
  }
})

你可以透過直接提供實作給 foobar 方法來確認此行為

ts
// foobar.test.js
import * as mod from './foobar.js'

vi.spyOn(mod, 'foo')

// exported foo references mocked method
mod.foobar(mod.foo)
ts
// foobar.js
export function foo() {
  return 'foo'
}

export function foobar(injectedFoo) {
  return injectedFoo !== foo // false
}

這是預期的行為。當模擬以這種方式進行時,通常表示程式碼不良。考慮將你的程式碼重構為多個檔案,或透過使用 依賴性注入 等技術來改善你的應用程式架構。

範例

js
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Client } from 'pg'
import { failure, success } from './handlers.js'

// handlers
export function success(data) {}
export function failure(data) {}

// get todos
export async function getTodos(event, context) {
  const client = new Client({
    // ...clientOptions
  })

  await client.connect()

  try {
    const result = await client.query('SELECT * FROM todos;')

    client.end()

    return success({
      message: `${result.rowCount} item(s) returned`,
      data: result.rows,
      status: true,
    })
  }
  catch (e) {
    console.error(e.stack)

    client.end()

    return failure({ message: e, status: false })
  }
}

vi.mock('pg', () => {
  const Client = vi.fn()
  Client.prototype.connect = vi.fn()
  Client.prototype.query = vi.fn()
  Client.prototype.end = vi.fn()

  return { Client }
})

vi.mock('./handlers.js', () => {
  return {
    success: vi.fn(),
    failure: vi.fn(),
  }
})

describe('get a list of todo items', () => {
  let client

  beforeEach(() => {
    client = new Client()
  })

  afterEach(() => {
    vi.clearAllMocks()
  })

  it('should return items successfully', async () => {
    client.query.mockResolvedValueOnce({ rows: [], rowCount: 0 })

    await getTodos()

    expect(client.connect).toBeCalledTimes(1)
    expect(client.query).toBeCalledWith('SELECT * FROM todos;')
    expect(client.end).toBeCalledTimes(1)

    expect(success).toBeCalledWith({
      message: '0 item(s) returned',
      data: [],
      status: true,
    })
  })

  it('should throw an error', async () => {
    const mError = new Error('Unable to retrieve rows')
    client.query.mockRejectedValueOnce(mError)

    await getTodos()

    expect(client.connect).toBeCalledTimes(1)
    expect(client.query).toBeCalledWith('SELECT * FROM todos;')
    expect(client.end).toBeCalledTimes(1)
    expect(failure).toBeCalledWith({ message: mError, status: false })
  })
})

要求

由於 Vitest 在 Node 中執行,模擬網路要求很棘手;網路 API 不可使用,因此我們需要一些東西來模擬我們的網路行為。我們建議使用 Mock Service Worker 來達成此目的。它將允許您模擬 RESTGraphQL 網路要求,而且與架構無關。

Mock Service Worker (MSW) 的運作方式是攔截測試所發出的要求,讓您無需變更任何應用程式碼即可使用它。在瀏覽器中,這會使用 Service Worker API。在 Node.js 中,以及對於 Vitest,它使用 @mswjs/interceptors 函式庫。若要深入了解 MSW,請閱讀他們的 簡介

設定

您可以在 設定檔案 中像下方這樣使用它

js
import { , ,  } from 'vitest'
import {  } from 'msw/node'
import { , ,  } from 'msw'

const  = [
  {
    : 1,
    : 1,
    : 'first post title',
    : 'first post body',
  },
  // ...
]

export const  = [
  .get('https://rest-endpoint.example/path/to/posts', () => {
    return .json()
  }),
]

const  = [
  .query('ListPosts', () => {
    return .json(
      {
        : {  },
      },
    )
  }),
]

const  = (..., ...)

// Start server before all tests
(() => .listen({ : 'error' }))

//  Close server after all tests
(() => .close())

// Reset handlers after each test `important for test isolation`
(() => .resetHandlers())

使用 onUnhandleRequest: 'error' 設定伺服器可確保在沒有對應要求處理常式的要求出現時擲回錯誤。

範例

我們有一個使用 MSW 的完整工作範例:使用 MSW 進行 React 測試

更多

MSW 還有更多功能。您可以存取 cookie 和查詢參數、定義模擬錯誤回應,以及更多功能!若要查看您使用 MSW 能做的一切,請閱讀 其文件

計時器

當我們測試涉及逾時或間隔的程式碼時,我們可以使用模擬呼叫 setTimeoutsetInterval 的「假」計時器來加快測試速度,而不必讓我們的測試等待或逾時。

請參閱 vi.useFakeTimers API 區段,以取得更深入的詳細 API 說明。

範例

js
import { , , , , ,  } from 'vitest'

function () {
  (, 1000 * 60 * 60 * 2) // 2 hours
}

function () {
  (, 1000 * 60) // 1 minute
}

const  = .(() => .('executed'))

('delayed execution', () => {
  (() => {
    .()
  })
  (() => {
    .()
  })
  ('should execute the function', () => {
    ()
    .()
    ().(1)
  })
  ('should not execute the function', () => {
    ()
    // advancing by 2ms won't trigger the func
    .(2)
    ()..()
  })
  ('should execute every minute', () => {
    ()
    .()
    ().(1)
    .()
    ().(2)
  })
})

秘笈

資訊

以下範例中的 vi 是直接從 vitest 匯入的。您也可以在 設定檔 中將 globals 設為 true,以在全域使用它。

我想…

偵測 方法

ts
const instance = new SomeClass()
vi.spyOn(instance, 'method')

模擬匯出的變數

js
// some-path.js
export const getter = 'variable'
ts
// some-path.test.ts
import * as exports from './some-path.js'

vi.spyOn(exports, 'getter', 'get').mockReturnValue('mocked')

模擬匯出的函式

  1. 使用 vi.mock 的範例

警告

請記住,vi.mock 呼叫會提升到檔案頂端。它將永遠在所有匯入之前執行。

ts
// ./some-path.js
export function method() {}
ts
import { method } from './some-path.js'

vi.mock('./some-path.js', () => ({
  method: vi.fn()
}))
  1. 使用 vi.spyOn 的範例
ts
import * as exports from './some-path.js'

vi.spyOn(exports, 'method').mockImplementation(() => {})

模擬匯出的類別實作

  1. 使用 vi.mock.prototype 的範例
ts
// some-path.ts
export class SomeClass {}
ts
import { SomeClass } from './some-path.js'

vi.mock('./some-path.js', () => {
  const SomeClass = vi.fn()
  SomeClass.prototype.someMethod = vi.fn()
  return { SomeClass }
})
// SomeClass.mock.instances will have SomeClass
  1. 使用 vi.mock 和回傳值的範例
ts
import { SomeClass } from './some-path.js'

vi.mock('./some-path.js', () => {
  const SomeClass = vi.fn(() => ({
    someMethod: vi.fn()
  }))
  return { SomeClass }
})
// SomeClass.mock.returns will have returned object
  1. 使用 vi.spyOn 的範例
ts
import * as exports from './some-path.js'

vi.spyOn(exports, 'SomeClass').mockImplementation(() => {
  // whatever suites you from first two examples
})

偵測函式回傳的物件

  1. 使用快取的範例
ts
// some-path.ts
export function useObject() {
  return { method: () => true }
}
ts
// useObject.js
import { useObject } from './some-path.js'

const obj = useObject()
obj.method()
ts
// useObject.test.js
import { useObject } from './some-path.js'

vi.mock('./some-path.js', () => {
  let _cache
  const useObject = () => {
    if (!_cache) {
      _cache = {
        method: vi.fn(),
      }
    }
    // now every time that useObject() is called it will
    // return the same object reference
    return _cache
  }
  return { useObject }
})

const obj = useObject()
// obj.method was called inside some-path
expect(obj.method).toHaveBeenCalled()

模擬模組的一部分

ts
import { mocked, original } from './some-path.js'

vi.mock('./some-path.js', async (importOriginal) => {
  const mod = await importOriginal<typeof import('./some-path.js')>()
  return {
    ...mod,
    mocked: vi.fn()
  }
})
original() // has original behaviour
mocked() // is a spy function

模擬目前的日期

若要模擬 Date 的時間,可以使用 vi.setSystemTime 輔助函式。此值不會在不同的測試之間自動重設。

請注意,使用 vi.useFakeTimers 也會變更 Date 的時間。

ts
const mockDate = new Date(2022, 0, 1)
vi.setSystemTime(mockDate)
const now = new Date()
expect(now.valueOf()).toBe(mockDate.valueOf())
// reset mocked time
vi.useRealTimers()

模擬全域變數

您可以透過將值指定給 globalThis 或使用 vi.stubGlobal 輔助函式來設定全域變數。使用 vi.stubGlobal 時,它不會在不同的測試之間自動重設,除非您啟用 unstubGlobals 設定檔選項或在 beforeEach 鉤子中呼叫 vi.unstubAllGlobals

ts
vi.stubGlobal('__VERSION__', '1.0.0')
expect(__VERSION__).toBe('1.0.0')

模擬 import.meta.env

  1. 若要變更環境變數,您只需指定一個新值給它即可。

警告

環境變數值不會在不同的測試之間自動重設。

ts
import { beforeEach, expect, it } from 'vitest'

// you can reset it in beforeEach hook manually
const originalViteEnv = import.meta.env.VITE_ENV

beforeEach(() => {
  import.meta.env.VITE_ENV = originalViteEnv
})

it('changes value', () => {
  import.meta.env.VITE_ENV = 'staging'
  expect(import.meta.env.VITE_ENV).toBe('staging')
})
  1. 如果您想要自動重設值,可以使用 vi.stubEnv 輔助函式,並啟用 unstubEnvs 設定檔選項(或在 beforeEach 鉤子中手動呼叫 vi.unstubAllEnvs
ts
import { expect, it, vi } from 'vitest'

// before running tests "VITE_ENV" is "test"
import.meta.env.VITE_ENV === 'test'

it('changes value', () => {
  vi.stubEnv('VITE_ENV', 'staging')
  expect(import.meta.env.VITE_ENV).toBe('staging')
})

it('the value is restored before running an other test', () => {
  expect(import.meta.env.VITE_ENV).toBe('test')
})
ts
// vitest.config.ts
export default {
  test: {
    unstubAllEnvs: true,
  }
}