Skip to content

Commit

Permalink
feat: return nextTick from setters, fix #1515 (#1517)
Browse files Browse the repository at this point in the history
* feat: return promise from set methods

* fix: fix error types for error wrapper

* refactor: change promise assertion method

* docs: mention that some methods can be awaited

Generally improve async docs

* fix: fix TS types

* fix: fix nextTick for vue < 2.1

* docs: fix eslint error

* chore: revert dist

* chore: revert committed dist files

Co-authored-by: Lachlan Miller <lachlan.miller.1990@outlook.com>
  • Loading branch information
dobromir-hristov and lmiller1990 committed Apr 27, 2020
1 parent 7a0b7e0 commit aa7b76d
Show file tree
Hide file tree
Showing 15 changed files with 265 additions and 209 deletions.
9 changes: 4 additions & 5 deletions docs/api/wrapper/trigger.md
Expand Up @@ -3,6 +3,7 @@
Triggers an event asynchronously on the `Wrapper` DOM node.

`trigger` takes an optional `options` object. The properties in the `options` object are added to the Event.
`trigger` returns a Promise, which when resolved, guarantees the component is updated.

- **Arguments:**

Expand All @@ -22,18 +23,16 @@ test('trigger demo', async () => {
propsData: { clickHandler }
})

wrapper.trigger('click')
await wrapper.trigger('click')

