diff --git a/docs/guide/mocking.md b/docs/guide/mocking.md index 4194290209f7..133baea6081c 100644 --- a/docs/guide/mocking.md +++ b/docs/guide/mocking.md @@ -537,21 +537,135 @@ describe('delayed execution', () => { }) ``` -## Cheat Sheet +## Classes -:::info -`vi` in the examples below is imported directly from `vitest`. You can also use it globally, if you set `globals` to `true` in your [config](/config/). +You can mock an entire class with a single `vi.fn` call - since all classes are also functions, this works out of the box. Beware that currently Vitest doesn't respect the `new` keyword so the `new.target` is always `undefined` in the body of a function. + +```ts +class Dog { + name: string + + constructor(name: string) { + this.name = name + } + + static getType(): string { + return 'animal' + } + + speak(): string { + return 'bark!' + } + + isHungry() {} + feed() {} +} +``` + +We can re-create this class with ES5 functions: + +```ts +const Dog = vi.fn(function (name) { + this.name = name +}) + +// notice that static methods are mocked directly on the function, +// not on the instance of the class +Dog.getType = vi.fn(() => 'mocked animal') + +// mock the "speak" and "feed" methods on every instance of a class +// all `new Dog()` instances will inherit these spies +Dog.prototype.speak = vi.fn(() => 'loud bark!') +Dog.prototype.feed = vi.fn() +``` + +::: tip WHEN TO USE? +Generally speaking, you would re-create a class like this inside the module factory if the class is re-exported from another module: + +```ts +import { Dog } from './dog.js' + +vi.mock(import('./dog.js'), () => { + const Dog = vi.fn() + Dog.prototype.feed = vi.fn() + // ... other mocks + return { Dog } +}) +``` + +This method can also be used to pass an instance of a class to a function that accepts the same interface: + +```ts +// ./src/feed.ts +function feed(dog: Dog) { + // ... +} + +// ./tests/dog.test.ts +import { expect, test, vi } from 'vitest' +import { feed } from '../src/feed.js' + +const Dog = vi.fn() +Dog.prototype.feed = vi.fn() + +test('can feed dogs', () => { + const dogMax = new Dog('Max') + + feed(dogMax) + + expect(dogMax.feed).toHaveBeenCalled() + expect(dogMax.isHungry()).toBe(false) +}) +``` ::: -I want to… +Now, when we create a new instance of the `Dog` class its `speak` method (alongside `feed`) is already mocked: -### Spy on a `method` +```ts +const dog = new Dog('Cooper') +dog.speak() // loud bark! + +// you can use built-in assertions to check the validity of the call +expect(dog.speak).toHaveBeenCalled() +``` + +We can reassign the return value for a specific instance: + +```ts +const dog = new Dog('Cooper') + +// "vi.mocked" is a type helper, since +// TypeScript doesn't know that Dog is a mocked class, +// it wraps any function in a MockInstance type +// without validating if the function is a mock +vi.mocked(dog.speak).mockReturnValue('woof woof') + +dog.speak() // woof woof +``` + +To mock the property, we can use the `vi.spyOn(dog, 'name', 'get')` method. This makes it possible to use spy assertions on the mocked property: ```ts -const instance = new SomeClass() -vi.spyOn(instance, 'method') +const dog = new Dog('Cooper') + +const nameSpy = vi.spyOn(dog, 'name', 'get').mockReturnValue('Max') + +expect(dog.name).toBe('Max') +expect(nameSpy).toHaveBeenCalledTimes(1) ``` +::: tip +You can also spy on getters and setters using the same method. +::: + +## Cheat Sheet + +:::info +`vi` in the examples below is imported directly from `vitest`. You can also use it globally, if you set `globals` to `true` in your [config](/config/). +::: + +I want to… + ### Mock exported variables ```js // some-path.js @@ -595,13 +709,13 @@ vi.spyOn(exports, 'method').mockImplementation(() => {}) 1. Example with `vi.mock` and `.prototype`: ```ts -// some-path.ts +// ./some-path.ts export class SomeClass {} ``` ```ts import { SomeClass } from './some-path.js' -vi.mock('./some-path.js', () => { +vi.mock(import('./some-path.js'), () => { const SomeClass = vi.fn() SomeClass.prototype.someMethod = vi.fn() return { SomeClass } @@ -609,27 +723,15 @@ vi.mock('./some-path.js', () => { // SomeClass.mock.instances will have SomeClass ``` -2. Example with `vi.mock` and a return value: -```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 -``` - -3. Example with `vi.spyOn`: +2. Example with `vi.spyOn`: ```ts -import * as exports from './some-path.js' +import * as mod from './some-path.js' -vi.spyOn(exports, 'SomeClass').mockImplementation(() => { - // whatever suites you from first two examples -}) +const SomeClass = vi.fn() +SomeClass.prototype.someMethod = vi.fn() + +vi.spyOn(mod, 'SomeClass').mockImplementation(SomeClass) ``` ### Spy on an object returned from a function @@ -655,7 +757,7 @@ obj.method() // useObject.test.js import { useObject } from './some-path.js' -vi.mock('./some-path.js', () => { +vi.mock(import('./some-path.js'), () => { let _cache const useObject = () => { if (!_cache) { @@ -680,8 +782,8 @@ expect(obj.method).toHaveBeenCalled() ```ts import { mocked, original } from './some-path.js' -vi.mock('./some-path.js', async (importOriginal) => { - const mod = await importOriginal() +vi.mock(import('./some-path.js'), async (importOriginal) => { + const mod = await importOriginal() return { ...mod, mocked: vi.fn() @@ -691,6 +793,10 @@ original() // has original behaviour mocked() // is a spy function ``` +::: warning +Don't forget that this only [mocks _external_ access](#mocking-pitfalls). In this example, if `original` calls `mocked` internally, it will always call the function defined in the module, not in the mock factory. +::: + ### Mock the current date To mock `Date`'s time, you can use `vi.setSystemTime` helper function. This value will **not** automatically reset between different tests. @@ -762,6 +868,6 @@ it('the value is restored before running an other test', () => { export default defineConfig({ test: { unstubEnvs: true, - } + }, }) ``` diff --git a/test/core/test/jest-mock.test.ts b/test/core/test/jest-mock.test.ts index 10bd7c27dba7..bec7485e80be 100644 --- a/test/core/test/jest-mock.test.ts +++ b/test/core/test/jest-mock.test.ts @@ -408,4 +408,35 @@ describe('jest mock compat layer', () => { testFn.mockRestore() expect(testFn()).toBe(true) }) + + abstract class Dog_ { + public name: string + + constructor(name: string) { + this.name = name + } + + abstract speak(): string + abstract feed(): void + } + + it('mocks classes', () => { + const Dog = vi.fn<(name: string) => Dog_>(function Dog_(name: string) { + this.name = name + } as (this: any, name: string) => Dog_) + + ;(Dog as any).getType = vi.fn(() => 'mocked animal') + + Dog.prototype.speak = vi.fn(() => 'loud bark!') + Dog.prototype.feed = vi.fn() + + const dogMax = new Dog('Max') + expect(dogMax.name).toBe('Max') + + expect(dogMax.speak()).toBe('loud bark!') + expect(dogMax.speak).toHaveBeenCalled() + + vi.mocked(dogMax.speak).mockReturnValue('woof woof') + expect(dogMax.speak()).toBe('woof woof') + }) })