diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 39f9d9e..0000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -scripts/* -.eslintignore -.prettierignore -.github/workflows/* -*.md -types diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 91f6920..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,42 +0,0 @@ -module.exports = { - root: true, - env: { - browser: true, - es6: true, - }, - extends: ['standard', 'plugin:svelte/recommended', 'prettier'], - plugins: ['svelte', 'simple-import-sort'], - rules: { - 'simple-import-sort/imports': 'error', - 'simple-import-sort/exports': 'error', - }, - overrides: [ - { - files: ['*.svelte'], - parser: 'svelte-eslint-parser', - parserOptions: { - parser: '@typescript-eslint/parser', - }, - rules: { - 'no-undef-init': 'off', - 'prefer-const': 'off', - 'svelte/no-unused-svelte-ignore': 'off', - }, - }, - { - files: ['*.ts'], - parser: '@typescript-eslint/parser', - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/stylistic', - 'prettier', - ], - }, - ], - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - }, - globals: { afterEach: 'readonly', $state: 'readonly', $props: 'readonly' }, - ignorePatterns: ['!/.*'], -} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c9eb8a7..434a8bb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,15 +23,6 @@ updates: development: dependency-type: 'development' - # TODO(mcous, 2024-04-30): update to ESLint v9 + flat config - ignore: - - dependency-name: 'eslint' - versions: ['>=9'] - - dependency-name: 'eslint-plugin-n' - versions: ['>=17'] - - dependency-name: 'eslint-plugin-promise' - versions: ['>=7'] - # Update GitHub Actions dependencies - package-ecosystem: 'github-actions' directory: '/' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b2ac4a..136111f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: main: # ignore all-contributors PRs if: ${{ !contains(github.head_ref, 'all-contributors') }} - name: Node ${{ matrix.node }}, Svelte ${{ matrix.svelte }}, ${{ matrix.check }} + name: Svelte ${{ matrix.svelte }}, Node ${{ matrix.node }}, ${{ matrix.check }} runs-on: ubuntu-latest # enable OIDC for codecov uploads @@ -27,19 +27,23 @@ jobs: strategy: fail-fast: false matrix: - node: ['16', '18', '20'] - svelte: ['3', '4'] + node: ['16', '18', '20', '22'] + svelte: ['3', '4', '5'] check: ['test:vitest:jsdom', 'test:vitest:happy-dom', 'test:jest'] + exclude: + # Don't run Svelte 3 on Node versions greater than 20 + - { svelte: '3', node: '22' } + # Only run Svelte 5 on Node versions greater than or equal to 20 + - { svelte: '5', node: '16' } + - { svelte: '5', node: '18' } include: - # We only need to lint once, so do it on latest Node and Svelte - - { node: '20', svelte: '4', check: 'lint' } - # `SvelteComponent` is not generic in Svelte 3, so type-checking only passes in >= 4 - - { node: '20', svelte: '4', check: 'types:legacy' } - - { node: '20', svelte: 'next', check: 'types' } - # Only run Svelte 5 checks on latest Node - - { node: '20', svelte: 'next', check: 'test:vitest:jsdom' } - - { node: '20', svelte: 'next', check: 'test:vitest:happy-dom' } - - { node: '20', svelte: 'next', check: 'test:jest' } + # 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' } + - { svelte: '5', node: '22', check: 'types' } steps: - name: ⬇️ Checkout repo @@ -51,16 +55,14 @@ jobs: node-version: ${{ matrix.node }} - name: 📥 Download deps - run: | - npm install --no-package-lock - npm install --no-save svelte@${{ matrix.svelte }} + run: npm run install:${{ matrix.svelte }} - name: ▶️ Run ${{ matrix.check }} run: npm run ${{ matrix.check }} - name: ⬆️ Upload coverage report if: ${{ startsWith(matrix.check, 'test:') }} - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: use_oidc: true fail_ci_if_error: true @@ -74,10 +76,10 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: 📥 Download deps - run: npm install --no-package-lock + run: npm install - name: 🏗️ Build types run: npm run build @@ -101,7 +103,7 @@ jobs: - name: ⎔ Setup node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: 📥 Downloads types build uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 151e826..ca3410b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ public/bundle.* coverage dist .idea +*.tgz # These cause more harm than good when working with contributors yarn-error.log diff --git a/.npmrc b/.npmrc index 43c97e7..86916fa 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ package-lock=false +engine-strict=true diff --git a/.prettierignore b/.prettierignore index 5e50c20..c750ecf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1 @@ -scripts/* -.eslintignore -.prettierignore .all-contributorsrc diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index 0a2ace3..0000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1,9 +0,0 @@ -semi: false -singleQuote: true -trailingComma: es5 -plugins: - - prettier-plugin-svelte -overrides: - - files: '*.svelte' - options: - parser: svelte diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92856ee..328bcda 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,11 +28,10 @@ npm run preview-release ## Development setup -After cloning the repository, install the project's dependencies and run the `validate` script to run all checks and tests to verify your setup. +After cloning the repository, use the `setup` script to install dependencies and run all checks: ```shell -npm install # or `pnpm install`, or `yarn install`, etc. -npm run validate +npm run setup ``` ### Lint and format @@ -40,13 +39,13 @@ npm run validate Run auto-formatting to ensure any changes adhere to the code style of the repository: ```shell -npm run format:delta +npm run format ``` To run lint and format checks without making any changes: ```shell -npm run lint:delta +npm run lint ``` ### Test @@ -58,12 +57,30 @@ npm test npm run test:watch ``` +### Using different versions of Svelte + +Use the provided script to set up your environment for different versions of Svelte: + +```shell +# Svelte 5 +npm run install:5 +npm run all + +# Svelte 4 +npm run install:4 +npm run all:legacy + +# Svelte 3 +npm run install:3 +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 bf45331..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,13 +66,11 @@ ## Table of Contents - - - - [The Problem](#the-problem) - [This Solution](#this-solution) - [Installation](#installation) - [Setup](#setup) + - [Auto-cleanup](#auto-cleanup) - [Docs](#docs) - [Issues](#issues) - [🐛 Bugs](#-bugs) @@ -77,8 +78,6 @@ - [❓ Questions](#-questions) - [Contributors](#contributors) - - ## The Problem You want to write maintainable tests for your [Svelte][svelte] components. @@ -140,6 +139,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. @@ -183,8 +215,11 @@ instead of filing an issue on GitHub. Thanks goes to these people ([emoji key][emojis]): + + + @@ -212,6 +247,7 @@ Thanks goes to these people ([emoji key][emojis]):
+ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092d967 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,98 @@ +import js from '@eslint/js' +import eslintPluginVitest from '@vitest/eslint-plugin' +import eslintConfigPrettier from 'eslint-config-prettier' +import eslintPluginJestDom from 'eslint-plugin-jest-dom' +import eslintPluginPromise from 'eslint-plugin-promise' +import eslintPluginSimpleImportSort from 'eslint-plugin-simple-import-sort' +import eslintPluginSvelte from 'eslint-plugin-svelte' +import eslintPluginTestingLibrary from 'eslint-plugin-testing-library' +import eslintPluginUnicorn from 'eslint-plugin-unicorn' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + js.configs.recommended, + tseslint.configs.strict, + tseslint.configs.stylistic, + eslintPluginUnicorn.configs['flat/recommended'], + eslintPluginPromise.configs['flat/recommended'], + eslintPluginSvelte.configs['flat/recommended'], + eslintPluginSvelte.configs['flat/prettier'], + eslintConfigPrettier, + { + name: 'settings', + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + parser: tseslint.parser, + extraFileExtensions: ['.svelte'], + }, + globals: { + ...globals.browser, + ...globals.node, + ...globals.jest, + }, + }, + }, + { + name: 'ignores', + ignores: ['coverage', 'types'], + }, + { + name: 'simple-import-sort', + plugins: { + 'simple-import-sort': eslintPluginSimpleImportSort, + }, + rules: { + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + }, + }, + { + name: 'tests', + files: ['**/*.test.js'], + extends: [ + eslintPluginVitest.configs.recommended, + eslintPluginJestDom.configs['flat/recommended'], + eslintPluginTestingLibrary.configs['flat/dom'], + ], + rules: { + 'testing-library/no-node-access': [ + 'error', + { allowContainerFirstChild: true }, + ], + }, + }, + { + name: 'extras', + rules: { + 'unicorn/prevent-abbreviations': 'off', + }, + }, + { + name: 'svelte-extras', + files: ['**/*.svelte'], + rules: { + 'svelte/no-unused-svelte-ignore': 'off', + 'unicorn/filename-case': [ + 'error', + { cases: { kebabCase: true, pascalCase: true } }, + ], + 'unicorn/no-useless-undefined': 'off', + }, + }, + { + name: 'ts-extras', + files: ['**/*.ts'], + extends: [ + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + } +) 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/jest.config.js b/jest.config.js index 4857b64..f4daad1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,26 +1,28 @@ import { VERSION as SVELTE_VERSION } from 'svelte/compiler' const SVELTE_TRANSFORM_PATTERN = - SVELTE_VERSION >= '5' ? '^.+\\.svelte(?:\\.js)?$' : '^.+\\.svelte$' + SVELTE_VERSION >= '5' + ? String.raw`^.+\.svelte(?:\.js)?$` + : String.raw`^.+\.svelte$` export default { - testMatch: ['/src/__tests__/**/*.test.js'], + testMatch: ['/tests/**/*.test.js'], transform: { [SVELTE_TRANSFORM_PATTERN]: 'svelte-jester', }, moduleFileExtensions: ['js', 'svelte'], extensionsToTreatAsEsm: ['.svelte'], testEnvironment: 'jsdom', - setupFilesAfterEnv: ['/src/__tests__/_jest-setup.js'], - injectGlobals: false, + setupFilesAfterEnv: ['/tests/_jest-setup.js'], + injectGlobals: true, moduleNameMapper: { - '^vitest$': '/src/__tests__/_jest-vitest-alias.js', + '^vitest$': '/tests/_jest-vitest-alias.js', + [String.raw`^@testing-library\/svelte$`]: '/src/index.js', }, resetMocks: true, restoreMocks: true, collectCoverageFrom: ['/src/**/*'], coveragePathIgnorePatterns: [ - '/__tests__/', '/src/vite.js', '/src/vitest.js', ], diff --git a/package.json b/package.json index 192560d..14e8cf9 100644 --- a/package.json +++ b/package.json @@ -49,33 +49,30 @@ ], "files": [ "src", - "types", - "!__tests__" + "types" ], "scripts": { - "all": "npm-run-all contributors:generate toc format types build test:vitest:* test:jest", - "toc": "doctoc README.md", + "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", + "docs": "remark --output --use remark-toc --use remark-code-import --use unified-prettier README.md examples", "lint": "prettier . --check && eslint .", - "lint:delta": "npm-run-all -p prettier:delta eslint:delta", - "prettier:delta": "prettier --check `./scripts/changed-files`", - "eslint:delta": "eslint `./scripts/changed-files`", "format": "prettier . --write && eslint . --fix", - "format:delta": "npm-run-all format:prettier:delta format:eslint:delta", - "format:prettier:delta": "prettier --write `./scripts/changed-files`", - "format:eslint:delta": "eslint --fix `./scripts/changed-files`", - "setup": "npm install && npm run all", + "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", - "validate": "npm-run-all test:vitest:* test:jest types build", "build": "tsc -p tsconfig.build.json && cp src/component-types.d.ts types", "contributors:add": "all-contributors add", "contributors:generate": "all-contributors generate", - "preview-release": "./scripts/preview-release" + "preview-release": "./scripts/preview-release", + "install:3": "./scripts/install-dependencies 3", + "install:4": "./scripts/install-dependencies 4", + "install:5": "./scripts/install-dependencies 5" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", @@ -91,39 +88,45 @@ } }, "dependencies": { - "@testing-library/dom": "^10.0.0" + "@testing-library/dom": "9.x.x || 10.x.x" }, "devDependencies": { + "@eslint/js": "^9.26.0", "@jest/globals": "^29.7.0", - "@sveltejs/vite-plugin-svelte": "^3.1.1", - "@testing-library/jest-dom": "^6.3.0", - "@testing-library/user-event": "^14.5.2", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@vitest/coverage-v8": "^2.0.2", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "^3.1.3", + "@vitest/eslint-plugin": "^1.1.44", "all-contributors-cli": "^6.26.1", - "doctoc": "^2.2.1", - "eslint": "^8.57.0", - "eslint-config-prettier": "^9.1.0", - "eslint-config-standard": "^17.1.0", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-n": "^16.6.2", - "eslint-plugin-promise": "^6.4.0", + "eslint": "^9.26.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-jest-dom": "^5.5.0", + "eslint-plugin-promise": "^7.2.1", "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-svelte": "^2.42.0", - "expect-type": "^0.20.0", - "happy-dom": "^15.7.3", + "eslint-plugin-svelte": "^3.5.1", + "eslint-plugin-testing-library": "^7.1.1", + "eslint-plugin-unicorn": "^59.0.1", + "expect-type": "^1.2.1", + "globals": "^16.1.0", + "happy-dom": "^17.4.6", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "jsdom": "^25.0.0", + "jsdom": "^26.1.0", "npm-run-all": "^4.1.5", - "prettier": "^3.3.3", - "prettier-plugin-svelte": "^3.2.5", - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", - "svelte-check": "^4.0.4", + "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.5.3", - "vite": "^5.3.3", - "vitest": "^2.0.2" + "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 new file mode 100644 index 0000000..55343f2 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,21 @@ +export default { + semi: false, + singleQuote: true, + trailingComma: 'es5', + plugins: ['prettier-plugin-svelte'], + overrides: [ + { + files: '*.svelte', + options: { + parser: 'svelte', + }, + }, + { + files: 'examples/**/*.md', + options: { + printWidth: 80, + proseWrap: 'always', + }, + }, + ], +} diff --git a/scripts/changed-files b/scripts/changed-files deleted file mode 100755 index 1275bd8..0000000 --- a/scripts/changed-files +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -git diff --name-only --diff-filter=d main diff --git a/scripts/install-dependencies b/scripts/install-dependencies new file mode 100755 index 0000000..aef6257 --- /dev/null +++ b/scripts/install-dependencies @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Install dependencies for a given version of Svelte +set -euxo pipefail + +svelte_version="${1-}" +node_version=$(node --version | sed 's/^v\([0-9]*\).*/\1/') +env_dir="tests/envs/svelte$svelte_version" +env_dir_by_node="$env_dir/node$node_version" + +if [[ -d $env_dir_by_node ]]; then + env_dir="$env_dir_by_node" +fi + +if [[ "$svelte_version" == "5" ]]; then + rm -rf coverage node_modules + npm install + exit 0 +fi + +if [[ -z "$svelte_version" ]]; then + echo "Invalid usage: missing Svelte version" >&2; + exit 1 +fi + +if [[ ! -d "$env_dir" ]]; then + echo "Error: package.json for Svelte $svelte_version, Node $node_version not found" 1>&2 + exit 2 +fi + +rm -rf coverage node_modules "$env_dir/node_modules" +pushd "$env_dir" +npm install --no-package-lock --engine-strict +npm ls "$env_dir" svelte +popd +mv "$env_dir/node_modules" . diff --git a/src/__tests__/_jest-setup.js b/src/__tests__/_jest-setup.js deleted file mode 100644 index d1c255c..0000000 --- a/src/__tests__/_jest-setup.js +++ /dev/null @@ -1,9 +0,0 @@ -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/src/__tests__/act.test.js b/src/__tests__/act.test.js deleted file mode 100644 index 75c9ded..0000000 --- a/src/__tests__/act.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { setTimeout } from 'node:timers/promises' - -import { act, render } from '@testing-library/svelte' -import { describe, expect, test } from 'vitest' - -import Comp from './fixtures/Comp.svelte' - -describe('act', () => { - test('state updates are flushed', async () => { - const { getByText } = render(Comp) - const button = getByText('Button') - - expect(button).toHaveTextContent('Button') - - await act(() => { - button.click() - }) - - expect(button).toHaveTextContent('Button Clicked') - }) - - test('accepts async functions', async () => { - const { getByText } = render(Comp) - const button = getByText('Button') - - await act(async () => { - await setTimeout(100) - button.click() - }) - - expect(button).toHaveTextContent('Button Clicked') - }) -}) diff --git a/src/__tests__/events.test.js b/src/__tests__/events.test.js deleted file mode 100644 index e0aba4d..0000000 --- a/src/__tests__/events.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { fireEvent, render } from '@testing-library/svelte' -import { describe, expect, test } from 'vitest' - -import Comp from './fixtures/Comp.svelte' - -describe('events', () => { - test('state changes are flushed after firing an event', async () => { - const { getByText } = render(Comp, { props: { name: 'World' } }) - const button = getByText('Button') - - const result = fireEvent.click(button) - - await expect(result).resolves.toBe(true) - expect(button).toHaveTextContent('Button Clicked') - }) - - test('calling `fireEvent` directly works too', async () => { - const { getByText } = render(Comp, { props: { name: 'World' } }) - const button = getByText('Button') - - const result = fireEvent( - button, - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - ) - - await expect(result).resolves.toBe(true) - expect(button).toHaveTextContent('Button Clicked') - }) -}) diff --git a/src/__tests__/fixtures/Typed.svelte b/src/__tests__/fixtures/Typed.svelte deleted file mode 100644 index 44960ab..0000000 --- a/src/__tests__/fixtures/Typed.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -

