diff --git a/jest.config.js b/jest.config.js index c68ee838..b50b81ac 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,7 @@ module.exports = merge(config, { testEnvironment: 'jsdom', moduleFileExtensions: ['js', 'vue'], moduleNameMapper: { - '@testing-library/vue': '/src/vue-testing-library.js', + '@testing-library/vue': '/src/index.js', }, coverageDirectory: './coverage', collectCoverageFrom: ['**/src/**/*.js', '!**/src/__tests__/**'], diff --git a/package.json b/package.json index e0e805fc..9d6f146f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@testing-library/vue", "version": "0.0.0-semantically-released", "description": "Simple and complete Vue DOM testing utilities that encourage good testing practices.", - "main": "dist/vue-testing-library.js", + "main": "dist/index.js", "types": "types/index.d.ts", "scripts": { "format": "kcd-scripts format", @@ -50,21 +50,21 @@ "devDependencies": { "@babel/plugin-transform-runtime": "^7.11.5", "@testing-library/jest-dom": "^5.11.6", - "@types/estree": "0.0.45", "@testing-library/user-event": "^12.1.10", + "@types/estree": "0.0.46", "apollo-boost": "^0.4.9", "apollo-cache-inmemory": "^1.6.6", - "axios": "^0.20.0", + "axios": "^0.21.1", "dtslint": "^4.0.5", "eslint": "^7.13.0", - "eslint-plugin-vue": "^7.1.0", + "eslint-plugin-vue": "^7.6.0", "graphql": "^15.3.0", "graphql-tag": "^2.11.0", "isomorphic-unfetch": "^3.0.0", "jest-serializer-vue": "^2.0.2", "kcd-scripts": "^7.0.3", "lodash.merge": "^4.6.2", - "msw": "^0.21.2", + "msw": "^0.26.2", "portal-vue": "^2.1.7", "typescript": "^4.0.5", "vee-validate": "^2.2.15", diff --git a/src/__tests__/fire-event.js b/src/__tests__/fire-event.js index d91d4383..2775227f 100644 --- a/src/__tests__/fire-event.js +++ b/src/__tests__/fire-event.js @@ -201,7 +201,7 @@ typingEvents.forEach(event => { expect(console.warn).toHaveBeenCalledTimes(1) expect(console.warn).toHaveBeenCalledWith( expect.stringContaining( - `Using "fireEvent.${event} may lead to unexpected results. Please use fireEvent.update() instead.`, + `Using "fireEvent.${event}" may lead to unexpected results. Please use fireEvent.update() instead.`, ), ) }) diff --git a/src/fire-event.js b/src/fire-event.js new file mode 100644 index 00000000..6a87d7ac --- /dev/null +++ b/src/fire-event.js @@ -0,0 +1,93 @@ +/* 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 +// 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 if (type === 'file') { + return fireEvent.change(elem) + } else { + elem.value = value + if (elem._vModifiers?.lazy) { + return fireEvent.change(elem) + } + return fireEvent.input(elem) + } + } + + case 'TEXTAREA': { + elem.value = value + if (elem._vModifiers?.lazy) { + return fireEvent.change(elem) + } + 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..63ba145d --- /dev/null +++ b/src/render.js @@ -0,0 +1,101 @@ +import {createLocalVue, 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 + } = {}, + configurationCb, +) { + const div = document.createElement('div') + const baseElement = customBaseElement || customContainer || document.body + const container = customContainer || baseElement.appendChild(div) + + const attachTo = document.createElement('div') + container.appendChild(attachTo) + + const localVue = createLocalVue() + let vuexStore = null + let router = null + let callbackOptions = {} + + if (store) { + const Vuex = require('vuex') + localVue.use(Vuex) + + vuexStore = new Vuex.Store(store) + } + + if (routes) { + const requiredRouter = require('vue-router') + const VueRouter = requiredRouter.default || requiredRouter + localVue.use(VueRouter) + + router = new VueRouter({routes}) + } + + if (configurationCb && typeof configurationCb === 'function') { + callbackOptions = configurationCb(localVue, vuexStore, router) + } + + if (!mountOptions.propsData && !!mountOptions.props) { + mountOptions.propsData = mountOptions.props + delete mountOptions.props + } + + const wrapper = mount(Component, { + attachTo, + localVue, + router, + store: vuexStore, + ...mountOptions, + ...callbackOptions, + }) + + mountedWrappers.add(wrapper) + container.appendChild(wrapper.element) + + return { + container, + baseElement, + debug: (el = baseElement, ...args) => + Array.isArray(el) + ? el.forEach(e => console.log(prettyDOM(e, ...args))) + : console.log(prettyDOM(el, ...args)), + unmount: () => wrapper.destroy(), + isUnmounted: () => wrapper.vm._isDestroyed, + html: () => wrapper.html(), + emitted: () => wrapper.emitted(), + updateProps: _ => wrapper.setProps(_), + ...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) + } + + try { + wrapper.destroy() + } finally { + mountedWrappers.delete(wrapper) + } +} + +export {cleanup, render} diff --git a/src/vue-testing-library.js b/src/vue-testing-library.js deleted file mode 100644 index 8c190dd8..00000000 --- a/src/vue-testing-library.js +++ /dev/null @@ -1,203 +0,0 @@ -/* eslint-disable testing-library/no-wait-for-empty-callback */ -import {createLocalVue, mount} from '@vue/test-utils' - -import { - getQueriesForElement, - prettyDOM, - waitFor, - 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 attachTo = document.createElement('div') - container.appendChild(attachTo) - - 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 requiredRouter = require('vue-router') - const VueRouter = requiredRouter.default || requiredRouter - 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, - attachTo, - store: vuexStore, - ...mountOptions, - ...additionalOptions, - }) - - mountedWrappers.add(wrapper) - container.appendChild(wrapper.element) - - 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.destroy(), - isUnmounted: () => wrapper.vm._isDestroyed, - html: () => wrapper.html(), - emitted: () => wrapper.emitted(), - updateProps: _ => wrapper.setProps(_), - ...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) - } - - try { - wrapper.destroy() - } finally { - 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 waitFor(() => {}) -} -const changeOrInputEventCalledDirectly = (eventValue, eventKey) => - eventValue && (eventKey === 'change' || eventKey === 'input') - -Object.keys(dtlFireEvent).forEach(key => { - fireEvent[key] = async (...args) => { - if ( - changeOrInputEventCalledDirectly(args[1], key) && - !process.env.VTL_SKIP_WARN_EVENT_UPDATE - ) { - console.warn( - `Using "fireEvent.${key} may lead to unexpected results. Please use fireEvent.update() instead.`, - ) - } - dtlFireEvent[key](...args) - await waitFor(() => {}) - } -}) - -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 if (type === 'file') { - return fireEvent.change(elem) - } else { - elem.value = value - if (elem._vModifiers && elem._vModifiers.lazy) { - return fireEvent.change(elem) - } - return fireEvent.input(elem) - } - } - - case 'TEXTAREA': { - elem.value = value - if (elem._vModifiers && elem._vModifiers.lazy) { - return fireEvent.change(elem) - } - 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}