Repository: testing-library/svelte-testing-library
Pull request #439 (2025-05-16 19:22:46)

fix: configure dom-testing-library to flush Svelte changes
## Overview

While working on new examples for Svelte 5, I found a problem with this library's interop with `@testing-library/user-event` - depending on project setup, `await user.click()` et. al. does not wait for Svelte to flush changes.

We were missing a call to `configure` to `@testing-library/dom` to configure the `eventWrapper` (for `fireEvent`) and `asyncWrapper` (for `user-event`) options. This fixes the issue for `user-event`, and happily, removes the need for an async re-export of `fireEvent` once we're able to drop Svelte 3/4, thanks to the new `flushSync` export from `svelte`

Related (old) issue: #250

## Change log

- Add `setup` function to complement `cleanup` function that calls `@testing-library/dom::configure` with `tick` and `flushSync`
- Wire `setup` into `beforeEach` for Jest and Vitest
- Add cleanup setup to setup docs in README


Patch:
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: ['<rootDir>/tests/_jest-setup.js'],
-  injectGlobals: false,
+  injectGlobals: true,
   moduleNameMapper: {
     '^vitest$': '<rootDir>/tests/_jest-vitest-alias.js',
     [String.raw`^@testing-library\/svelte$`]: '<rootDir>/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 `<body>`. */
+/** Unmount components, remove elements added to `<body>`, 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<void>}
+ * @template T
+ * @param {(() => Promise<T>) | () => T} [fn] - A function, which may be `async`, to call before flushing updates.
+ * @returns {Promise<T>}
  */
 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 }


Test patch:
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,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')
+  })
 })