hello {name}

-

count: {count}

diff --git a/src/component-types.d.ts b/src/component-types.d.ts index 9df84c2..007f1e4 100644 --- a/src/component-types.d.ts +++ b/src/component-types.d.ts @@ -1,47 +1,61 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type * as Svelte from 'svelte' +import type { + Component as ModernComponent, + ComponentConstructorOptions as LegacyConstructorOptions, + ComponentProps, + EventDispatcher, + mount, + SvelteComponent as LegacyComponent, + SvelteComponentTyped as Svelte3LegacyComponent, +} from 'svelte' -type IS_MODERN_SVELTE = any extends Svelte.Component ? false : true +type IS_MODERN_SVELTE = ModernComponent extends (...args: any[]) => any + ? true + : false + +type IS_LEGACY_SVELTE_4 = + EventDispatcher extends (...args: any[]) => any ? true : false /** A compiled, imported Svelte component. */ export type Component< - P extends Record, - E extends Record, + P extends Record = any, + E extends Record = any, > = IS_MODERN_SVELTE extends true - ? Svelte.Component | Svelte.SvelteComponent

- : Svelte.SvelteComponent

+ ? ModernComponent | LegacyComponent

+ : IS_LEGACY_SVELTE_4 extends true + ? LegacyComponent

+ : Svelte3LegacyComponent

