Skip to content

fix: configure dom-testing-library to flush Svelte changes #439

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
- [This Solution](#this-solution)
- [Installation](#installation)
- [Setup](#setup)
- [Auto-cleanup](#auto-cleanup)
- [Docs](#docs)
- [Issues](#issues)
- [🐛 Bugs](#-bugs)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 16 additions & 8 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
61 changes: 43 additions & 18 deletions src/pure.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -94,7 +96,7 @@ const render = (Component, options = {}, renderOptions = {}) => {
}

updateProps(component, props)
await tick()
await Svelte.tick()
},
unmount: () => {
cleanupComponent(component)
Expand All @@ -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)
Expand All @@ -121,27 +150,31 @@ 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)
}
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
}

/**
Expand All @@ -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 }
14 changes: 9 additions & 5 deletions src/vitest.js
Original file line number Diff line number Diff line change
@@ -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()
}
})
8 changes: 0 additions & 8 deletions tests/_jest-setup.js
Original file line number Diff line number Diff line change
@@ -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()
})
3 changes: 2 additions & 1 deletion tests/_jest-vitest-alias.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
13 changes: 12 additions & 1 deletion tests/act.test.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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')
})
})
4 changes: 4 additions & 0 deletions tests/auto-cleanup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand All @@ -37,6 +40,7 @@ describe.skipIf(IS_JEST)('auto-cleanup', () => {

await import('@testing-library/svelte')

expect(globalBeforeEach).toHaveBeenCalledTimes(0)
expect(globalAfterEach).toHaveBeenCalledTimes(0)
})
})
12 changes: 12 additions & 0 deletions tests/events.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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')
})
})