Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/api/mock.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,40 @@ const myMockFn = vi
console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn())
```

## mockThrow <Version>4.1.0</Version> {#mockthrow}

```ts
function mockThrow(value: unknown): Mock<T>
```

Accepts a value that will be thrown whenever the mock function is called.

```ts
const myMockFn = vi.fn()
myMockFn.mockThrow(new Error('error message'))
myMockFn() // throws Error<'error message'>
```

## mockThrowOnce <Version>4.1.0</Version> {#mockthrowonce}

```ts
function mockThrowOnce(value: unknown): Mock<T>
```

Accepts a value that will be thrown during the next function call. If chained, every consecutive call will throw the specified value.

```ts
const myMockFn = vi
.fn()
.mockReturnValue('default')
.mockThrowOnce(new Error('first call error'))
.mockThrowOnce('second call error')

expect(() => myMockFn()).toThrow('first call error')
expect(() => myMockFn()).toThrow('second call error')
expect(myMockFn()).toEqual('default')
```

## mock.calls

```ts
Expand Down
20 changes: 8 additions & 12 deletions packages/runner/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ async function callAroundHooks<THook extends Function>(
return
}

const hookErrors: unknown[] = []

const createTimeoutPromise = (
timeout: number,
phase: 'setup' | 'teardown',
Expand Down Expand Up @@ -352,23 +354,13 @@ async function callAroundHooks<THook extends Function>(
setupTimeout.clear()

// Run inner hooks - don't time this against our teardown timeout
let nextError: { value: unknown } | undefined
try {
await runNextHook(index + 1)
}
catch (value) {
nextError = { value }
}
await runNextHook(index + 1).catch(e => hookErrors.push(e))

// Start teardown timer after inner hooks complete - only times this hook's teardown code
teardownTimeout = createTimeoutPromise(timeout, 'teardown', stackTraceError)

// Signal that use() is returning (teardown phase starting)
resolveUseReturned()

if (nextError) {
throw nextError.value
}
}

// Start setup timeout
Expand Down Expand Up @@ -422,7 +414,11 @@ async function callAroundHooks<THook extends Function>(
}
}

await runNextHook(0)
await runNextHook(0).catch(e => hookErrors.push(e))

if (hookErrors.length > 0) {
throw hookErrors
}
}

async function callAroundAllHooks(
Expand Down
14 changes: 14 additions & 0 deletions packages/spy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,20 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock<Proce
})
}

mock.mockThrow = function mockThrow(value) {
// eslint-disable-next-line prefer-arrow-callback
return mock.mockImplementation(function () {
throw value
})
}

mock.mockThrowOnce = function mockThrowOnce(value) {
// eslint-disable-next-line prefer-arrow-callback
return mock.mockImplementationOnce(function () {
throw value
})
}

