node. So you can do that, or you can also set the
-// attribute to the DOM.
-document.body.setAttribute('data-app', true)
-// Another solution is to create a custom renderer that provides all the
-// environment required by Vuetify.
-
-test('renders a Vuetify-powered component', async () => {
- const {getByText} = render(VuetifyDemoComponent, {
- vuetify: new Vuetify(),
- })
-
- await fireEvent.click(getByText('open'))
-
- expect(getByText('Lorem ipsum dolor sit amet.')).toMatchInlineSnapshot(`
-
- Lorem ipsum dolor sit amet.
-
- `)
-})
diff --git a/src/__tests__/vuex.js b/src/__tests__/vuex.js
index 79b81cc5..d0faa53a 100644
--- a/src/__tests__/vuex.js
+++ b/src/__tests__/vuex.js
@@ -1,9 +1,30 @@
-import '@testing-library/jest-dom/extend-expect'
-import {render, fireEvent} from '@testing-library/vue'
-
+import '@testing-library/jest-dom'
+import {createStore} from 'vuex'
+import {render, fireEvent} from '..'
import VuexTest from './components/Store/VuexTest'
import {store} from './components/Store/store'
+test('basic test with Vuex store', async () => {
+ const storeInstance = createStore(store)
+
+ const {getByTestId, getByText} = render(VuexTest, {
+ global: {
+ plugins: [storeInstance],
+ },
+ })
+
+ expect(getByTestId('count-value')).toHaveTextContent('0')
+
+ await fireEvent.click(getByText('+'))
+ expect(getByTestId('count-value')).toHaveTextContent('1')
+
+ await fireEvent.click(getByText('+'))
+ expect(getByTestId('count-value')).toHaveTextContent('2')
+
+ await fireEvent.click(getByText('-'))
+ expect(getByTestId('count-value')).toHaveTextContent('1')
+})
+
// A common testing pattern is to create a custom renderer for a specific test
// file. This way, common operations such as registering a Vuex store can be
// abstracted out while avoiding sharing mutable state.
@@ -11,14 +32,20 @@ import {store} from './components/Store/store'
// Tests should be completely isolated from one another.
// Read this for additional context: https://kentcdodds.com/blog/test-isolation-with-react
function renderVuexTestComponent(customStore) {
- // Render the component and merge the original store and the custom one
- // provided as a parameter. This way, we can alter some behaviors of the
- // initial implementation.
- return render(VuexTest, {store: {...store, ...customStore}})
+ // Create a custom store with the original one and the one coming as a
+ // parameter. This way we can alter some of its values.
+ const mergedStoreInstance = createStore({...store, ...customStore})
+
+ return render(VuexTest, {
+ global: {
+ plugins: [mergedStoreInstance],
+ },
+ })
}
test('can render with vuex with defaults', async () => {
const {getByTestId, getByText} = renderVuexTestComponent()
+
await fireEvent.click(getByText('+'))
expect(getByTestId('count-value')).toHaveTextContent('1')
@@ -26,8 +53,9 @@ test('can render with vuex with defaults', async () => {
test('can render with vuex with custom initial state', async () => {
const {getByTestId, getByText} = renderVuexTestComponent({
- state: {count: 3},
+ state: () => ({count: 3}),
})
+
await fireEvent.click(getByText('-'))
expect(getByTestId('count-value')).toHaveTextContent('2')
@@ -36,17 +64,21 @@ test('can render with vuex with custom initial state', async () => {
test('can render with vuex with custom store', async () => {
// This is a silly store that can never be changed.
// eslint-disable-next-line no-shadow
- const store = {
- state: {count: 1000},
+ const store = createStore({
+ state: () => ({count: 1000}),
actions: {
increment: () => jest.fn(),
decrement: () => jest.fn(),
},
- }
+ })
- // Notice how here we are not using the helper method, because there's no
- // need to do that.
- const {getByTestId, getByText} = render(VuexTest, {store})
+ // Notice how here we are not using the helper rendering method, because
+ // there's no need to do that here. We're passing a whole store.
+ const {getByTestId, getByText} = render(VuexTest, {
+ global: {
+ plugins: [store],
+ },
+ })
await fireEvent.click(getByText('+'))
expect(getByTestId('count-value')).toHaveTextContent('1000')
diff --git a/src/__tests__/within.js b/src/__tests__/within.js
index de04c76f..53f70bab 100644
--- a/src/__tests__/within.js
+++ b/src/__tests__/within.js
@@ -1,4 +1,4 @@
-import {render, within} from '@testing-library/vue'
+import {render, within} from '..'
test('within() returns an object with all queries bound to the DOM node', () => {
const {getByTestId, getByText} = render({
@@ -21,12 +21,13 @@ test('within() returns an object with all queries bound to the DOM node', () =>
// within() returns queries bound to the provided DOM node, so the following
// assertion passes. Notice how we are not using the getByText() function
// provided by render(), but the one coming from within().
+ // eslint-disable-next-line testing-library/prefer-explicit-assert
within(divNode).getByText('repeated text')
// Here, proof that there's only one match for the specified text.
expect(divNode).toMatchInlineSnapshot(`
repeated text
diff --git a/src/fire-event.js b/src/fire-event.js
new file mode 100644
index 00000000..eca4438c
--- /dev/null
+++ b/src/fire-event.js
@@ -0,0 +1,87 @@
+/* eslint-disable testing-library/no-wait-for-empty-callback */
+import {waitFor, fireEvent as dtlFireEvent} from '@testing-library/dom'
+
+// Vue Testing Lib's version of fireEvent will call DOM Testing Lib's
+// version of fireEvent. The reason is because we need to wait another
+// event loop tick to allow Vue to flush and update the DOM
+// More info: https://vuejs.org/v2/guide/reactivity.html#Async-Update-Queue
+
+async function fireEvent(...args) {
+ dtlFireEvent(...args)
+ await waitFor(() => {})
+}
+
+Object.keys(dtlFireEvent).forEach(key => {
+ fireEvent[key] = async (...args) => {
+ warnOnChangeOrInputEventCalledDirectly(args[1], key)
+
+ dtlFireEvent[key](...args)
+ await waitFor(() => {})
+ }
+})
+
+fireEvent.touch = async elem => {
+ await fireEvent.focus(elem)
+ await fireEvent.blur(elem)
+}
+
+// fireEvent.update is a small utility to provide a better experience when
+// working with v-model.
+// Related upstream issue: https://github.com/vuejs/vue-test-utils/issues/345#issuecomment-380588199
+// See some examples in __tests__/form.js
+fireEvent.update = (elem, value) => {
+ const tagName = elem.tagName
+ const type = elem.type
+
+ switch (tagName) {
+ case 'OPTION': {
+ elem.selected = true
+
+ const parentSelectElement =
+ elem.parentElement.tagName === 'OPTGROUP'
+ ? elem.parentElement.parentElement
+ : elem.parentElement
+
+ return fireEvent.change(parentSelectElement)
+ }
+
+ case 'INPUT': {
+ if (['checkbox', 'radio'].includes(type)) {
+ elem.checked = true
+ return fireEvent.change(elem)
+ } else if (type === 'file') {
+ return fireEvent.change(elem)
+ } else {
+ elem.value = value
+ return fireEvent.input(elem)
+ }
+ }
+
+ case 'TEXTAREA': {
+ elem.value = value
+ return fireEvent.input(elem)
+ }
+
+ case 'SELECT': {
+ elem.value = value
+ return fireEvent.change(elem)
+ }
+
+ default:
+ // do nothing
+ }
+
+ return null
+}
+
+function warnOnChangeOrInputEventCalledDirectly(eventValue, eventKey) {
+ if (process.env.VTL_SKIP_WARN_EVENT_UPDATE) return
+
+ if (eventValue && (eventKey === 'change' || eventKey === 'input')) {
+ console.warn(
+ `Using "fireEvent.${eventKey}" may lead to unexpected results. Please use fireEvent.update() instead.`,
+ )
+ }
+}
+
+export {fireEvent}
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 00000000..b9f4dc4e
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,15 @@
+import {cleanup} from './render'
+
+// If we're running in a test runner that supports afterEach then we'll
+// automatically run cleanup after each test.
+// This ensures that tests run in isolation from each other.
+// If you don't like this, set the VTL_SKIP_AUTO_CLEANUP variable to 'true'.
+if (typeof afterEach === 'function' && !process.env.VTL_SKIP_AUTO_CLEANUP) {
+ afterEach(() => {
+ cleanup()
+ })
+}
+
+export * from '@testing-library/dom'
+export {cleanup, render} from './render'
+export {fireEvent} from './fire-event'
diff --git a/src/render.js b/src/render.js
new file mode 100644
index 00000000..997ba4c0
--- /dev/null
+++ b/src/render.js
@@ -0,0 +1,71 @@
+/* eslint-disable testing-library/no-wait-for-empty-callback */
+import {mount} from '@vue/test-utils'
+
+import {getQueriesForElement, prettyDOM} from '@testing-library/dom'
+
+const mountedWrappers = new Set()
+
+function render(
+ Component,
+ {
+ store = null,
+ routes = null,
+ container: customContainer,
+ baseElement: customBaseElement,
+ ...mountOptions
+ } = {},
+) {
+ const div = document.createElement('div')
+ const baseElement = customBaseElement || customContainer || document.body
+ const container = customContainer || baseElement.appendChild(div)
+
+ if (store || routes) {
+ console.warn(`Providing 'store' or 'routes' options is no longer available.
+You need to create a router/vuex instance and provide it through 'global.plugins'.
+Check out the test examples on GitHub for further details.`)
+ }
+
+ const wrapper = mount(Component, {
+ ...mountOptions,
+ attachTo: container,
+ })
+
+ // this removes the additional wrapping div node from VTU:
+ // https://github.com/vuejs/vue-test-utils-next/blob/master/src/mount.ts#L309
+ unwrapNode(wrapper.parentElement)
+
+ mountedWrappers.add(wrapper)
+
+ return {
+ container,
+ baseElement,
+ debug: (el = baseElement, maxLength, options) =>
+ Array.isArray(el)
+ ? el.forEach(e => console.log(prettyDOM(e, maxLength, options)))
+ : console.log(prettyDOM(el, maxLength, options)),
+ unmount: () => wrapper.unmount(),
+ html: () => wrapper.html(),
+ emitted: name => wrapper.emitted(name),
+ rerender: props => wrapper.setProps(props),
+ ...getQueriesForElement(baseElement),
+ }
+}
+
+function unwrapNode(node) {
+ node.replaceWith(...node.childNodes)
+}
+
+function cleanup() {
+ mountedWrappers.forEach(cleanupAtWrapper)
+}
+
+function cleanupAtWrapper(wrapper) {
+ if (wrapper.element?.parentNode?.parentNode === document.body) {
+ document.body.removeChild(wrapper.element.parentNode)
+ }
+
+ wrapper.unmount()
+ mountedWrappers.delete(wrapper)
+}
+
+export {render, cleanup}
diff --git a/src/vue-testing-library.js b/src/vue-testing-library.js
deleted file mode 100644
index 028b8406..00000000
--- a/src/vue-testing-library.js
+++ /dev/null
@@ -1,182 +0,0 @@
-import {createLocalVue, mount} from '@vue/test-utils'
-
-import {
- getQueriesForElement,
- logDOM,
- wait,
- fireEvent as dtlFireEvent,
-} from '@testing-library/dom'
-
-const mountedWrappers = new Set()
-
-function render(
- TestComponent,
- {
- store = null,
- routes = null,
- container: customContainer,
- baseElement: customBaseElement,
- ...mountOptions
- } = {},
- configurationCb,
-) {
- const div = document.createElement('div')
- const baseElement = customBaseElement || customContainer || document.body
- const container = customContainer || baseElement.appendChild(div)
-
- const localVue = createLocalVue()
- let vuexStore = null
- let router = null
- let additionalOptions = {}
-
- if (store) {
- const Vuex = require('vuex')
- localVue.use(Vuex)
- vuexStore = new Vuex.Store(store)
- }
-
- if (routes) {
- const VueRouter = require('vue-router')
- localVue.use(VueRouter)
- router = new VueRouter({
- routes,
- })
- }
-
- if (configurationCb && typeof configurationCb === 'function') {
- additionalOptions = configurationCb(localVue, vuexStore, router)
- }
-
- if (!mountOptions.propsData && !!mountOptions.props) {
- mountOptions.propsData = mountOptions.props
- delete mountOptions.props
- }
-
- const wrapper = mount(TestComponent, {
- localVue,
- router,
- store: vuexStore,
- attachToDocument: true,
- sync: false,
- ...mountOptions,
- ...additionalOptions,
- })
-
- mountedWrappers.add(wrapper)
- container.appendChild(wrapper.element)
-
- return {
- container,
- baseElement,
- debug: (el = baseElement) =>
- Array.isArray(el) ? el.forEach(e => logDOM(e)) : logDOM(el),
- unmount: () => wrapper.destroy(),
- isUnmounted: () => wrapper.vm._isDestroyed,
- html: () => wrapper.html(),
- emitted: () => wrapper.emitted(),
- updateProps: _ => {
- wrapper.setProps(_)
- return wait()
- },
- ...getQueriesForElement(baseElement),
- }
-}
-
-function cleanup() {
- mountedWrappers.forEach(cleanupAtWrapper)
-}
-
-function cleanupAtWrapper(wrapper) {
- if (
- wrapper.element.parentNode &&
- wrapper.element.parentNode.parentNode === document.body
- ) {
- document.body.removeChild(wrapper.element.parentNode)
- }
-
- if (wrapper.isVueInstance()) {
- wrapper.destroy()
- }
-
- mountedWrappers.delete(wrapper)
-}
-
-// Vue Testing Library's version of fireEvent will call DOM Testing Library's
-// version of fireEvent plus wait for one tick of the event loop to allow Vue
-// to asynchronously handle the event.
-// More info: https://vuejs.org/v2/guide/reactivity.html#Async-Update-Queue
-async function fireEvent(...args) {
- dtlFireEvent(...args)
- await wait()
-}
-
-Object.keys(dtlFireEvent).forEach(key => {
- fireEvent[key] = async (...args) => {
- dtlFireEvent[key](...args)
- await wait()
- }
-})
-
-fireEvent.touch = async elem => {
- await fireEvent.focus(elem)
- await fireEvent.blur(elem)
-}
-
-// Small utility to provide a better experience when working with v-model.
-// Related upstream issue: https://github.com/vuejs/vue-test-utils/issues/345#issuecomment-380588199
-// Examples: https://github.com/testing-library/vue-testing-library/blob/master/src/__tests__/form.js
-fireEvent.update = (elem, value) => {
- const tagName = elem.tagName
- const type = elem.type
-
- switch (tagName) {
- case 'OPTION': {
- elem.selected = true
-
- const parentSelectElement =
- elem.parentElement.tagName === 'OPTGROUP'
- ? elem.parentElement.parentElement
- : elem.parentElement
-
- return fireEvent.change(parentSelectElement)
- }
-
- case 'INPUT': {
- if (['checkbox', 'radio'].includes(type)) {
- elem.checked = true
- return fireEvent.change(elem)
- } else {
- elem.value = value
- return fireEvent.input(elem)
- }
- }
-
- case 'TEXTAREA': {
- elem.value = value
- return fireEvent.input(elem)
- }
-
- case 'SELECT': {
- elem.value = value
- return fireEvent.change(elem)
- }
-
- default:
- // do nothing
- }
-
- return null
-}
-
-// If we're running in a test runner that supports afterEach then we'll
-// automatically run cleanup after each test. This ensures that tests run in
-// isolation from each other.
-// If you don't like this, set the VTL_SKIP_AUTO_CLEANUP variable to 'true'.
-if (typeof afterEach === 'function' && !process.env.VTL_SKIP_AUTO_CLEANUP) {
- afterEach(() => {
- cleanup()
- })
-}
-
-export * from '@testing-library/dom'
-export {cleanup, render, fireEvent}
diff --git a/types/index.d.ts b/types/index.d.ts
new file mode 100644
index 00000000..d4edc3bf
--- /dev/null
+++ b/types/index.d.ts
@@ -0,0 +1,90 @@
+// Minimum TypeScript Version: 4.0
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import {VNodeChild} from 'vue'
+import {MountingOptions} from '@vue/test-utils'
+import {queries, EventType, BoundFunctions} from '@testing-library/dom'
+// eslint-disable-next-line import/no-extraneous-dependencies
+import {OptionsReceived as PrettyFormatOptions} from 'pretty-format'
+import {ComponentProps, ComponentSlots} from 'vue-component-type-helpers'
+import {RemoveIndexSignature} from 'type-fest'
+
+// NOTE: fireEvent is overridden below
+export * from '@testing-library/dom'
+
+export function cleanup(): void
+
+type Debug = (
+ baseElement?: Array
| DocumentFragment | Element,
+ maxLength?: number,
+ options?: PrettyFormatOptions,
+) => void
+
+export interface RenderResult extends BoundFunctions {
+ container: Element
+ baseElement: Element
+ debug: Debug
+ unmount(): void
+ html(): string
+ emitted(): Record
+ emitted(name?: string): T[]
+ rerender(props: object): Promise
+}
+
+type VueTestUtilsRenderOptions = Omit< + MountingOptions>,
+ 'attachTo' | 'propsData' | 'shallow'
+>
+interface VueTestingLibraryRenderOptions {
+ /**
+ * @deprecated Add a Vuex instance through `global.plugins` array instead.
+ */
+ store?: any
+ /**
+ * @deprecated Add a Router instance through `global.plugins` array instead.
+ */
+ routes?: any
+ container?: Element
+ baseElement?: Element
+}
+
+type AllowNonFunctionSlots = {
+ [K in keyof Slots]: Slots[K] | VNodeChild
+}
+type ExtractSlots = AllowNonFunctionSlots< + Partial>>
+>
+
+export interface RenderOptions
+ extends Omit< + VueTestingLibraryRenderOptions & VueTestUtilsRenderOptions, + 'props' | 'slots' +> {
+ props?: ComponentProps
+ slots?: ExtractSlots
+}
+
+export function render(
+ TestComponent: C,
+ options?: RenderOptions,
+): RenderResult
+
+export type AsyncFireObject = {
+ [K in EventType]: (
+ element: Document | Element | Window,
+ options?: {},
+ ) => Promise
+}
+
+export interface VueFireEventObject extends AsyncFireObject {
+ (element: Document | Element | Window, event: Event): Promise
+ touch(element: Document | Element | Window): Promise
+ update(element: HTMLOptionElement): Promise
+ update(
+ element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
+ value: string,
+ ): Promise
+ update(element: Element, value?: string): Promise
+}
+
+export const fireEvent: VueFireEventObject
diff --git a/types/index.test-d.ts b/types/index.test-d.ts
new file mode 100644
index 00000000..02e9e187
--- /dev/null
+++ b/types/index.test-d.ts
@@ -0,0 +1,100 @@
+import {expectType} from 'tsd'
+import {defineComponent} from 'vue'
+import {render, fireEvent, screen, waitFor} from '.'
+
+declare const elem: Element
+
+const SomeComponent = defineComponent({
+ name: 'SomeComponent',
+ props: {
+ foo: {type: Number, default: 0},
+ bar: {type: String, default: '0'},
+ },
+})
+
+export async function testRender() {
+ const utils = render({template: ''})
+
+ // single queries
+ expectType(utils.getByText('foo'))
+ expectType(utils.queryByText('foo'))
+ expectType(await utils.findByText('foo'))
+
+ // multiple queries
+ expectType(utils.getAllByText('bar'))
+ expectType(utils.queryAllByText('bar'))
+ expectType(await utils.findAllByText('bar'))
+
+ // helpers
+ const {container, baseElement, unmount, debug, rerender} = utils
+
+ expectType(await rerender({a: 1}))
+
+ expectType(debug())
+ expectType(debug(container))
+ expectType(debug([elem, elem], 100, {highlight: false}))
+
+ expectType(unmount())
+
+ expectType(container)
+ expectType(baseElement)
+}
+
+export function testRenderOptions() {
+ const container = document.createElement('div')
+ const baseElement = document.createElement('div')
+ const options = {container, baseElement}
+ render({template: 'div'}, options)
+}
+
+export async function testFireEvent() {
+ const {container} = render({template: 'button'})
+ expectType(await fireEvent.click(container))
+ expectType(await fireEvent.touch(elem))
+}
+
+export async function testScreen() {
+ render({template: 'button'})
+
+ expectType(await screen.findByRole('button'))
+}
+
+export async function testWaitFor() {
+ const {container} = render({template: 'button'})
+ expectType(await fireEvent.update(container))
+ expectType(await waitFor(() => {}))
+}
+
+export function testOptions() {
+ render(SomeComponent, {
+ attrs: {a: 1},
+ props: {foo: 1},
+ data: () => ({b: 2}),
+ slots: {
+ default: '',
+ footer: '',
+ },
+ global: {
+ config: {isCustomElement: _ => true},
+ plugins: [],
+ },
+ baseElement: document.createElement('div'),
+ container: document.createElement('div'),
+ })
+}
+
+export function testEmitted() {
+ const {emitted} = render(SomeComponent)
+ expectType(emitted().foo)
+ expectType(emitted('foo'))
+}
+
+/*
+eslint
+ testing-library/prefer-explicit-assert: "off",
+ testing-library/no-wait-for-empty-callback: "off",
+ testing-library/no-debugging-utils: "off",
+ testing-library/prefer-screen-queries: "off",
+ @typescript-eslint/unbound-method: "off",
+ @typescript-eslint/no-invalid-void-type: "off"
+*/
diff --git a/types/tsconfig.json b/types/tsconfig.json
new file mode 100644
index 00000000..d822978b
--- /dev/null
+++ b/types/tsconfig.json
@@ -0,0 +1,17 @@
+// this additional tsconfig is required by dtslint
+// see: https://github.com/Microsoft/dtslint#typestsconfigjson
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "lib": ["ES2020", "DOM"],
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "strictNullChecks": true,
+ "strictFunctionTypes": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@testing-library/vue": ["."]
+ }
+ }
+}
diff --git a/types/tslint.json b/types/tslint.json
new file mode 100644
index 00000000..70c4494b
--- /dev/null
+++ b/types/tslint.json
@@ -0,0 +1,7 @@
+{
+ "extends": "dtslint/dtslint.json",
+ "rules": {
+ "semicolon": false,
+ "whitespace": false
+ }
+}