wrapper.trigger('click', {
await wrapper.trigger('click', {
button: 0
})

wrapper.trigger('click', {
await wrapper.trigger('click', {
ctrlKey: true // For testing @click.ctrl handlers
})

await wrapper.vm.$nextTick() // Wait until trigger events have been handled

expect(clickHandler.called).toBe(true)
})
```
Expand Down
34 changes: 12 additions & 22 deletions docs/guides/common-tips.md
Expand Up @@ -35,43 +35,35 @@ When using either the `mount` or `shallowMount` methods, you can expect your com

Additionally, the component will not be automatically destroyed at the end of each spec, and it is up to the user to stub or manually clean up tasks that will continue to run (`setInterval` or `setTimeout`, for example) before the end of each spec.

### Writing asynchronous tests using `nextTick` (new)
### Writing asynchronous tests (new)

By default, Vue batches updates to run asynchronously (on the next "tick"). This is to prevent unnecessary DOM re-renders, and watcher computations ([see the docs](https://vuejs.org/v2/guide/reactivity.html#Async-Update-Queue) for more details).

This means you **must** wait for updates to run after you set a reactive property. You can wait for updates with `Vue.nextTick()`:
This means that you **must** wait for updates to run after you change a reactive property. You can do that by awaiting mutation methods like `trigger`:

```js
it('updates text', async () => {
const wrapper = mount(Component)
wrapper.trigger('click')
await Vue.nextTick()
await wrapper.trigger('click')
expect(wrapper.text()).toContain('updated')
await wrapper.trigger('click')
wrapper.text().toContain('some different text')
})

// Or if you're without async/await
it('render text', done => {
const wrapper = mount(TestComponent)
wrapper.trigger('click')
Vue.nextTick(() => {
wrapper.text().toContain('some text')
wrapper.trigger('click')
Vue.nextTick(() => {
wrapper.trigger('click').then(() => {
wrapper.text().toContain('updated')
wrapper.trigger('click').then(() => {
wrapper.text().toContain('some different text')
done()
})
})
})
```

The following methods often cause watcher updates that require you to wait for the next tick:

- `setChecked`
- `setData`
- `setSelected`
- `setProps`
- `setValue`
- `trigger`
Learn more in the [Testing Asynchronous Behavior](../guides/README.md#testing-asynchronous-behavior)

### Asserting Emitted Events

Expand Down Expand Up @@ -213,16 +205,15 @@ test('should render Foo, then hide it', async () => {
const wrapper = mount(Foo)
expect(wrapper.text()).toMatch(/Foo/)

wrapper.setData({
await wrapper.setData({
show: false
})
await wrapper.vm.$nextTick()

expect(wrapper.text()).not.toMatch(/Foo/)
})
```

In practice, although we are calling `setData` then waiting for the `nextTick` to ensure the DOM is updated, this test fails. This is an ongoing issue related to how Vue implements the `<transition>` component, that we would like to solve before version 1.0. For now, there are some workarounds:
In practice, although we are calling and awaiting `setData` to ensure the DOM is updated, this test fails. This is an ongoing issue related to how Vue implements the `<transition>` component, that we would like to solve before version 1.0. For now, there are some workarounds:

#### Using a `transitionStub` helper

Expand All @@ -241,10 +232,9 @@ test('should render Foo, then hide it', async () => {
})
expect(wrapper.text()).toMatch(/Foo/)

wrapper.setData({
await wrapper.setData({
show: false
})
await wrapper.vm.$nextTick()

expect(wrapper.text()).not.toMatch(/Foo/)
})
Expand Down
29 changes: 19 additions & 10 deletions docs/guides/getting-started.md
Expand Up @@ -122,30 +122,34 @@ it('button click should increment the count', () => {

In order to test that the counter text has updated, we need to learn about `nextTick`.

### Using `nextTick`
### Using `nextTick` and awaiting actions

Anytime you make a change (in computed, data, vuex state, etc) which updates the DOM (ex. show a component from v-if), you should await the `nextTick` function before running the test. This is because Vue batches pending DOM updates and _applies them asynchronously_ to prevent unnecessary re-renders caused by multiple data mutations.
Anytime you make a change (in computed, data, vuex state, etc) which updates the DOM (ex. show a component from v-if or display dynamic text), you should await the `nextTick` function before running the assertion.
This is because Vue batches pending DOM updates and _applies them asynchronously_ to prevent unnecessary re-renders caused by multiple data mutations.

_You can read more about asynchronous updates in the [Vue docs](https://vuejs.org/v2/guide/reactivity.html#Async-Update-Queue)_

We need to use `wrapper.vm.$nextTick` to wait until Vue has performed the DOM update after we set a reactive property. In the counter example, setting the `count` property schedules a DOM update to run on the next tick.
After updating a reactive property we can await methods like `trigger` or `wrapper.vm.$nextTick` directly, until Vue has performed the DOM update. In the counter example, setting the `count` property schedules a DOM update to run on the next tick.

We can `await` `wrapper.vm.$nextTick()` by writing the tests in an async function:
Lets see how we can `await trigger()` by writing the tests in an async function:

```js
it('button click should increment the count text', async () => {
expect(wrapper.text()).toContain('0')
const button = wrapper.find('button')
button.trigger('click')
await wrapper.vm.$nextTick()
await button.trigger('click')
expect(wrapper.text()).toContain('1')
})
```

When you use `nextTick` in your test files, be aware that any errors thrown inside it may not be caught by your test runner as it uses promises internally. There are two approaches to fixing this: either you can set the `done` callback as Vue's global error handler at the start of the test, or you can call `nextTick` without an argument and return it as a promise:
`trigger` returns a promise, which can be awaited as seen above or chained with `then` like a regular promise callback. Methods like `trigger` just return `Vue.nextTick` internally.
You can read more in depth about [Testing Asynchronous Components](../guides/README.md#testing-async-components).

If for some reason you choose to use `nextTick` instead in your test files, be aware that any errors thrown inside it may not be caught by your test runner as it uses promises internally. There are two approaches to fixing this:
either you can set the `done` callback as Vue's global error handler at the start of the test, or you can call `nextTick` without an argument and return it as a promise:

```js
// this will not be caught
// errors will not be caught
it('will time out', done => {
Vue.nextTick(() => {
expect(true).toBe(false)
Expand Down Expand Up @@ -174,7 +178,12 @@ it('will catch the error using async/await', async () => {
})
```

`Vue.nextTick` is equal to `component.vm.$nextTick`, where `component` can be the result of `mount` or `find`.

As mentioned in the beginning, in most cases, awaiting `trigger` is the recommended way to go.

### What's Next

- Integrate Vue Test Utils into your project by [choosing a test runner](./choosing-a-test-runner.md).
- Learn more about [common techniques when writing tests](./common-tips.md).
- Learn more about [common techniques when writing tests](./README.md#knowing-what-to-test).
- Integrate Vue Test Utils into your project by [choosing a test runner](./README.md#choosing-a-test-runner).
- Learn more about [Testing Asynchronous Behavior](./README.md#testing-asynchronous-behavior)
50 changes: 37 additions & 13 deletions docs/guides/testing-async-components.md
Expand Up @@ -11,17 +11,22 @@ Vue batches pending DOM updates and applies them asynchronously to prevent unnec

_You can read more about asynchronous updates in the [Vue docs](https://vuejs.org/v2/guide/reactivity.html#Async-Update-Queue)_

In practice, this means you have to use `Vue.nextTick()` to wait until Vue has performed updates after you set a reactive property.

The easiest way to use `Vue.nextTick()` is to write your tests in an async function:
In practice, this means that after mutating a reactive property, to assert that change your test has to wait while Vue is performing updates.
One way is to use `await Vue.nextTick()`, but an easier and cleaner way is to just `await` the method that you mutated the state with, like `trigger`.

```js
// import Vue at the top of file
import Vue from 'vue'
// inside test-suite, add this test case
it('button click should increment the count text', async () => {
expect(wrapper.text()).toContain('0')
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.text()).toContain('1')
})
```

// other code snippet...
Awaiting the trigger above is the same as doing:

// inside test-suite, add this test case
```js
it('button click should increment the count text', async () => {
expect(wrapper.text()).toContain('0')
const button = wrapper.find('button')
Expand All @@ -31,6 +36,15 @@ it('button click should increment the count text', async () => {
})
```

Methods that can be awaited are:

- [setData](../api/wrapper/README.md#setdata)
- [setValue](../api/wrapper/README.md#setvalue)
- [setChecked](../api/wrapper/README.md#setchecked)
- [setSelected](../api/wrapper/README.md#setselected)
- [setProps](../api/wrapper/README.md#setprops)
- [trigger](../api/wrapper/README.md#trigger)

## Asynchronous behavior outside of Vue

One of the most common asynchronous behaviors outside of Vue is API calls in Vuex actions. The following examples shows how to test a method that makes an API call. This example uses Jest to run the test and to mock the HTTP library `axios`. More about Jest manual mocks can be found [here](https://jestjs.io/docs/en/manual-mocks.html#content).
Expand All @@ -47,7 +61,7 @@ The below component makes an API call when a button is clicked, then assigns the

```html
<template>
<button @click="fetchResults" />
<button @click="fetchResults">{{ value }}</button>
</template>

<script>
Expand Down Expand Up @@ -75,12 +89,14 @@ A test can be written like this:
```js
import { shallowMount } from '@vue/test-utils'
import Foo from './Foo'
jest.mock('axios')
jest.mock('axios', () => ({
get: Promise.resolve('value')
}))

it('fetches async when a button is clicked', () => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
expect(wrapper.vm.value).toBe('value')
expect(wrapper.text()).toBe('value')
})
```

Expand All @@ -91,15 +107,15 @@ it('fetches async when a button is clicked', done => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.value).toBe('value')
expect(wrapper.text()).toBe('value')
done()
})
})
```

The reason `setTimeout` allows the test to pass is because the microtask queue where promise callbacks are processed runs before the task queue, where `setTimeout` callbacks are processed. This means by the time the `setTimeout` callback runs, any promise callbacks on the microtask queue will have been executed. `$nextTick` on the other hand schedules a microtask, but since the microtask queue is processed first-in-first-out that also guarantees the promise callback has been executed by the time the assertion is made. See [here](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) for a more detailed explanation.

Another solution is to use an `async` function and the [npm package flush-promises](https://www.npmjs.com/package/flush-promises). `flush-promises` flushes all pending resolved promise handlers. You can `await` the call of `flushPromises` to flush pending promises and improve the readability of your test.
Another solution is to use an `async` function and a package like [flush-promises](https://www.npmjs.com/package/flush-promises). `flush-promises` flushes all pending resolved promise handlers. You can `await` the call of `flushPromises` to flush pending promises and improve the readability of your test.

The updated test looks like this:

Expand All @@ -113,8 +129,16 @@ it('fetches async when a button is clicked', async () => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.vm.value).toBe('value')
expect(wrapper.text()).toBe('value')
})
```

This same technique can be applied to Vuex actions, which return a promise by default.

#### Why not just `await button.trigger()` ?

As explained above, there is a difference between the time it takes for Vue to update its components,
and the time it takes for a Promise, like the one from `axios` to resolve.

A nice rule to follow is to always `await` on mutations like `trigger` or `setProps`.
If your code relies on something async, like calling `axios`, add an await to the `flushPromises` call as well.
12 changes: 6 additions & 6 deletions flow/wrapper.flow.js
Expand Up @@ -30,13 +30,13 @@ declare interface BaseWrapper {
props(key?: string): { [name: string]: any } | any | void;
text(): string | void;
selector: Selector | void;
setData(data: Object): void;
setData(data: Object): Promise<void> | void;
setMethods(methods: Object): void;
setValue(value: any): void;
setChecked(checked?: boolean): void;
setSelected(): void;
setProps(data: Object): void;
trigger(type: string, options: Object): void;
setValue(value: any): Promise<void> | void;
setChecked(checked?: boolean): Promise<void> | void;
setSelected(): Promise<void> | void;
setProps(data: Object): Promise<void> | void;
trigger(type: string, options: Object): Promise<void> | void;
destroy(): void;
}

Expand Down
13 changes: 13 additions & 0 deletions packages/shared/util.js
@@ -1,6 +1,7 @@
// @flow
import Vue from 'vue'
import semver from 'semver'
import { VUE_VERSION } from './consts'
import { config } from '@vue/test-utils'

export function throwError(msg: string): void {
Expand Down Expand Up @@ -87,6 +88,18 @@ export function getCheckedEvent() {
return 'change'
}

/**
* Normalize nextTick to return a promise for all Vue 2 versions.
* Vue < 2.1 does not return a Promise from nextTick
* @return {Promise<R>}
*/
export function nextTick(): Promise<void> {
if (VUE_VERSION > 2) return Vue.nextTick()
return new Promise(resolve => {
Vue.nextTick(resolve)
})
}

export function warnDeprecated(method: string, fallback: string = '') {
if (!config.showDeprecationWarnings) return
let msg = `${method} is deprecated and will removed in the next major version`
Expand Down

0 comments on commit aa7b76d

Please sign in to comment.