Skip to content

Commit

Permalink
docs: document how to mock class and its methods (#6615)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va authored Oct 2, 2024
1 parent 8a8d3f0 commit f4a682d
Show file tree
Hide file tree
Showing 2 changed files with 168 additions and 31 deletions.
168 changes: 137 additions & 31 deletions docs/guide/mocking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> 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
Expand Down Expand Up @@ -595,41 +709,29 @@ 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 }
})
// 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
Expand All @@ -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) {
Expand All @@ -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<typeof import('./some-path.js')>()
vi.mock(import('./some-path.js'), async (importOriginal) => {
const mod = await importOriginal()
return {
...mod,
mocked: vi.fn()
Expand All @@ -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.
Expand Down Expand Up @@ -762,6 +868,6 @@ it('the value is restored before running an other test', () => {
export default defineConfig({
test: {
unstubEnvs: true,
}
},
})
```
31 changes: 31 additions & 0 deletions test/core/test/jest-mock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

0 comments on commit f4a682d

Please sign in to comment.