From 10f02213f8744d4e3bc505d6f7c3fec031a0d1a6 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Fri, 16 May 2025 15:02:53 -0400 Subject: [PATCH] fix: configure dom-testing-library to flush Svelte changes --- README.md | 34 +++++++++++++++++++++ jest.config.js | 2 +- src/index.js | 24 ++++++++++----- src/pure.js | 61 ++++++++++++++++++++++++++----------- src/vitest.js | 14 ++++++--- tests/_jest-setup.js | 8 ----- tests/_jest-vitest-alias.js | 3 +- tests/act.test.js | 13 +++++++- tests/auto-cleanup.test.js | 4 +++ tests/events.test.js | 12 ++++++++ 10 files changed, 133 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index bf45331..29429d4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ - [This Solution](#this-solution) - [Installation](#installation) - [Setup](#setup) + - [Auto-cleanup](#auto-cleanup) - [Docs](#docs) - [Issues](#issues) - [🐛 Bugs](#-bugs) @@ -140,6 +141,39 @@ test runners like Jest. [vitest]: https://vitest.dev/ [setup docs]: https://testing-library.com/docs/svelte-testing-library/setup +### Auto-cleanup + +In Vitest (via the `svelteTesting` plugin) and Jest (via the `beforeEach` and `afterEach` globals), +this library will automatically setup and cleanup the test environment before and after each test. + +To do your own cleanup, or if you're using another framework, call the `setup` and `cleanup` functions yourself: + +```js +import { cleanup, render, setup } from '@testing-library/svelte' + +// before +setup() + +// test +render(/* ... */) + +// after +cleanup() +``` + +To disable auto-cleanup in Vitest, set the `autoCleanup` option of the plugin to false: + +```js +svelteTesting({ autoCleanup: false }) +``` + +To disable auto-cleanup in Jest and other frameworks with global test hooks, +set the `STL_SKIP_AUTO_CLEANUP` environment variable: + +```shell +STL_SKIP_AUTO_CLEANUP=1 jest +``` + ## Docs See the [**docs**][stl-docs] over at the Testing Library website. diff --git a/jest.config.js b/jest.config.js index b590229..f4daad1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,7 +14,7 @@ export default { extensionsToTreatAsEsm: ['.svelte'], testEnvironment: 'jsdom', setupFilesAfterEnv: ['/tests/_jest-setup.js'], - injectGlobals: false, + injectGlobals: true, moduleNameMapper: { '^vitest$': '/tests/_jest-vitest-alias.js', [String.raw`^@testing-library\/svelte$`]: '/src/index.js', diff --git a/src/index.js b/src/index.js index 5a65c08..2c88a28 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,22 @@ -import { act, cleanup } from './pure.js' +import { act, cleanup, setup } from './pure.js' -// If we're running in a test runner that supports afterEach -// then we'll automatically run cleanup afterEach test +// If we're running in a test runner that supports beforeEach/afterEach +// we'll automatically run setup and cleanup before and after each test // this ensures that tests run in isolation from each other // if you don't like this then set the STL_SKIP_AUTO_CLEANUP env variable. -if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) { - afterEach(async () => { - await act() - cleanup() - }) +if (typeof process !== 'undefined' && !process.env.STL_SKIP_AUTO_CLEANUP) { + if (typeof beforeEach === 'function') { + beforeEach(() => { + setup() + }) + } + + if (typeof afterEach === 'function') { + afterEach(async () => { + await act() + cleanup() + }) + } } // export all base queries, screen, etc. diff --git a/src/pure.js b/src/pure.js index 44e68d6..583d063 100644 --- a/src/pure.js +++ b/src/pure.js @@ -1,9 +1,11 @@ import { + configure as configureDTL, fireEvent as baseFireEvent, + getConfig as getDTLConfig, getQueriesForElement, prettyDOM, } from '@testing-library/dom' -import { tick } from 'svelte' +import * as Svelte from 'svelte' import { mount, unmount, updateProps, validateOptions } from './core/index.js' @@ -94,7 +96,7 @@ const render = (Component, options = {}, renderOptions = {}) => { } updateProps(component, props) - await tick() + await Svelte.tick() }, unmount: () => { cleanupComponent(component) @@ -103,6 +105,33 @@ const render = (Component, options = {}, renderOptions = {}) => { } } +/** @type {import('@testing-library/dom'.Config | undefined} */ +let originalDTLConfig + +/** + * Configure `@testing-library/dom` for usage with Svelte. + * + * Ensures events fired from `@testing-library/dom` + * and `@testing-library/user-event` wait for Svelte + * to flush changes to the DOM before proceeding. + */ +const setup = () => { + originalDTLConfig = getDTLConfig() + + configureDTL({ + asyncWrapper: act, + eventWrapper: Svelte.flushSync ?? ((cb) => cb()), + }) +} + +/** Reset dom-testing-library config. */ +const cleanupDTL = () => { + if (originalDTLConfig) { + configureDTL(originalDTLConfig) + originalDTLConfig = undefined + } +} + /** Remove a component from the component cache. */ const cleanupComponent = (component) => { const inCache = componentCache.delete(component) @@ -121,7 +150,7 @@ const cleanupTarget = (target) => { } } -/** Unmount all components and remove elements added to ``. */ +/** Unmount components, remove elements added to ``, and reset `@testing-library/dom`. */ const cleanup = () => { for (const component of componentCache) { cleanupComponent(component) @@ -129,19 +158,23 @@ const cleanup = () => { for (const target of targetCache) { cleanupTarget(target) } + cleanupDTL() } /** * Call a function and wait for Svelte to flush pending changes. * - * @param {() => unknown} [fn] - A function, which may be `async`, to call before flushing updates. - * @returns {Promise} + * @template T + * @param {(() => Promise) | () => T} [fn] - A function, which may be `async`, to call before flushing updates. + * @returns {Promise} */ const act = async (fn) => { + let result if (fn) { - await fn() + result = await fn() } - return tick() + await Svelte.tick() + return result } /** @@ -162,18 +195,10 @@ const act = async (fn) => { * * @type {FireFunction & FireObject} */ -const fireEvent = async (...args) => { - const event = baseFireEvent(...args) - await tick() - return event -} +const fireEvent = async (...args) => act(() => baseFireEvent(...args)) for (const [key, baseEvent] of Object.entries(baseFireEvent)) { - fireEvent[key] = async (...args) => { - const event = baseEvent(...args) - await tick() - return event - } + fireEvent[key] = async (...args) => act(() => baseEvent(...args)) } -export { act, cleanup, fireEvent, render } +export { act, cleanup, fireEvent, render, setup } diff --git a/src/vitest.js b/src/vitest.js index 71977e6..ebbbb41 100644 --- a/src/vitest.js +++ b/src/vitest.js @@ -1,7 +1,11 @@ -import { act, cleanup } from '@testing-library/svelte' -import { afterEach } from 'vitest' +import { act, cleanup, setup } from '@testing-library/svelte' +import { beforeEach } from 'vitest' -afterEach(async () => { - await act() - cleanup() +beforeEach(() => { + setup() + + return async () => { + await act() + cleanup() + } }) diff --git a/tests/_jest-setup.js b/tests/_jest-setup.js index d1c255c..2c9b6f7 100644 --- a/tests/_jest-setup.js +++ b/tests/_jest-setup.js @@ -1,9 +1 @@ import '@testing-library/jest-dom/jest-globals' - -import { afterEach } from '@jest/globals' -import { act, cleanup } from '@testing-library/svelte' - -afterEach(async () => { - await act() - cleanup() -}) diff --git a/tests/_jest-vitest-alias.js b/tests/_jest-vitest-alias.js index 6628c80..a09c310 100644 --- a/tests/_jest-vitest-alias.js +++ b/tests/_jest-vitest-alias.js @@ -11,9 +11,10 @@ export { jest as vi, } from '@jest/globals' -// Add support for describe.skipIf and test.skipIf +// Add support for describe.skipIf, test.skipIf, and test.runIf describe.skipIf = (condition) => (condition ? describe.skip : describe) test.skipIf = (condition) => (condition ? test.skip : test) +test.runIf = (condition) => (condition ? test : test.skip) // Add support for `stubGlobal` jest.stubGlobal = (property, stub) => { diff --git a/tests/act.test.js b/tests/act.test.js index 9308d75..39cf93e 100644 --- a/tests/act.test.js +++ b/tests/act.test.js @@ -1,6 +1,7 @@ import { setTimeout } from 'node:timers/promises' import { act, render, screen } from '@testing-library/svelte' +import { userEvent } from '@testing-library/user-event' import { describe, expect, test } from 'vitest' import Comp from './fixtures/Comp.svelte' @@ -24,10 +25,20 @@ describe('act', () => { const button = screen.getByText('Button') await act(async () => { - await setTimeout(100) + await setTimeout(10) button.click() }) expect(button).toHaveTextContent('Button Clicked') }) + + test('wires act into user-event', async () => { + const user = userEvent.setup() + render(Comp) + const button = screen.getByText('Button') + + await user.click(button) + + expect(button).toHaveTextContent('Button Clicked') + }) }) diff --git a/tests/auto-cleanup.test.js b/tests/auto-cleanup.test.js index d6ba28b..391146e 100644 --- a/tests/auto-cleanup.test.js +++ b/tests/auto-cleanup.test.js @@ -6,15 +6,18 @@ import { IS_JEST } from './_env.js' // in Jest breaks Svelte's environment checking heuristics. // Re-implement this test in a more accurate environment, without mocks. describe.skipIf(IS_JEST)('auto-cleanup', () => { + const globalBeforeEach = vi.fn() const globalAfterEach = vi.fn() beforeEach(() => { vi.resetModules() + globalThis.beforeEach = globalBeforeEach globalThis.afterEach = globalAfterEach }) afterEach(() => { delete process.env.STL_SKIP_AUTO_CLEANUP + delete globalThis.beforeEach delete globalThis.afterEach }) @@ -37,6 +40,7 @@ describe.skipIf(IS_JEST)('auto-cleanup', () => { await import('@testing-library/svelte') + expect(globalBeforeEach).toHaveBeenCalledTimes(0) expect(globalAfterEach).toHaveBeenCalledTimes(0) }) }) diff --git a/tests/events.test.js b/tests/events.test.js index 9864692..254ae57 100644 --- a/tests/events.test.js +++ b/tests/events.test.js @@ -1,6 +1,8 @@ +import { fireEvent as fireEventDTL } from '@testing-library/dom' import { fireEvent, render, screen } from '@testing-library/svelte' import { describe, expect, test } from 'vitest' +import { IS_SVELTE_5 } from './_env.js' import Comp from './fixtures/Comp.svelte' describe('events', () => { @@ -29,4 +31,14 @@ describe('events', () => { await expect(result).resolves.toBe(true) expect(button).toHaveTextContent('Button Clicked') }) + + test.runIf(IS_SVELTE_5)('state changes are flushed synchronously', () => { + render(Comp, { props: { name: 'World' } }) + const button = screen.getByText('Button') + + const result = fireEventDTL.click(button) + + expect(result).toBe(true) + expect(button).toHaveTextContent('Button Clicked') + }) })