diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index be02b70..136111f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,8 +37,9 @@ jobs:
- { svelte: '5', node: '16' }
- { svelte: '5', node: '18' }
include:
- # We only need to lint once, so do it on latest Node and Svelte
+ # Only lint and test examples on latest Node and Svelte
- { svelte: '5', node: '22', check: 'lint' }
+ - { svelte: '5', node: '22', check: 'test:examples' }
# Run type checks in latest applicable Node
- { svelte: '3', node: '20', check: 'types:legacy' }
- { svelte: '4', node: '22', check: 'types:legacy' }
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 41193a3..328bcda 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -77,10 +77,10 @@ npm run all:legacy
### Docs
-Use the `toc` script to ensure the README's table of contents is up to date:
+Use the `docs` script to ensure the README's table of contents is up to date:
```shell
-npm run toc
+npm run docs
```
Use `contributors:add` to add a contributor to the README:
diff --git a/README.md b/README.md
index 29429d4..6a5f1d9 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,10 @@
Simple and complete Svelte testing utilities that encourage good testing practices.
-[**Read The Docs**][stl-docs] | [Edit the docs][stl-docs-repo]
+[**Read The Docs**][stl-docs] | [Edit the docs][stl-docs-repo] | [Examples](./examples)
+
[![Build Status][build-badge]][build]
[![Code Coverage][coverage-badge]][coverage]
[![version][version-badge]][package]
@@ -29,7 +30,9 @@
[![Watch on GitHub][github-watch-badge]][github-watch]
[![Star on GitHub][github-star-badge]][github-star]
[![Tweet][twitter-badge]][twitter]
+
+
@@ -63,9 +66,6 @@
## Table of Contents
-
-
-
- [The Problem](#the-problem)
- [This Solution](#this-solution)
- [Installation](#installation)
@@ -78,8 +78,6 @@
- [❓ Questions](#-questions)
- [Contributors](#contributors)
-
-
## The Problem
You want to write maintainable tests for your [Svelte][svelte] components.
@@ -217,8 +215,11 @@ instead of filing an issue on GitHub.
Thanks goes to these people ([emoji key][emojis]):
+
+
+
@@ -246,6 +247,7 @@ Thanks goes to these people ([emoji key][emojis]):
+{/if}
diff --git a/examples/basic/basic.test.js b/examples/basic/basic.test.js
new file mode 100644
index 0000000..6ea6099
--- /dev/null
+++ b/examples/basic/basic.test.js
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './basic.svelte'
+
+test('no initial greeting', () => {
+ render(Subject, { name: 'World' })
+
+ const button = screen.getByRole('button', { name: 'Greet' })
+ const greeting = screen.queryByText(/hello/iu)
+
+ expect(button).toBeInTheDocument()
+ expect(greeting).not.toBeInTheDocument()
+})
+
+test('greeting appears on click', async () => {
+ const user = userEvent.setup()
+ render(Subject, { name: 'World' })
+
+ const button = screen.getByRole('button')
+ await user.click(button)
+ const greeting = screen.getByText(/hello world/iu)
+
+ expect(greeting).toBeInTheDocument()
+})
diff --git a/examples/basic/readme.md b/examples/basic/readme.md
new file mode 100644
index 0000000..6d98485
--- /dev/null
+++ b/examples/basic/readme.md
@@ -0,0 +1,68 @@
+# Basic
+
+This basic example demonstrates how to:
+
+- Pass props to your Svelte component using [render()]
+- [Query][] the structure of your component's DOM elements using screen
+- Interact with your component using [@testing-library/user-event][]
+- Make assertions using expect, using matchers from
+ [@testing-library/jest-dom][]
+
+[query]: https://testing-library.com/docs/queries/about
+[render()]: https://testing-library.com/docs/svelte-testing-library/api#render
+[@testing-library/user-event]: https://testing-library.com/docs/user-event/intro
+[@testing-library/jest-dom]: https://github.com/testing-library/jest-dom
+
+## Table of contents
+
+- [`basic.svelte`](#basicsvelte)
+- [`basic.test.js`](#basictestjs)
+
+## `basic.svelte`
+
+```svelte file=./basic.svelte
+
+
+
+
+{#if showGreeting}
+
Hello {name}
+{/if}
+```
+
+## `basic.test.js`
+
+```js file=./basic.test.js
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './basic.svelte'
+
+test('no initial greeting', () => {
+ render(Subject, { name: 'World' })
+
+ const button = screen.getByRole('button', { name: 'Greet' })
+ const greeting = screen.queryByText(/hello/iu)
+
+ expect(button).toBeInTheDocument()
+ expect(greeting).not.toBeInTheDocument()
+})
+
+test('greeting appears on click', async () => {
+ const user = userEvent.setup()
+ render(Subject, { name: 'World' })
+
+ const button = screen.getByRole('button')
+ await user.click(button)
+ const greeting = screen.getByText(/hello world/iu)
+
+ expect(greeting).toBeInTheDocument()
+})
+```
diff --git a/examples/binds/bind.svelte b/examples/binds/bind.svelte
new file mode 100644
index 0000000..62fd858
--- /dev/null
+++ b/examples/binds/bind.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/examples/binds/bind.test.js b/examples/binds/bind.test.js
new file mode 100644
index 0000000..6d00665
--- /dev/null
+++ b/examples/binds/bind.test.js
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './bind.svelte'
+
+test('value binding', async () => {
+ const user = userEvent.setup()
+ let value = ''
+
+ render(Subject, {
+ get value() {
+ return value
+ },
+ set value(nextValue) {
+ value = nextValue
+ },
+ })
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'hello world')
+
+ expect(value).toBe('hello world')
+})
diff --git a/examples/binds/no-bind.svelte b/examples/binds/no-bind.svelte
new file mode 100644
index 0000000..3e4079a
--- /dev/null
+++ b/examples/binds/no-bind.svelte
@@ -0,0 +1,9 @@
+
+
+
diff --git a/examples/binds/readme.md b/examples/binds/readme.md
new file mode 100644
index 0000000..fb7c745
--- /dev/null
+++ b/examples/binds/readme.md
@@ -0,0 +1,82 @@
+# Binds
+
+Two-way data binding using [bindable() props][] is difficult to test directly.
+It's usually easier to structure your code so that you can test user-facing
+results, leaving the binding as an implementation detail.
+
+However, if two-way binding is an important developer-facing API of your
+component, you can use setters to test your binding.
+
+[bindable() props]: https://svelte.dev/docs/svelte/$bindable
+
+## Table of contents
+
+- [`bind.svelte`](#bindsvelte)
+- [`bind.test.js`](#bindtestjs)
+- [Consider avoiding binding](#consider-avoiding-binding)
+
+## `bind.svelte`
+
+```svelte file=./bind.svelte
+
+
+
+```
+
+## `bind.test.js`
+
+```js file=./bind.test.js
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './bind.svelte'
+
+test('value binding', async () => {
+ const user = userEvent.setup()
+ let value = ''
+
+ render(Subject, {
+ get value() {
+ return value
+ },
+ set value(nextValue) {
+ value = nextValue
+ },
+ })
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'hello world')
+
+ expect(value).toBe('hello world')
+})
+```
+
+## Consider avoiding binding
+
+Before embarking on writing tests for bindable props, consider avoiding
+`bindable()` entirely. Two-way data binding can make your data flows and state
+changes difficult to reason about and test effectively. Instead, you can use
+value props to pass data down and callback props to pass changes back up to the
+parent.
+
+> Well-written applications use bindings very sparingly — the vast majority of
+> data flow should be top-down --
+> [Rich Harris](https://github.com/sveltejs/svelte/issues/10768#issue-2181814844)
+
+For example, rather than using a `bindable()` prop, use a value prop to pass the
+value down and callback prop to send changes back up to the parent:
+
+```svelte file=./no-bind.svelte
+
+
+
+```
diff --git a/examples/contexts/context.svelte b/examples/contexts/context.svelte
new file mode 100644
index 0000000..c0fbb19
--- /dev/null
+++ b/examples/contexts/context.svelte
@@ -0,0 +1,14 @@
+
+
+
+ {#each messages.current as message (message.id)}
+
{message.text}
+
+ {/each}
+
diff --git a/examples/contexts/context.test.js b/examples/contexts/context.test.js
new file mode 100644
index 0000000..4a433d2
--- /dev/null
+++ b/examples/contexts/context.test.js
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './context.svelte'
+
+test('notifications with messages from context', async () => {
+ const messages = {
+ get current() {
+ return [
+ { id: 'abc', text: 'hello' },
+ { id: 'def', text: 'world' },
+ ]
+ },
+ }
+
+ render(Subject, {
+ context: new Map([['messages', messages]]),
+ props: { label: 'Notifications' },
+ })
+
+ const status = screen.getByRole('status', { name: 'Notifications' })
+
+ expect(status).toHaveTextContent('hello world')
+})
diff --git a/examples/contexts/readme.md b/examples/contexts/readme.md
new file mode 100644
index 0000000..15284a9
--- /dev/null
+++ b/examples/contexts/readme.md
@@ -0,0 +1,61 @@
+# Context
+
+If your component requires access to contexts, you can pass those contexts in
+when you render the component. When using extra [component options][] like
+`context`, be sure to place props under the `props` key.
+
+[component options]:
+ https://testing-library.com/docs/svelte-testing-library/api#component-options
+
+## Table of contents
+
+- [`context.svelte`](#contextsvelte)
+- [`context.test.js`](#contexttestjs)
+
+## `context.svelte`
+
+```svelte file=./context.svelte
+
+
+
+ {#each messages.current as message (message.id)}
+
diff --git a/examples/deprecated/deprecated-slot.test.js b/examples/deprecated/deprecated-slot.test.js
new file mode 100644
index 0000000..dfe73b8
--- /dev/null
+++ b/examples/deprecated/deprecated-slot.test.js
@@ -0,0 +1,13 @@
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './deprecated-slot.test.svelte'
+
+test('heading with slot', () => {
+ render(SubjectTest)
+
+ const heading = screen.getByRole('heading')
+ const child = within(heading).getByTestId('child')
+
+ expect(child).toBeInTheDocument()
+})
diff --git a/examples/deprecated/deprecated-slot.test.svelte b/examples/deprecated/deprecated-slot.test.svelte
new file mode 100644
index 0000000..92b5f6b
--- /dev/null
+++ b/examples/deprecated/deprecated-slot.test.svelte
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/examples/deprecated/readme.md b/examples/deprecated/readme.md
new file mode 100644
index 0000000..ca1a8d1
--- /dev/null
+++ b/examples/deprecated/readme.md
@@ -0,0 +1,105 @@
+# Deprecated Svelte 3/4 features
+
+Several features from Svelte 3 and 4 have been deprecated in Svelte 5, but while
+you still have components using the old syntax, or if you haven't yet updated to
+Svelte 5, you can continue to use `@testing-library/svelte` to test your
+components.
+
+## Table of contents
+
+- [Events](#events)
+ - [`deprecated-event.svelte`](#deprecated-eventsvelte)
+ - [`deprecated-event.test.js`](#deprecated-eventtestjs)
+- [Slots](#slots)
+ - [`deprecated-slot.svelte`](#deprecated-slotsvelte)
+ - [`deprecated-slot.test.svelte`](#deprecated-slottestsvelte)
+ - [`deprecated-slot.test.js`](#deprecated-slottestjs)
+
+## Events
+
+The `on:event` syntax was deprecated in favor of callback props. However, if you
+have updated your Svelte runtime to version 5, you can use the `events`
+component option to continue to test events in older components.
+
+### `deprecated-event.svelte`
+
+```svelte file=./deprecated-event.svelte
+
+```
+
+### `deprecated-event.test.js`
+
+> \[!WARNING]
+>
+> If you are still using Svelte version 3 or 4, `render` will **not** have an
+> `events` option. Instead, use `component.$on` to attach an event listener.
+>
+> ```js
+> const onClick = vi.fn()
+>
+> const { component } = render(Subject)
+> component.$on('click', onClick)
+> ```
+
+```js file=./deprecated-event.test.js
+import { render, screen } from '@testing-library/svelte'
+import userEvent from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+
+import Subject from './deprecated-event.svelte'
+
+test('on:click event', async () => {
+ const user = userEvent.setup()
+ const onClick = vi.fn()
+
+ render(Subject, { events: { click: onClick } })
+
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ expect(onClick).toHaveBeenCalledOnce()
+})
+```
+
+## Slots
+
+The slots feature was deprecated in favor of snippets. If you have components
+that still use slots, you can create a wrapper component to test them.
+
+### `deprecated-slot.svelte`
+
+```svelte file=./deprecated-slot.svelte
+
+
+
+```
+
+### `deprecated-slot.test.svelte`
+
+```svelte file=./deprecated-slot.test.svelte
+
+
+
+
+
+```
+
+### `deprecated-slot.test.js`
+
+```js file=deprecated-slot.test.js
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './deprecated-slot.test.svelte'
+
+test('heading with slot', () => {
+ render(SubjectTest)
+
+ const heading = screen.getByRole('heading')
+ const child = within(heading).getByTestId('child')
+
+ expect(child).toBeInTheDocument()
+})
+```
diff --git a/examples/events/event.svelte b/examples/events/event.svelte
new file mode 100644
index 0000000..3562da1
--- /dev/null
+++ b/examples/events/event.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/examples/events/event.test.js b/examples/events/event.test.js
new file mode 100644
index 0000000..c37902b
--- /dev/null
+++ b/examples/events/event.test.js
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+
+import Subject from './event.svelte'
+
+test('onclick event', async () => {
+ const user = userEvent.setup()
+ const onclick = vi.fn()
+
+ render(Subject, { onclick })
+
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ expect(onclick).toHaveBeenCalledOnce()
+})
diff --git a/examples/events/readme.md b/examples/events/readme.md
new file mode 100644
index 0000000..7af235a
--- /dev/null
+++ b/examples/events/readme.md
@@ -0,0 +1,43 @@
+# Events
+
+Events can be tested using spy functions. If you're using Vitest you can use
+[vi.fn()][] to create a spy.
+
+[vi.fn()]: https://vitest.dev/api/vi.html#vi-fn
+
+## Table of contents
+
+- [`event.svelte`](#eventsvelte)
+- [`event.test.js`](#eventtestjs)
+
+## `event.svelte`
+
+```svelte file=./event.svelte
+
+
+
+```
+
+## `event.test.js`
+
+```js file=./event.test.js
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+
+import Subject from './event.svelte'
+
+test('onclick event', async () => {
+ const user = userEvent.setup()
+ const onclick = vi.fn()
+
+ render(Subject, { onclick })
+
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ expect(onclick).toHaveBeenCalledOnce()
+})
+```
diff --git a/examples/readme.md b/examples/readme.md
new file mode 100644
index 0000000..e8c7e26
--- /dev/null
+++ b/examples/readme.md
@@ -0,0 +1,8 @@
+# `@testing-library/svelte` examples
+
+- [Basic](./basic)
+- [Events](./events)
+- [Snippets](./snippets)
+- [Contexts](./contexts)
+- [Binds](./binds)
+- [Deprecated Svelte 3 and 4 features](./deprecated)
diff --git a/examples/snippets/basic-snippet.svelte b/examples/snippets/basic-snippet.svelte
new file mode 100644
index 0000000..55e1f71
--- /dev/null
+++ b/examples/snippets/basic-snippet.svelte
@@ -0,0 +1,7 @@
+
+
+
+ {@render children?.()}
+
diff --git a/examples/snippets/basic-snippet.test.js b/examples/snippets/basic-snippet.test.js
new file mode 100644
index 0000000..11cf95c
--- /dev/null
+++ b/examples/snippets/basic-snippet.test.js
@@ -0,0 +1,13 @@
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './basic-snippet.test.svelte'
+
+test('basic snippet', () => {
+ render(SubjectTest)
+
+ const heading = screen.getByRole('heading')
+ const child = within(heading).getByTestId('child')
+
+ expect(child).toBeInTheDocument()
+})
diff --git a/examples/snippets/basic-snippet.test.svelte b/examples/snippets/basic-snippet.test.svelte
new file mode 100644
index 0000000..e96cac7
--- /dev/null
+++ b/examples/snippets/basic-snippet.test.svelte
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/examples/snippets/complex-snippet.svelte b/examples/snippets/complex-snippet.svelte
new file mode 100644
index 0000000..81e8a70
--- /dev/null
+++ b/examples/snippets/complex-snippet.svelte
@@ -0,0 +1,9 @@
+
+
+
+ {@render message?.(greeting)}
+
diff --git a/examples/snippets/complex-snippet.test.js b/examples/snippets/complex-snippet.test.js
new file mode 100644
index 0000000..55885ef
--- /dev/null
+++ b/examples/snippets/complex-snippet.test.js
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/svelte'
+import { createRawSnippet } from 'svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './complex-snippet.svelte'
+
+test('renders greeting in message snippet', () => {
+ render(Subject, {
+ name: 'Alice',
+ message: createRawSnippet((greeting) => ({
+ render: () => `${greeting()}`,
+ })),
+ })
+
+ const message = screen.getByTestId('message')
+
+ expect(message).toHaveTextContent('Hello, Alice!')
+})
diff --git a/examples/snippets/readme.md b/examples/snippets/readme.md
new file mode 100644
index 0000000..30a70d6
--- /dev/null
+++ b/examples/snippets/readme.md
@@ -0,0 +1,108 @@
+# Snippets
+
+Snippets are difficult to test directly. It's usually easier to structure your
+code so that you can test the user-facing results, leaving any snippets as an
+implementation detail. However, if snippets are an important developer-facing
+API of your component, there are several strategies you can use.
+
+## Table of contents
+
+- [Basic snippets example](#basic-snippets-example)
+ - [`basic-snippet.svelte`](#basic-snippetsvelte)
+ - [`basic-snippet.test.svelte`](#basic-snippettestsvelte)
+ - [`basic-snippet.test.js`](#basic-snippettestjs)
+- [Using `createRawSnippet`](#using-createrawsnippet)
+ - [`complex-snippet.svelte`](#complex-snippetsvelte)
+ - [`complex-snippet.test.js`](#complex-snippettestjs)
+
+## Basic snippets example
+
+For simple snippets, you can use a wrapper component and "dummy" children to
+test them. Setting `data-testid` attributes can be helpful when testing slots in
+this manner.
+
+### `basic-snippet.svelte`
+
+```svelte file=./basic-snippet.svelte
+
+
+
+ {@render children?.()}
+
+```
+
+### `basic-snippet.test.svelte`
+
+```svelte file=./basic-snippet.test.svelte
+
+
+
+
+
+```
+
+### `basic-snippet.test.js`
+
+```js file=./basic-snippet.test.js
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './basic-snippet.test.svelte'
+
+test('basic snippet', () => {
+ render(SubjectTest)
+
+ const heading = screen.getByRole('heading')
+ const child = within(heading).getByTestId('child')
+
+ expect(child).toBeInTheDocument()
+})
+```
+
+## Using `createRawSnippet`
+
+For more complex snippets, e.g. where you want to check arguments, you can use
+Svelte's [createRawSnippet][] API.
+
+[createRawSnippet]: https://svelte.dev/docs/svelte/svelte#createRawSnippet
+
+### `complex-snippet.svelte`
+
+```svelte file=./complex-snippet.svelte
+
+
+
+ {@render message?.(greeting)}
+
+```
+
+### `complex-snippet.test.js`
+
+```js file=./complex-snippet.test.js
+import { render, screen } from '@testing-library/svelte'
+import { createRawSnippet } from 'svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './complex-snippet.svelte'
+
+test('renders greeting in message snippet', () => {
+ render(Subject, {
+ name: 'Alice',
+ message: createRawSnippet((greeting) => ({
+ render: () => `${greeting()}`,
+ })),
+ })
+
+ const message = screen.getByTestId('message')
+
+ expect(message).toHaveTextContent('Hello, Alice!')
+})
+```
diff --git a/package.json b/package.json
index fea66a7..14e8cf9 100644
--- a/package.json
+++ b/package.json
@@ -52,17 +52,18 @@
"types"
],
"scripts": {
- "all": "npm-run-all contributors:generate toc format types build test:vitest:* test:jest",
+ "all": "npm-run-all contributors:generate docs format types build test:vitest:* test:jest test:examples",
"all:legacy": "npm-run-all types:legacy test:vitest:* test:jest",
- "toc": "doctoc README.md",
+ "docs": "remark --output --use remark-toc --use remark-code-import --use unified-prettier README.md examples",
"lint": "prettier . --check && eslint .",
"format": "prettier . --write && eslint . --fix",
"setup": "npm run install:5 && npm run all",
"test": "vitest run --coverage",
"test:watch": "vitest",
- "test:vitest:jsdom": "vitest run --coverage --environment jsdom",
- "test:vitest:happy-dom": "vitest run --coverage --environment happy-dom",
+ "test:vitest:jsdom": "vitest run tests --coverage --environment jsdom",
+ "test:vitest:happy-dom": "vitest run tests --coverage --environment happy-dom",
"test:jest": "npx --node-options=\"--experimental-vm-modules --no-warnings\" jest --coverage",
+ "test:examples": "vitest run examples --coverage",
"types": "svelte-check",
"types:legacy": "svelte-check --tsconfig tsconfig.legacy.json",
"build": "tsc -p tsconfig.build.json && cp src/component-types.d.ts types",
@@ -98,7 +99,6 @@
"@vitest/coverage-v8": "^3.1.3",
"@vitest/eslint-plugin": "^1.1.44",
"all-contributors-cli": "^6.26.1",
- "doctoc": "^2.2.1",
"eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-jest-dom": "^5.5.0",
@@ -116,12 +116,16 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
+ "remark-cli": "^12.0.1",
+ "remark-code-import": "^1.2.0",
+ "remark-toc": "^9.0.0",
"svelte": "^5.28.2",
"svelte-check": "^4.1.7",
"svelte-jester": "^5.0.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.0",
"typescript-svelte-plugin": "^0.3.46",
+ "unified-prettier": "^2.0.1",
"vite": "^6.3.5",
"vitest": "^3.1.3"
}
diff --git a/prettier.config.js b/prettier.config.js
index 8c5a52d..55343f2 100644
--- a/prettier.config.js
+++ b/prettier.config.js
@@ -10,5 +10,12 @@ export default {
parser: 'svelte',
},
},
+ {
+ files: 'examples/**/*.md',
+ options: {
+ printWidth: 80,
+ proseWrap: 'always',
+ },
+ },
],
}
diff --git a/src/component-types.d.ts b/src/component-types.d.ts
index 007f1e4..1281763 100644
--- a/src/component-types.d.ts
+++ b/src/component-types.d.ts
@@ -1,4 +1,4 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-deprecated */
import type {
Component as ModernComponent,
ComponentConstructorOptions as LegacyConstructorOptions,
diff --git a/src/core/cleanup.js b/src/core/cleanup.js
new file mode 100644
index 0000000..2aa70da
--- /dev/null
+++ b/src/core/cleanup.js
@@ -0,0 +1,32 @@
+/** @type {Set<() => void>} */
+const cleanupTasks = new Set()
+
+/**
+ * Register later cleanup task
+ *
+ * @param {() => void} onCleanup
+ */
+const addCleanupTask = (onCleanup) => {
+ cleanupTasks.add(onCleanup)
+ return onCleanup
+}
+
+/**
+ * Remove a cleanup task without running it.
+ *
+ * @param {() => void} onCleanup
+ */
+const removeCleanupTask = (onCleanup) => {
+ cleanupTasks.delete(onCleanup)
+}
+
+/** Clean up all components and elements added to the document. */
+const cleanup = () => {
+ for (const handleCleanup of cleanupTasks.values()) {
+ handleCleanup()
+ }
+
+ cleanupTasks.clear()
+}
+
+export { addCleanupTask, cleanup, removeCleanupTask }
diff --git a/src/core/index.js b/src/core/index.js
index 9e41adf..ffdd5ac 100644
--- a/src/core/index.js
+++ b/src/core/index.js
@@ -5,15 +5,9 @@
* Will switch to legacy, class-based mounting logic
* if it looks like we're in a Svelte <= 4 environment.
*/
-import * as LegacyCore from './legacy.js'
-import * as ModernCore from './modern.svelte.js'
-import { createValidateOptions } from './validate-options.js'
-
-const { mount, unmount, updateProps, allowedOptions } =
- ModernCore.IS_MODERN_SVELTE ? ModernCore : LegacyCore
-
-/** Validate component options. */
-const validateOptions = createValidateOptions(allowedOptions)
-
-export { mount, unmount, updateProps, validateOptions }
-export { UnknownSvelteOptionsError } from './validate-options.js'
+export { addCleanupTask, cleanup } from './cleanup.js'
+export { mount } from './mount.js'
+export {
+ UnknownSvelteOptionsError,
+ validateOptions,
+} from './validate-options.js'
diff --git a/src/core/legacy.js b/src/core/legacy.js
deleted file mode 100644
index c9e6d1c..0000000
--- a/src/core/legacy.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * Legacy rendering core for svelte-testing-library.
- *
- * Supports Svelte <= 4.
- */
-
-/** Allowed options for the component constructor. */
-const allowedOptions = [
- 'target',
- 'accessors',
- 'anchor',
- 'props',
- 'hydrate',
- 'intro',
- 'context',
-]
-
-/**
- * Mount the component into the DOM.
- *
- * The `onDestroy` callback is included for strict backwards compatibility
- * with previous versions of this library. It's mostly unnecessary logic.
- */
-const mount = (Component, options, onDestroy) => {
- const component = new Component(options)
-
- if (typeof onDestroy === 'function') {
- component.$$.on_destroy.push(() => {
- onDestroy(component)
- })
- }
-
- return component
-}
-
-/** Remove the component from the DOM. */
-const unmount = (component) => {
- component.$destroy()
-}
-
-/** Update the component's props. */
-const updateProps = (component, nextProps) => {
- component.$set(nextProps)
-}
-
-export { allowedOptions, mount, unmount, updateProps }
diff --git a/src/core/modern.svelte.js b/src/core/modern.svelte.js
deleted file mode 100644
index 34893f5..0000000
--- a/src/core/modern.svelte.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * Modern rendering core for svelte-testing-library.
- *
- * Supports Svelte >= 5.
- */
-import * as Svelte from 'svelte'
-
-/** Props signals for each rendered component. */
-const propsByComponent = new Map()
-
-/** Whether we're using Svelte >= 5. */
-const IS_MODERN_SVELTE = typeof Svelte.mount === 'function'
-
-/** Allowed options to the `mount` call. */
-const allowedOptions = [
- 'target',
- 'anchor',
- 'props',
- 'events',
- 'context',
- 'intro',
-]
-
-/** Mount the component into the DOM. */
-const mount = (Component, options) => {
- const props = $state(options.props ?? {})
- const component = Svelte.mount(Component, { ...options, props })
-
- Svelte.flushSync()
- propsByComponent.set(component, props)
-
- return component
-}
-
-/** Remove the component from the DOM. */
-const unmount = (component) => {
- propsByComponent.delete(component)
- Svelte.flushSync(() => Svelte.unmount(component))
-}
-
-/**
- * Update the component's props.
- *
- * Relies on the `$state` signal added in `mount`.
- */
-const updateProps = (component, nextProps) => {
- const prevProps = propsByComponent.get(component)
- Object.assign(prevProps, nextProps)
-}
-
-export { allowedOptions, IS_MODERN_SVELTE, mount, unmount, updateProps }
diff --git a/src/core/mount.js b/src/core/mount.js
new file mode 100644
index 0000000..56951ae
--- /dev/null
+++ b/src/core/mount.js
@@ -0,0 +1,95 @@
+/**
+ * Component rendering core, with support for Svelte 3, 4, and 5
+ */
+import * as Svelte from 'svelte'
+
+import { addCleanupTask, removeCleanupTask } from './cleanup.js'
+import { createProps } from './props.svelte.js'
+
+/** Whether we're using Svelte >= 5. */
+const IS_MODERN_SVELTE = typeof Svelte.mount === 'function'
+
+/** Allowed options to the `mount` call or legacy component constructor. */
+const ALLOWED_MOUNT_OPTIONS = IS_MODERN_SVELTE
+ ? ['target', 'anchor', 'props', 'events', 'context', 'intro']
+ : ['target', 'accessors', 'anchor', 'props', 'hydrate', 'intro', 'context']
+
+/** Mount a modern Svelte 5 component into the DOM. */
+const mountModern = (Component, options) => {
+ const [props, updateProps] = createProps(options.props)
+ const component = Svelte.mount(Component, { ...options, props })
+
+ /** Remove the component from the DOM. */
+ const unmount = () => {
+ Svelte.flushSync(() => Svelte.unmount(component))
+ removeCleanupTask(unmount)
+ }
+
+ /** Update the component's props. */
+ const rerender = (nextProps) => {
+ Svelte.flushSync(() => updateProps(nextProps))
+ }
+
+ addCleanupTask(unmount)
+ Svelte.flushSync()
+
+ return { component, unmount, rerender }
+}
+
+/** Mount a legacy Svelte 3 or 4 component into the DOM. */
+const mountLegacy = (Component, options) => {
+ const component = new Component(options)
+
+ /** Remove the component from the DOM. */
+ const unmount = () => {
+ component.$destroy()
+ removeCleanupTask(unmount)
+ }
+
+ /** Update the component's props. */
+ const rerender = (nextProps) => {
+ component.$set(nextProps)
+ }
+
+ // This `$$.on_destroy` listener is included for strict backwards compatibility
+ // with previous versions of `@testing-library/svelte`.
+ // It's unnecessary and will be removed in a future major version.
+ component.$$.on_destroy.push(() => {
+ removeCleanupTask(unmount)
+ })
+
+ addCleanupTask(unmount)
+
+ return { component, unmount, rerender }
+}
+
+/** The mount method in use. */
+const mountComponent = IS_MODERN_SVELTE ? mountModern : mountLegacy
+
+/**
+ * Render a Svelte component into the document.
+ *
+ * @template {import('./types.js').Component} C
+ * @param {import('./types.js').ComponentType} Component
+ * @param {import('./types.js').MountOptions} options
+ * @returns {{
+ * component: C
+ * unmount: () => void
+ * rerender: (props: Partial>) => Promise
+ * }}
+ */
+const mount = (Component, options = {}) => {
+ const { component, unmount, rerender } = mountComponent(Component, options)
+
+ return {
+ component,
+ unmount,
+ rerender: async (props) => {
+ rerender(props)
+ // Await the next tick for Svelte 4, which cannot flush changes synchronously
+ await Svelte.tick()
+ },
+ }
+}
+
+export { ALLOWED_MOUNT_OPTIONS, mount }
diff --git a/src/core/props.svelte.js b/src/core/props.svelte.js
new file mode 100644
index 0000000..f181e72
--- /dev/null
+++ b/src/core/props.svelte.js
@@ -0,0 +1,38 @@
+/**
+ * Create a shallowly reactive props object.
+ *
+ * This allows us to update props on `rerender`
+ * without turing `props` into a deep set of Proxy objects
+ *
+ * @template {Record} Props
+ * @param {Props} initialProps
+ * @returns {[Props, (nextProps: Partial) => void]}
+ */
+const createProps = (initialProps) => {
+ const targetProps = initialProps ?? {}
+ let currentProps = $state.raw(targetProps)
+
+ const props = new Proxy(targetProps, {
+ get(_, key) {
+ return currentProps[key]
+ },
+ set(_, key, value) {
+ currentProps[key] = value
+ return true
+ },
+ has(_, key) {
+ return Reflect.has(currentProps, key)
+ },
+ ownKeys() {
+ return Reflect.ownKeys(currentProps)
+ },
+ })
+
+ const update = (nextProps) => {
+ currentProps = { ...currentProps, ...nextProps }
+ }
+
+ return [props, update]
+}
+
+export { createProps }
diff --git a/src/core/validate-options.js b/src/core/validate-options.js
index c0d794b..294a317 100644
--- a/src/core/validate-options.js
+++ b/src/core/validate-options.js
@@ -1,3 +1,5 @@
+import { ALLOWED_MOUNT_OPTIONS } from './mount.js'
+
class UnknownSvelteOptionsError extends TypeError {
constructor(unknownOptions, allowedOptions) {
super(`Unknown options.
@@ -15,9 +17,9 @@ class UnknownSvelteOptionsError extends TypeError {
}
}
-const createValidateOptions = (allowedOptions) => (options) => {
+const validateOptions = (options) => {
const isProps = !Object.keys(options).some((option) =>
- allowedOptions.includes(option)
+ ALLOWED_MOUNT_OPTIONS.includes(option)
)
if (isProps) {
@@ -26,14 +28,14 @@ const createValidateOptions = (allowedOptions) => (options) => {
// Check if any props and Svelte options were accidentally mixed.
const unknownOptions = Object.keys(options).filter(
- (option) => !allowedOptions.includes(option)
+ (option) => !ALLOWED_MOUNT_OPTIONS.includes(option)
)
if (unknownOptions.length > 0) {
- throw new UnknownSvelteOptionsError(unknownOptions, allowedOptions)
+ throw new UnknownSvelteOptionsError(unknownOptions, ALLOWED_MOUNT_OPTIONS)
}
return options
}
-export { createValidateOptions, UnknownSvelteOptionsError }
+export { UnknownSvelteOptionsError, validateOptions }
diff --git a/src/pure.js b/src/pure.js
index 583d063..fbb73c2 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -7,10 +7,7 @@ import {
} from '@testing-library/dom'
import * as Svelte from 'svelte'
-import { mount, unmount, updateProps, validateOptions } from './core/index.js'
-
-const targetCache = new Set()
-const componentCache = new Set()
+import { addCleanupTask, mount, validateOptions } from './core/index.js'
/**
* Customize how Svelte renders the component.
@@ -70,16 +67,17 @@ const render = (Component, options = {}, renderOptions = {}) => {
// eslint-disable-next-line unicorn/prefer-dom-node-append
options.target ?? baseElement.appendChild(document.createElement('div'))
- targetCache.add(target)
+ addCleanupTask(() => {
+ if (target.parentNode === document.body) {
+ target.remove()
+ }
+ })
- const component = mount(
+ const { component, unmount, rerender } = mount(
Component.default ?? Component,
- { ...options, target },
- cleanupComponent
+ { ...options, target }
)
- componentCache.add(component)
-
return {
baseElement,
component,
@@ -95,19 +93,13 @@ const render = (Component, options = {}, renderOptions = {}) => {
props = props.props
}
- updateProps(component, props)
- await Svelte.tick()
- },
- unmount: () => {
- cleanupComponent(component)
+ await rerender(props)
},
+ unmount,
...queries,
}
}
-/** @type {import('@testing-library/dom'.Config | undefined} */
-let originalDTLConfig
-
/**
* Configure `@testing-library/dom` for usage with Svelte.
*
@@ -116,49 +108,16 @@ let originalDTLConfig
* to flush changes to the DOM before proceeding.
*/
const setup = () => {
- originalDTLConfig = getDTLConfig()
+ const originalDTLConfig = getDTLConfig()
configureDTL({
asyncWrapper: act,
eventWrapper: Svelte.flushSync ?? ((cb) => cb()),
})
-}
-/** Reset dom-testing-library config. */
-const cleanupDTL = () => {
- if (originalDTLConfig) {
+ addCleanupTask(() => {
configureDTL(originalDTLConfig)
- originalDTLConfig = undefined
- }
-}
-
-/** Remove a component from the component cache. */
-const cleanupComponent = (component) => {
- const inCache = componentCache.delete(component)
-
- if (inCache) {
- unmount(component)
- }
-}
-
-/** Remove a target element from the target cache. */
-const cleanupTarget = (target) => {
- const inCache = targetCache.delete(target)
-
- if (inCache && target.parentNode === document.body) {
- target.remove()
- }
-}
-
-/** Unmount components, remove elements added to ``, and reset `@testing-library/dom`. */
-const cleanup = () => {
- for (const component of componentCache) {
- cleanupComponent(component)
- }
- for (const target of targetCache) {
- cleanupTarget(target)
- }
- cleanupDTL()
+ })
}
/**
@@ -201,4 +160,5 @@ for (const [key, baseEvent] of Object.entries(baseFireEvent)) {
fireEvent[key] = async (...args) => act(() => baseEvent(...args))
}
-export { act, cleanup, fireEvent, render, setup }
+export { cleanup } from './core/index.js'
+export { act, fireEvent, render, setup }
diff --git a/tests/_jest-vitest-alias.js b/tests/_jest-vitest-alias.js
index a09c310..fa93be9 100644
--- a/tests/_jest-vitest-alias.js
+++ b/tests/_jest-vitest-alias.js
@@ -13,6 +13,7 @@ export {
// Add support for describe.skipIf, test.skipIf, and test.runIf
describe.skipIf = (condition) => (condition ? describe.skip : describe)
+describe.runIf = (condition) => (condition ? describe : describe.skip)
test.skipIf = (condition) => (condition ? test.skip : test)
test.runIf = (condition) => (condition ? test : test.skip)
diff --git a/tests/act.test.js b/tests/act.test.js
index 39cf93e..c8bb007 100644
--- a/tests/act.test.js
+++ b/tests/act.test.js
@@ -26,6 +26,7 @@ describe('act', () => {
await act(async () => {
await setTimeout(10)
+
button.click()
})
diff --git a/tests/events.test.js b/tests/events.test.js
index 254ae57..db47d62 100644
--- a/tests/events.test.js
+++ b/tests/events.test.js
@@ -22,10 +22,7 @@ describe('events', () => {
const result = fireEvent(
button,
- new MouseEvent('click', {
- bubbles: true,
- cancelable: true,
- })
+ new MouseEvent('click', { bubbles: true, cancelable: true })
)
await expect(result).resolves.toBe(true)
@@ -36,7 +33,10 @@ describe('events', () => {
render(Comp, { props: { name: 'World' } })
const button = screen.getByText('Button')
- const result = fireEventDTL.click(button)
+ const result = fireEventDTL(
+ button,
+ new MouseEvent('click', { bubbles: true, cancelable: true })
+ )
expect(result).toBe(true)
expect(button).toHaveTextContent('Button Clicked')
diff --git a/tests/fixtures/PropCloner.svelte b/tests/fixtures/PropCloner.svelte
new file mode 100644
index 0000000..035f2fe
--- /dev/null
+++ b/tests/fixtures/PropCloner.svelte
@@ -0,0 +1,7 @@
+
diff --git a/tests/rerender.test.js b/tests/rerender.test.js
index 2fdff0d..34e9398 100644
--- a/tests/rerender.test.js
+++ b/tests/rerender.test.js
@@ -1,7 +1,7 @@
import { act, render, screen } from '@testing-library/svelte'
import { beforeAll, describe, expect, test, vi } from 'vitest'
-import { COMPONENT_FIXTURES, IS_SVELTE_5, MODE_RUNES } from './_env.js'
+import { COMPONENT_FIXTURES, IS_JEST, IS_SVELTE_5, MODE_RUNES } from './_env.js'
describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => {
let Comp
@@ -52,3 +52,20 @@ describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => {
expect(element).toHaveTextContent('Hello Planet!')
})
})
+
+// NOTE: Jest does not support `structuredClone`, used in this test
+// to check that `input` isn't turned into a Proxy
+describe.runIf(IS_SVELTE_5 && !IS_JEST)('reactive prop handling', () => {
+ let Comp
+
+ beforeAll(async () => {
+ Comp = await import('./fixtures/PropCloner.svelte')
+ })
+
+ test('does not interfere with props values', () => {
+ const { component } = render(Comp, { input: { hello: 'world' } })
+ const result = component.cloneInput()
+
+ expect(result).toEqual({ hello: 'world' })
+ })
+})