/** * The type of an imported, compiled Svelte component. * - * In Svelte 4, this was the Svelte component class' type. * In Svelte 5, this distinction no longer matters. + * In Svelte 4, this is the Svelte component class constructor. */ -export type ComponentType = C extends Svelte.SvelteComponent - ? Svelte.ComponentType +export type ComponentType = C extends LegacyComponent + ? new (...args: any[]) => C : C /** The props of a component. */ -export type Props> = Svelte.ComponentProps +export type Props = ComponentProps /** * The exported fields of a component. * - * In Svelte 4, this is simply the instance of the component class. * In Svelte 5, this is the set of variables marked as `export`'d. + * In Svelte 4, this is simply the instance of the component class. */ -export type Exports = C extends Svelte.SvelteComponent - ? C - : C extends Svelte.Component +export type Exports = IS_MODERN_SVELTE extends true + ? C extends ModernComponent ? E - : never + : C & { $set: never; $on: never; $destroy: never } + : C /** * Options that may be passed to `mount` when rendering the component. * * In Svelte 4, these are the options passed to the component constructor. */ -export type MountOptions> = - IS_MODERN_SVELTE extends true - ? Parameters, Exports>>[1] - : Svelte.ComponentConstructorOptions> +export type MountOptions = IS_MODERN_SVELTE extends true + ? Parameters, Exports>>[1] + : LegacyConstructorOptions> diff --git a/src/core/index.js b/src/core/index.js index f4a40aa..9e41adf 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -7,10 +7,7 @@ */ import * as LegacyCore from './legacy.js' import * as ModernCore from './modern.svelte.js' -import { - createValidateOptions, - UnknownSvelteOptionsError, -} from './validate-options.js' +import { createValidateOptions } from './validate-options.js' const { mount, unmount, updateProps, allowedOptions } = ModernCore.IS_MODERN_SVELTE ? ModernCore : LegacyCore @@ -18,10 +15,5 @@ const { mount, unmount, updateProps, allowedOptions } = /** Validate component options. */ const validateOptions = createValidateOptions(allowedOptions) -export { - mount, - UnknownSvelteOptionsError, - unmount, - updateProps, - validateOptions, -} +export { mount, unmount, updateProps, validateOptions } +export { UnknownSvelteOptionsError } from './validate-options.js' diff --git a/src/index.js b/src/index.js index 2704824..2c88a28 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,22 @@ -/* eslint-disable import/export */ -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 875f87c..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' @@ -65,6 +67,7 @@ const render = (Component, options = {}, renderOptions = {}) => { const queries = getQueriesForElement(baseElement, renderOptions.queries) const target = + // eslint-disable-next-line unicorn/prefer-dom-node-append options.target ?? baseElement.appendChild(document.createElement('div')) targetCache.add(target) @@ -93,7 +96,7 @@ const render = (Component, options = {}, renderOptions = {}) => { } updateProps(component, props) - await tick() + await Svelte.tick() }, unmount: () => { cleanupComponent(component) @@ -102,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) @@ -116,27 +146,35 @@ const cleanupTarget = (target) => { const inCache = targetCache.delete(target) if (inCache && target.parentNode === document.body) { - document.body.removeChild(target) + target.remove() } } -/** Unmount all components and remove elements added to ``. */ +/** Unmount components, remove elements added to ``, and reset `@testing-library/dom`. */ const cleanup = () => { - componentCache.forEach(cleanupComponent) - targetCache.forEach(cleanupTarget) + 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} + * @template T + * @param {(() => Promise) | () => T} [fn] - A function, which may be `async`, to call before flushing updates. + * @returns {Promise} */ const act = async (fn) => { + let result if (fn) { - await fn() + result = await fn() } - return tick() + await Svelte.tick() + return result } /** @@ -157,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)) -Object.keys(baseFireEvent).forEach((key) => { - fireEvent[key] = async (...args) => { - const event = baseFireEvent[key](...args) - await tick() - return event - } -}) +for (const [key, baseEvent] of Object.entries(baseFireEvent)) { + fireEvent[key] = async (...args) => act(() => baseEvent(...args)) +} -export { act, cleanup, fireEvent, render } +export { act, cleanup, fireEvent, render, setup } diff --git a/src/vite.js b/src/vite.js index 0062b89..b57c886 100644 --- a/src/vite.js +++ b/src/vite.js @@ -1,5 +1,5 @@ -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import path from 'node:path' +import url from 'node:url' /** * Vite plugin to configure @testing-library/svelte. @@ -7,12 +7,13 @@ import { fileURLToPath } from 'node:url' * Ensures Svelte is imported correctly in tests * and that the DOM is cleaned up after each test. * - * @param {{resolveBrowser?: boolean, autoCleanup?: boolean}} options + * @param {{resolveBrowser?: boolean, autoCleanup?: boolean, noExternal?: boolean}} options * @returns {import('vite').Plugin} */ export const svelteTesting = ({ resolveBrowser = true, autoCleanup = true, + noExternal = true, } = {}) => ({ name: 'vite-plugin-svelte-testing-library', config: (config) => { @@ -27,6 +28,10 @@ export const svelteTesting = ({ if (autoCleanup) { addAutoCleanup(config) } + + if (noExternal) { + addNoExternal(config) + } }, }) @@ -45,8 +50,8 @@ const addBrowserCondition = (config) => { const browserConditionIndex = conditions.indexOf('browser') if ( - nodeConditionIndex >= 0 && - (nodeConditionIndex < browserConditionIndex || browserConditionIndex < 0) + nodeConditionIndex !== -1 && + (nodeConditionIndex < browserConditionIndex || browserConditionIndex === -1) ) { conditions.splice(nodeConditionIndex, 0, 'browser') } @@ -64,12 +69,55 @@ const addAutoCleanup = (config) => { const test = config.test ?? {} let setupFiles = test.setupFiles ?? [] + if (test.globals) { + return + } + if (typeof setupFiles === 'string') { setupFiles = [setupFiles] } - setupFiles.push(join(dirname(fileURLToPath(import.meta.url)), './vitest.js')) + setupFiles.push( + path.join(path.dirname(url.fileURLToPath(import.meta.url)), './vitest.js') + ) test.setupFiles = setupFiles config.test = test } + +/** + * Add `@testing-library/svelte` to Vite's noExternal rules, if not present. + * + * This ensures `@testing-library/svelte` is processed by `@sveltejs/vite-plugin-svelte` + * in certain monorepo setups. + */ +const addNoExternal = (config) => { + const ssr = config.ssr ?? {} + let noExternal = ssr.noExternal ?? [] + + if (noExternal === true) { + return + } + + if (typeof noExternal === 'string' || noExternal instanceof RegExp) { + noExternal = [noExternal] + } + + if (!Array.isArray(noExternal)) { + return + } + + for (const rule of noExternal) { + if (typeof rule === 'string' && rule === '@testing-library/svelte') { + return + } + + if (rule instanceof RegExp && rule.test('@testing-library/svelte')) { + return + } + } + + noExternal.push('@testing-library/svelte') + ssr.noExternal = noExternal + config.ssr = ssr +} 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/src/__tests__/utils.js b/tests/_env.js similarity index 79% rename from src/__tests__/utils.js rename to tests/_env.js index 68be33c..96b30f4 100644 --- a/src/__tests__/utils.js +++ b/tests/_env.js @@ -1,9 +1,11 @@ import { VERSION as SVELTE_VERSION } from 'svelte/compiler' -export const IS_JSDOM = window.navigator.userAgent.includes('jsdom') +export const IS_JSDOM = globalThis.navigator.userAgent.includes('jsdom') export const IS_HAPPYDOM = !IS_JSDOM // right now it's happy or js +export const IS_JEST = Boolean(process.env.JEST_WORKER_ID) + export const IS_SVELTE_5 = SVELTE_VERSION >= '5' export const MODE_LEGACY = 'legacy' diff --git a/tests/_jest-setup.js b/tests/_jest-setup.js new file mode 100644 index 0000000..2c9b6f7 --- /dev/null +++ b/tests/_jest-setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom/jest-globals' diff --git a/src/__tests__/_jest-vitest-alias.js b/tests/_jest-vitest-alias.js similarity index 81% rename from src/__tests__/_jest-vitest-alias.js rename to tests/_jest-vitest-alias.js index 6628c80..a09c310 100644 --- a/src/__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/src/__tests__/_vitest-setup.js b/tests/_vitest-setup.js similarity index 100% rename from src/__tests__/_vitest-setup.js rename to tests/_vitest-setup.js diff --git a/tests/act.test.js b/tests/act.test.js new file mode 100644 index 0000000..c6caae2 --- /dev/null +++ b/tests/act.test.js @@ -0,0 +1,46 @@ +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' + +describe('act', () => { + test('state updates are flushed', async () => { + render(Comp) + const button = screen.getByText('Button') + + expect(button).toHaveTextContent('Button') + + await act(() => { + // eslint-disable-next-line testing-library/no-node-access + button.click() + }) + + expect(button).toHaveTextContent('Button Clicked') + }) + + test('accepts async functions', async () => { + render(Comp) + const button = screen.getByText('Button') + + await act(async () => { + await setTimeout(10) + // eslint-disable-next-line testing-library/no-node-access + 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/src/__tests__/auto-cleanup.test.js b/tests/auto-cleanup.test.js similarity index 60% rename from src/__tests__/auto-cleanup.test.js rename to tests/auto-cleanup.test.js index 803001e..391146e 100644 --- a/src/__tests__/auto-cleanup.test.js +++ b/tests/auto-cleanup.test.js @@ -1,20 +1,28 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -const globalAfterEach = vi.fn() +import { IS_JEST } from './_env.js' + +// TODO(mcous, 2024-12-08): clearing module cache and re-importing +// 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() -describe('auto-cleanup', () => { beforeEach(() => { vi.resetModules() + globalThis.beforeEach = globalBeforeEach globalThis.afterEach = globalAfterEach }) afterEach(() => { delete process.env.STL_SKIP_AUTO_CLEANUP + delete globalThis.beforeEach delete globalThis.afterEach }) test('calls afterEach with cleanup if globally defined', async () => { - const { render } = await import('../index.js') + const { render } = await import('@testing-library/svelte') expect(globalAfterEach).toHaveBeenCalledTimes(1) expect(globalAfterEach).toHaveBeenLastCalledWith(expect.any(Function)) @@ -30,8 +38,9 @@ describe('auto-cleanup', () => { test('does not call afterEach if process STL_SKIP_AUTO_CLEANUP is set', async () => { process.env.STL_SKIP_AUTO_CLEANUP = 'true' - await import('../index.js') + await import('@testing-library/svelte') + expect(globalBeforeEach).toHaveBeenCalledTimes(0) expect(globalAfterEach).toHaveBeenCalledTimes(0) }) }) diff --git a/src/__tests__/cleanup.test.js b/tests/cleanup.test.js similarity index 100% rename from src/__tests__/cleanup.test.js rename to tests/cleanup.test.js diff --git a/src/__tests__/context.test.js b/tests/context.test.js similarity index 62% rename from src/__tests__/context.test.js rename to tests/context.test.js index da54b9d..e9d83eb 100644 --- a/src/__tests__/context.test.js +++ b/tests/context.test.js @@ -1,4 +1,4 @@ -import { render } from '@testing-library/svelte' +import { render, screen } from '@testing-library/svelte' import { expect, test } from 'vitest' import Comp from './fixtures/Context.svelte' @@ -6,9 +6,9 @@ import Comp from './fixtures/Context.svelte' test('can set a context', () => { const message = 'Got it' - const { getByText } = render(Comp, { + render(Comp, { context: new Map(Object.entries({ foo: { message } })), }) - expect(getByText(message)).toBeTruthy() + expect(screen.getByText(message)).toBeInTheDocument() }) diff --git a/src/__tests__/debug.test.js b/tests/debug.test.js similarity index 100% rename from src/__tests__/debug.test.js rename to tests/debug.test.js diff --git a/tests/envs/svelte3/node16/package.json b/tests/envs/svelte3/node16/package.json new file mode 100644 index 0000000..8acb921 --- /dev/null +++ b/tests/envs/svelte3/node16/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": "16.x.x" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "2.x.x", + "@testing-library/dom": "9.x.x", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "0.x.x", + "expect-type": "^1.2.1", + "happy-dom": "14.x.x", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "22.x.x", + "npm-run-all": "^4.1.5", + "svelte": "3.x.x", + "svelte-check": "3.x.x", + "svelte-jester": "3.x.x", + "vite": "4.x.x", + "vitest": "0.x.x" + } +} diff --git a/tests/envs/svelte3/package.json b/tests/envs/svelte3/package.json new file mode 100644 index 0000000..d563d97 --- /dev/null +++ b/tests/envs/svelte3/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": ">=18" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "2.x.x", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "0.x.x", + "expect-type": "^1.2.1", + "happy-dom": "^17.4.6", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^26.1.0", + "npm-run-all": "^4.1.5", + "svelte": "3.x.x", + "svelte-check": "3.x.x", + "svelte-jester": "3.x.x", + "vite": "4.x.x", + "vitest": "0.x.x" + } +} diff --git a/tests/envs/svelte4/node16/package.json b/tests/envs/svelte4/node16/package.json new file mode 100644 index 0000000..ce420a1 --- /dev/null +++ b/tests/envs/svelte4/node16/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": "16.x.x" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "2.x.x", + "@testing-library/dom": "9.x.x", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "0.x.x", + "expect-type": "^1.2.1", + "happy-dom": "14.x.x", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "22.x.x", + "npm-run-all": "^4.1.5", + "svelte": "4.x.x", + "svelte-check": "3.x.x", + "svelte-jester": "3.x.x", + "vite": "4.x.x", + "vitest": "0.x.x" + } +} diff --git a/tests/envs/svelte4/package.json b/tests/envs/svelte4/package.json new file mode 100644 index 0000000..b90351d --- /dev/null +++ b/tests/envs/svelte4/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "engines": { + "node": ">=18" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "3.x.x", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "2.x.x", + "expect-type": "^1.2.1", + "happy-dom": "^17.4.6", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^26.1.0", + "npm-run-all": "^4.1.5", + "svelte": "4.x.x", + "svelte-check": "^4.1.7", + "svelte-jester": "^5.0.0", + "vite": "5.x.x", + "vitest": "2.x.x" + } +} diff --git a/tests/events.test.js b/tests/events.test.js new file mode 100644 index 0000000..db47d62 --- /dev/null +++ b/tests/events.test.js @@ -0,0 +1,44 @@ +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', () => { + test('state changes are flushed after firing an event', async () => { + render(Comp, { props: { name: 'World' } }) + const button = screen.getByText('Button') + + const result = fireEvent.click(button) + + await expect(result).resolves.toBe(true) + expect(button).toHaveTextContent('Button Clicked') + }) + + test('calling `fireEvent` directly works too', async () => { + render(Comp, { props: { name: 'World' } }) + const button = screen.getByText('Button') + + const result = fireEvent( + button, + new MouseEvent('click', { bubbles: true, cancelable: true }) + ) + + 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( + button, + new MouseEvent('click', { bubbles: true, cancelable: true }) + ) + + expect(result).toBe(true) + expect(button).toHaveTextContent('Button Clicked') + }) +}) diff --git a/src/__tests__/fixtures/Comp.svelte b/tests/fixtures/Comp.svelte similarity index 100% rename from src/__tests__/fixtures/Comp.svelte rename to tests/fixtures/Comp.svelte diff --git a/src/__tests__/fixtures/CompRunes.svelte b/tests/fixtures/CompRunes.svelte similarity index 100% rename from src/__tests__/fixtures/CompRunes.svelte rename to tests/fixtures/CompRunes.svelte diff --git a/src/__tests__/fixtures/Context.svelte b/tests/fixtures/Context.svelte similarity index 100% rename from src/__tests__/fixtures/Context.svelte rename to tests/fixtures/Context.svelte diff --git a/src/__tests__/fixtures/Mounter.svelte b/tests/fixtures/Mounter.svelte similarity index 100% rename from src/__tests__/fixtures/Mounter.svelte rename to tests/fixtures/Mounter.svelte diff --git a/src/__tests__/fixtures/Transitioner.svelte b/tests/fixtures/Transitioner.svelte similarity index 100% rename from src/__tests__/fixtures/Transitioner.svelte rename to tests/fixtures/Transitioner.svelte diff --git a/tests/fixtures/Typed.svelte b/tests/fixtures/Typed.svelte new file mode 100644 index 0000000..dad8e14 --- /dev/null +++ b/tests/fixtures/Typed.svelte @@ -0,0 +1,14 @@ + + +

