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]):
+ diff --git a/eslint.config.js b/eslint.config.js index c03de8c..092d967 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -75,7 +75,10 @@ export default tseslint.config( files: ['**/*.svelte'], rules: { 'svelte/no-unused-svelte-ignore': 'off', - 'unicorn/filename-case': ['error', { case: 'pascalCase' }], + 'unicorn/filename-case': [ + 'error', + { cases: { kebabCase: true, pascalCase: true } }, + ], 'unicorn/no-useless-undefined': 'off', }, }, diff --git a/examples/basic/basic.svelte b/examples/basic/basic.svelte new file mode 100644 index 0000000..11ca22c --- /dev/null +++ b/examples/basic/basic.svelte @@ -0,0 +1,13 @@ + + + + +{#if showGreeting} +

Hello {name}

+{/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)} +

{message.text}

+
+ {/each} +
+``` + +## `context.test.js` + +```js file=./context.test.js +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/deprecated/deprecated-event.svelte b/examples/deprecated/deprecated-event.svelte new file mode 100644 index 0000000..fe21a0e --- /dev/null +++ b/examples/deprecated/deprecated-event.svelte @@ -0,0 +1 @@ + diff --git a/examples/deprecated/deprecated-event.test.js b/examples/deprecated/deprecated-event.test.js new file mode 100644 index 0000000..973e897 --- /dev/null +++ b/examples/deprecated/deprecated-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 './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() +}) diff --git a/examples/deprecated/deprecated-slot.svelte b/examples/deprecated/deprecated-slot.svelte new file mode 100644 index 0000000..08d88e3 --- /dev/null +++ b/examples/deprecated/deprecated-slot.svelte @@ -0,0 +1,3 @@ +

+ +

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' }) + }) +})