mock.mockResolvedValue = function mockResolvedValue(value) {
return mock.mockImplementation(function () {
if (new.target) {
Expand Down
22 changes: 22 additions & 0 deletions packages/spy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,28 @@ export interface MockInstance<T extends Procedure | Constructable = Procedure> e
* console.log(myMockFn(), myMockFn(), myMockFn())
*/
mockReturnValueOnce(value: MockReturnType<T>): this
/**
* Accepts a value that will be thrown whenever the mock function is called.
* @see https://vitest.dev/api/mock#mockthrow
* @example
* const myMockFn = vi.fn().mockThrow(new Error('error'))
* myMockFn() // throws 'error'
*/
mockThrow(value: unknown): this
/**
* Accepts a value that will be thrown during the next function call. If chained, every consecutive call will throw the specified value.
* @example
* const myMockFn = vi
* .fn()
* .mockReturnValue('default')
* .mockThrowOnce(new Error('first call error'))
* .mockThrowOnce('second call error')
*
* expect(() => myMockFn()).toThrowError('first call error')
* expect(() => myMockFn()).toThrowError('second call error')
* expect(myMockFn()).toEqual('default')
*/
mockThrowOnce(value: unknown): this
/**
* Accepts a value that will be resolved when the async function is called. TypeScript will only accept values that match the return type of the original function.
* @example
Expand Down
5 changes: 3 additions & 2 deletions packages/vitest/src/integrations/env/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { pathToFileURL } from 'node:url'
import { resolve } from 'pathe'
import { ModuleRunner } from 'vite/module-runner'
import { EvaluatedModules, ModuleRunner } from 'vite/module-runner'
import { VitestTransport } from '../../runtime/moduleRunner/moduleTransport'
import { environments } from './index'

Expand All @@ -24,6 +24,7 @@ export function createEnvironmentLoader(root: string, rpc: WorkerRPC): ModuleRun
if (!cachedLoader || cachedLoader.isClosed()) {
_loaders.delete(root)

const evaluatedModules = new EvaluatedModules()
const moduleRunner = new ModuleRunner({
hmr: false,
sourcemapInterceptor: 'prepareStackTrace',
Expand All @@ -46,7 +47,7 @@ export function createEnvironmentLoader(root: string, rpc: WorkerRPC): ModuleRun
async resolveId(id, importer) {
return rpc.resolve(id, importer, '__vitest__')
},
}),
}, evaluatedModules, new WeakMap()),
})
_loaders.set(root, moduleRunner)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export class Logger {
this.error(errorMessage)
errors.forEach((err) => {
this.printError(err, {
fullStack: true,
fullStack: (err as any).name !== 'EnvironmentTeardownError',
type: (err as any).type || 'Unhandled Error',
})
})
Expand Down
8 changes: 7 additions & 1 deletion packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ export class VitestModuleRunner
public mocker: VitestMocker
public moduleExecutionInfo: ModuleExecutionInfo
private _otel: Traces
private _callstacks: WeakMap<EvaluatedModuleNode, string[]>

constructor(private vitestOptions: VitestModuleRunnerOptions) {
const options = vitestOptions
const transport = new VitestTransport(options.transport)
const evaluatedModules = options.evaluatedModules
const callstacks = new WeakMap<EvaluatedModuleNode, string[]>()
const transport = new VitestTransport(options.transport, evaluatedModules, callstacks)
super(
{
transport,
Expand All @@ -64,6 +66,7 @@ export class VitestModuleRunner
},
options.evaluator,
)
this._callstacks = callstacks
this._otel = vitestOptions.traces || new Traces({ enabled: false })
this.moduleExecutionInfo = options.getWorkerState().moduleExecutionInfo
this.mocker = options.mocker || new VitestMocker(this, {
Expand Down Expand Up @@ -153,6 +156,9 @@ export class VitestModuleRunner
metadata?: SSRImportMetadata,
ignoreMock = false,
): Promise<any> {
// Track for a better error message if dynamic import is not resolved properly
this._callstacks.set(mod, callstack)

if (ignoreMock) {
return this._cachedRequest(url, mod, callstack, metadata)
}
Expand Down
29 changes: 25 additions & 4 deletions packages/vitest/src/runtime/moduleRunner/moduleTransport.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { FetchFunction, ModuleRunnerTransport } from 'vite/module-runner'
import type { EvaluatedModuleNode, EvaluatedModules, FetchFunction, ModuleRunnerTransport } from 'vite/module-runner'
import type { ResolveFunctionResult } from '../../types/general'
import { EnvironmentTeardownError } from '../utils'

export interface VitestTransportOptions {
fetchModule: FetchFunction
resolveId: (id: string, importer?: string) => Promise<ResolveFunctionResult | null>
}

export class VitestTransport implements ModuleRunnerTransport {
constructor(private options: VitestTransportOptions) {}
constructor(
private options: VitestTransportOptions,
private evaluatedModules: EvaluatedModules,
private callstacks: WeakMap<EvaluatedModuleNode, string[]>,
) {}

async invoke(event: any): Promise<{ result: any } | { error: any }> {
if (event.type !== 'custom') {
Expand All @@ -29,8 +34,24 @@ export class VitestTransport implements ModuleRunnerTransport {
const result = await this.options.fetchModule(...data as Parameters<FetchFunction>)
return { result }
}
catch (error) {
return { error }
catch (cause) {
if (cause instanceof EnvironmentTeardownError) {
const [id, importer] = data as Parameters<FetchFunction>
let message = `Cannot load '${id}'${importer ? ` imported from ${importer}` : ''} after the environment was torn down. `
+ `This is not a bug in Vitest.`

const moduleNode = importer ? this.evaluatedModules.getModuleById(importer) : undefined
const callstack = moduleNode ? this.callstacks.get(moduleNode) : undefined
if (callstack) {
message += ` The last recorded callstack:\n- ${[...callstack, importer, id].reverse().join('\n- ')}`
}
const error = new EnvironmentTeardownError(message)
if (cause.stack) {
error.stack = cause.stack.replace(cause.message, error.message)
}
return { error }
}
return { error: cause }
}
}
}
4 changes: 4 additions & 0 deletions packages/vitest/src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { getSafeTimers } from '@vitest/utils/timers'

const NAME_WORKER_STATE = '__vitest_worker__'

export class EnvironmentTeardownError extends Error {
name = 'EnvironmentTeardownError'
}

export function getWorkerState(): WorkerGlobalState {
// @ts-expect-error untyped global
const workerState = globalThis[NAME_WORKER_STATE]
Expand Down
3 changes: 2 additions & 1 deletion packages/vitest/src/runtime/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { setupInspect } from './inspector'
import * as listeners from './listeners'
import { VitestEvaluatedModules } from './moduleRunner/evaluatedModules'
import { onCancel, rpcDone } from './rpc'
import { EnvironmentTeardownError } from './utils'

const resolvingModules = new Set<string>()

Expand All @@ -21,7 +22,7 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC, worker: Vites
// do not close the RPC channel so that we can get the error messages sent to the main thread
cleanups.push(async () => {
await Promise.all(rpc.$rejectPendingCalls(({ method, reject }) => {
reject(new Error(`[vitest-worker]: Closing rpc while "${method}" was pending`))
reject(new EnvironmentTeardownError(`[vitest-worker]: Closing rpc while "${method}" was pending`))
}))
})

Expand Down
Loading
Loading