hello {name}

+

count: {count}

+ diff --git a/src/__tests__/fixtures/TypedRunes.svelte b/tests/fixtures/TypedRunes.svelte similarity index 78% rename from src/__tests__/fixtures/TypedRunes.svelte rename to tests/fixtures/TypedRunes.svelte index 979be41..0fb690b 100644 --- a/src/__tests__/fixtures/TypedRunes.svelte +++ b/tests/fixtures/TypedRunes.svelte @@ -1,7 +1,7 @@

hello {name}

diff --git a/src/__tests__/mount.test.js b/tests/mount.test.js similarity index 100% rename from src/__tests__/mount.test.js rename to tests/mount.test.js diff --git a/src/__tests__/multi-base.test.js b/tests/multi-base.test.js similarity index 100% rename from src/__tests__/multi-base.test.js rename to tests/multi-base.test.js diff --git a/src/__tests__/render-runes.test-d.ts b/tests/render-runes.test-d.ts similarity index 54% rename from src/__tests__/render-runes.test-d.ts rename to tests/render-runes.test-d.ts index 2d0c69f..1e42544 100644 --- a/src/__tests__/render-runes.test-d.ts +++ b/tests/render-runes.test-d.ts @@ -1,7 +1,8 @@ +import * as subject from '@testing-library/svelte' import { expectTypeOf } from 'expect-type' -import { describe, test } from 'vitest' +import { describe, test, vi } from 'vitest' -import * as subject from '../index.js' +import LegacyComponent from './fixtures/Typed.svelte' import Component from './fixtures/TypedRunes.svelte' describe('types', () => { @@ -28,7 +29,7 @@ describe('types', () => { test('render result has container and component', () => { const result = subject.render(Component, { name: 'Alice', count: 42 }) - expectTypeOf(result).toMatchTypeOf<{ + expectTypeOf(result).toExtend<{ container: HTMLElement component: { hello: string } debug: (el?: HTMLElement) => void @@ -37,3 +38,32 @@ describe('types', () => { }>() }) }) + +describe('legacy component types', () => { + test('render accepts events', () => { + const onGreeting = vi.fn() + subject.render(LegacyComponent, { + props: { name: 'Alice', count: 42 }, + events: { greeting: onGreeting }, + }) + }) + + test('component $set and $on are not allowed', () => { + const onGreeting = vi.fn() + const { component } = subject.render(LegacyComponent, { + name: 'Alice', + count: 42, + }) + + expectTypeOf(component).toExtend<{ hello: string }>() + + // @ts-expect-error: Svelte 5 mount does not return `$set` + component.$on('greeting', onGreeting) + + // @ts-expect-error: Svelte 5 mount does not return `$set` + component.$set({ name: 'Bob' }) + + // @ts-expect-error: Svelte 5 mount does not return `$destroy` + component.$destroy() + }) +}) diff --git a/src/__tests__/render-utilities.test-d.ts b/tests/render-utilities.test-d.ts similarity index 74% rename from src/__tests__/render-utilities.test-d.ts rename to tests/render-utilities.test-d.ts index b45614e..c72f761 100644 --- a/src/__tests__/render-utilities.test-d.ts +++ b/tests/render-utilities.test-d.ts @@ -1,14 +1,14 @@ +import * as subject from '@testing-library/svelte' import { expectTypeOf } from 'expect-type' import { describe, test } from 'vitest' -import * as subject from '../index.js' import Component from './fixtures/Comp.svelte' describe('render query and utility types', () => { test('render result has default queries', () => { const result = subject.render(Component, { name: 'Alice' }) - expectTypeOf(result.getByRole).parameters.toMatchTypeOf< + expectTypeOf(result.getByRole).parameters.toExtend< [role: subject.ByRoleMatcher, options?: subject.ByRoleOptions] >() }) @@ -27,25 +27,25 @@ describe('render query and utility types', () => { { queries: { getByVibes } } ) - expectTypeOf(result.getByVibes).parameters.toMatchTypeOf<[vibes: string]>() + expectTypeOf(result.getByVibes).parameters.toExtend<[vibes: string]>() }) test('act is an async function', () => { - expectTypeOf(subject.act).toMatchTypeOf<() => Promise>() + expectTypeOf(subject.act).toExtend<() => Promise>() }) test('act accepts a sync function', () => { - expectTypeOf(subject.act).toMatchTypeOf<(fn: () => void) => Promise>() + expectTypeOf(subject.act).toExtend<(fn: () => void) => Promise>() }) test('act accepts an async function', () => { - expectTypeOf(subject.act).toMatchTypeOf< + expectTypeOf(subject.act).toExtend< (fn: () => Promise) => Promise >() }) test('fireEvent is an async function', () => { - expectTypeOf(subject.fireEvent).toMatchTypeOf< + expectTypeOf(subject.fireEvent).toExtend< ( element: Element | Node | Document | Window, event: Event @@ -54,7 +54,7 @@ describe('render query and utility types', () => { }) test('fireEvent[eventName] is an async function', () => { - expectTypeOf(subject.fireEvent.click).toMatchTypeOf< + expectTypeOf(subject.fireEvent.click).toExtend< ( element: Element | Node | Document | Window, // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/__tests__/render.test-d.ts b/tests/render.test-d.ts similarity index 63% rename from src/__tests__/render.test-d.ts rename to tests/render.test-d.ts index f8d1d90..eb1c639 100644 --- a/src/__tests__/render.test-d.ts +++ b/tests/render.test-d.ts @@ -1,7 +1,8 @@ +import * as subject from '@testing-library/svelte' import { expectTypeOf } from 'expect-type' +import { ComponentProps } from 'svelte' import { describe, test } from 'vitest' -import * as subject from '../index.js' import Component from './fixtures/Typed.svelte' describe('types', () => { @@ -17,6 +18,14 @@ describe('types', () => { await rerender({ count: 0 }) }) + test('non-components are rejected', () => { + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + class NotComponent {} + + // @ts-expect-error: component should be a Svelte component + subject.render(NotComponent) + }) + test('invalid prop types are rejected', () => { // @ts-expect-error: name should be a string subject.render(Component, { name: 42 }) @@ -28,7 +37,7 @@ describe('types', () => { test('render result has container and component', () => { const result = subject.render(Component, { name: 'Alice', count: 42 }) - expectTypeOf(result).toMatchTypeOf<{ + expectTypeOf(result).toExtend<{ container: HTMLElement component: { hello: string } debug: (el?: HTMLElement) => void @@ -36,4 +45,14 @@ describe('types', () => { unmount: () => void }>() }) + + test('render function may be wrapped', () => { + const renderSubject = (props: ComponentProps) => { + return subject.render(Component, props) + } + + renderSubject({ name: 'Alice', count: 42 }) + // @ts-expect-error: name should be a string + renderSubject(Component, { name: 42 }) + }) }) diff --git a/src/__tests__/render.test.js b/tests/render.test.js similarity index 76% rename from src/__tests__/render.test.js rename to tests/render.test.js index f396751..19bf2c3 100644 --- a/src/__tests__/render.test.js +++ b/tests/render.test.js @@ -1,7 +1,7 @@ -import { render } from '@testing-library/svelte' +import { render, screen } from '@testing-library/svelte' import { beforeAll, describe, expect, test } from 'vitest' -import { COMPONENT_FIXTURES } from './utils.js' +import { COMPONENT_FIXTURES } from './_env.js' describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => { const props = { name: 'World' } @@ -12,14 +12,14 @@ describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => { }) test('renders component into the document', () => { - const { getByText } = render(Comp, { props }) + render(Comp, { props }) - expect(getByText('Hello World!')).toBeInTheDocument() + expect(screen.getByText('Hello World!')).toBeInTheDocument() }) test('accepts props directly', () => { - const { getByText } = render(Comp, props) - expect(getByText('Hello World!')).toBeInTheDocument() + render(Comp, props) + expect(screen.getByText('Hello World!')).toBeInTheDocument() }) test('throws error when mixing svelte component options and props', () => { @@ -35,8 +35,8 @@ describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => { }) test('should return a container object wrapping the DOM of the rendered component', () => { - const { container, getByTestId } = render(Comp, props) - const firstElement = getByTestId('test') + const { container } = render(Comp, props) + const firstElement = screen.getByTestId('test') expect(container.firstChild).toBe(firstElement) }) @@ -73,17 +73,14 @@ describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => { const baseElement = document.body const target = document.createElement('section') const anchor = document.createElement('div') - baseElement.appendChild(target) - target.appendChild(anchor) + baseElement.append(target) + target.append(anchor) - const { getByTestId } = render( - Comp, - { props, target, anchor }, - { baseElement } - ) - const firstElement = getByTestId('test') + render(Comp, { props, target, anchor }, { baseElement }) + const firstElement = screen.getByTestId('test') expect(target.firstChild).toBe(firstElement) + // eslint-disable-next-line testing-library/no-node-access expect(target.lastChild).toBe(anchor) }) }) diff --git a/src/__tests__/rerender.test.js b/tests/rerender.test.js similarity index 88% rename from src/__tests__/rerender.test.js rename to tests/rerender.test.js index 52acd0f..2fdff0d 100644 --- a/src/__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 './utils.js' +import { COMPONENT_FIXTURES, IS_SVELTE_5, MODE_RUNES } from './_env.js' describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => { let Comp @@ -39,8 +39,8 @@ describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => { ? { name: 'World' } : { accessors: true, props: { name: 'World' } } - const { component, getByText } = render(Comp, componentOptions) - const element = getByText('Hello World!') + const { component } = render(Comp, componentOptions) + const element = screen.getByText('Hello World!') expect(element).toBeInTheDocument() expect(component.name).toBe('World') diff --git a/src/__tests__/transition.test.js b/tests/transition.test.js similarity index 76% rename from src/__tests__/transition.test.js rename to tests/transition.test.js index a9c8e02..27b236e 100644 --- a/src/__tests__/transition.test.js +++ b/tests/transition.test.js @@ -2,16 +2,17 @@ import { render, screen, waitFor } from '@testing-library/svelte' import { userEvent } from '@testing-library/user-event' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { IS_JSDOM, IS_SVELTE_5 } from './_env.js' import Transitioner from './fixtures/Transitioner.svelte' -import { IS_JSDOM, IS_SVELTE_5 } from './utils.js' describe.skipIf(IS_SVELTE_5)('transitions', () => { - beforeEach(() => { - if (!IS_JSDOM) return - - const raf = (fn) => setTimeout(() => fn(new Date()), 16) - vi.stubGlobal('requestAnimationFrame', raf) - }) + if (IS_JSDOM) { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (fn) => + setTimeout(() => fn(new Date()), 16) + ) + }) + } test('on:introend', async () => { const user = userEvent.setup() diff --git a/tests/vite-plugin.test.js b/tests/vite-plugin.test.js new file mode 100644 index 0000000..92fc8f5 --- /dev/null +++ b/tests/vite-plugin.test.js @@ -0,0 +1,227 @@ +import { svelteTesting } from '@testing-library/svelte/vite' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { IS_JEST } from './_env.js' + +describe.skipIf(IS_JEST)('vite plugin', () => { + beforeEach(() => { + vi.stubEnv('VITEST', '1') + }) + + test('does not modify config if disabled', () => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: false, + }) + + const result = {} + subject.config(result) + + expect(result).toEqual({}) + }) + + test('does not modify config if not Vitest', () => { + vi.stubEnv('VITEST', '') + + const subject = svelteTesting() + + const result = {} + subject.config(result) + + expect(result).toEqual({}) + }) + + test.each([ + { + config: () => ({ resolve: { conditions: ['node'] } }), + expectedConditions: ['browser', 'node'], + }, + { + config: () => ({ resolve: { conditions: ['svelte', 'node'] } }), + expectedConditions: ['svelte', 'browser', 'node'], + }, + ])( + 'adds browser condition if necessary', + ({ config, expectedConditions }) => { + const subject = svelteTesting({ + resolveBrowser: true, + autoCleanup: false, + noExternal: false, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + resolve: { + conditions: expectedConditions, + }, + }) + } + ) + + test.each([ + { + config: () => ({}), + expectedConditions: [], + }, + { + config: () => ({ resolve: { conditions: [] } }), + expectedConditions: [], + }, + { + config: () => ({ resolve: { conditions: ['svelte'] } }), + expectedConditions: ['svelte'], + }, + ])( + 'skips browser condition if possible', + ({ config, expectedConditions }) => { + const subject = svelteTesting({ + resolveBrowser: true, + autoCleanup: false, + noExternal: false, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + resolve: { + conditions: expectedConditions, + }, + }) + } + ) + + test.each([ + { + config: () => ({}), + expectedSetupFiles: [expect.stringMatching(/src\/vitest.js$/u)], + }, + { + config: () => ({ test: { setupFiles: [] } }), + expectedSetupFiles: [expect.stringMatching(/src\/vitest.js$/u)], + }, + { + config: () => ({ test: { setupFiles: 'other-file.js' } }), + expectedSetupFiles: [ + 'other-file.js', + expect.stringMatching(/src\/vitest.js$/u), + ], + }, + ])('adds cleanup', ({ config, expectedSetupFiles }) => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: true, + noExternal: false, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + test: { + setupFiles: expectedSetupFiles, + }, + }) + }) + + test('skips cleanup in global mode', () => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: true, + noExternal: false, + }) + + const result = { test: { globals: true } } + subject.config(result) + + expect(result).toEqual({ + test: { + globals: true, + }, + }) + }) + + test.each([ + { + config: () => ({ ssr: { noExternal: [] } }), + expectedNoExternal: ['@testing-library/svelte'], + }, + { + config: () => ({}), + expectedNoExternal: ['@testing-library/svelte'], + }, + { + config: () => ({ ssr: { noExternal: 'other-file.js' } }), + expectedNoExternal: ['other-file.js', '@testing-library/svelte'], + }, + { + config: () => ({ ssr: { noExternal: /other/u } }), + expectedNoExternal: [/other/u, '@testing-library/svelte'], + }, + ])('adds noExternal rule', ({ config, expectedNoExternal }) => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: true, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + ssr: { + noExternal: expectedNoExternal, + }, + }) + }) + + test.each([ + { + config: () => ({ ssr: { noExternal: true } }), + expectedNoExternal: true, + }, + { + config: () => ({ ssr: { noExternal: '@testing-library/svelte' } }), + expectedNoExternal: '@testing-library/svelte', + }, + { + config: () => ({ ssr: { noExternal: /svelte/u } }), + expectedNoExternal: /svelte/u, + }, + ])('skips noExternal if able', ({ config, expectedNoExternal }) => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: true, + }) + + const result = config() + subject.config(result) + + expect(result).toEqual({ + ssr: { + noExternal: expectedNoExternal, + }, + }) + }) + + test('bails on noExternal if input is unexpected', () => { + const subject = svelteTesting({ + resolveBrowser: false, + autoCleanup: false, + noExternal: true, + }) + + const result = { ssr: { noExternal: false } } + subject.config(result) + + expect(result).toEqual({ + ssr: { + noExternal: false, + }, + }) + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json index 0baa218..bfc566b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,5 +8,5 @@ "rootDir": "src", "outDir": "types" }, - "exclude": ["src/**/__tests__/**"] + "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index f79cace..fda8aba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,12 @@ "noEmit": true, "skipLibCheck": true, "strict": true, - "types": ["svelte", "vite/client", "vitest", "vitest/globals"] + "types": ["svelte", "vite/client", "vitest", "vitest/globals"], + "baseUrl": "./", + "paths": { + "@testing-library/svelte": ["./src"] + }, + "plugins": [{ "name": "typescript-svelte-plugin" }] }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/tsconfig.legacy.json b/tsconfig.legacy.json index b5d9e57..304d872 100644 --- a/tsconfig.legacy.json +++ b/tsconfig.legacy.json @@ -1,8 +1,8 @@ { "extends": ["./tsconfig.json"], "exclude": [ - "src/__tests__/render-runes.test-d.ts", - "src/__tests__/fixtures/CompRunes.svelte", - "src/__tests__/fixtures/TypedRunes.svelte" + "tests/render-runes.test-d.ts", + "tests/fixtures/CompRunes.svelte", + "tests/fixtures/TypedRunes.svelte" ] } diff --git a/vite.config.js b/vite.config.js index 76baf61..65e1ca7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,20 +1,26 @@ +import { createRequire } from 'node:module' + import { svelte } from '@sveltejs/vite-plugin-svelte' +import { svelteTesting } from '@testing-library/svelte/vite' import { defineConfig } from 'vite' -import { svelteTesting } from './src/vite.js' +const require = createRequire(import.meta.url) -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [svelte(), svelteTesting()], + plugins: [svelte({ hot: false }), svelteTesting()], test: { environment: 'jsdom', - setupFiles: ['./src/__tests__/_vitest-setup.js'], + setupFiles: ['./tests/_vitest-setup.js'], mockReset: true, unstubGlobals: true, + unstubEnvs: true, coverage: { provider: 'v8', include: ['src/**/*'], - exclude: ['**/__tests__/**', 'src/vite.js', 'src/vitest.js'], + }, + alias: { + '@testing-library/svelte/vite': require.resolve('./src/vite.js'), + '@testing-library/svelte': require.resolve('./src/index.js'), }, }, })