+ {#each messages.current as message (message.id)}
+
{message.text}
+
+ {/each}
+
diff --git a/examples/wrappers/child.test.js b/examples/wrappers/child.test.js
new file mode 100644
index 0000000..e9cc3eb
--- /dev/null
+++ b/examples/wrappers/child.test.js
@@ -0,0 +1,22 @@
+import { render, screen } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './child.svelte'
+import Wrapper from './wrapper.svelte'
+
+test('notifications with messages from context', () => {
+ const messages = [
+ { id: 'abc', text: 'hello' },
+ { id: 'def', text: 'world' },
+ ]
+
+ render(
+ Subject,
+ { label: 'Notifications' },
+ { wrapper: Wrapper, wrapperProps: { messages } }
+ )
+
+ const status = screen.getByRole('status', { name: 'Notifications' })
+
+ expect(status).toHaveTextContent('hello world')
+})
diff --git a/examples/wrappers/readme.md b/examples/wrappers/readme.md
new file mode 100644
index 0000000..37402bd
--- /dev/null
+++ b/examples/wrappers/readme.md
@@ -0,0 +1,87 @@
+# Wrappers
+
+Sometimes, a component cannot properly render or operate unless it's the child
+of another component. To wrap your component under test in a parent, use the
+`wrapper` and `wrapperProps` options.
+
+> \[!TIP]
+>
+> If you can't test a component in isolation, this may be a sign that you're
+> testing at the wrong level, or that your component structure should be
+> rethought. Consider if you can make your components more testable in isolation
+> before reaching for this option.
+
+While this example uses context to demonstrate the usage of the `wrapper`
+options, if you have access to the context directly, not through a provider
+component, then the [`context` option](../contexts) is a better choice than
+`wrapper`.
+
+## Table of contents
+
+- [`wrapper.svelte`](#wrappersvelte)
+- [`child.svelte`](#childsvelte)
+- [`child.test.js`](#childtestjs)
+
+## `wrapper.svelte`
+
+```svelte file=./wrapper.svelte
+
+
+{@render children?.()}
+```
+
+## `child.svelte`
+
+```svelte file=./child.svelte
+
+
+
+ {#each messages.current as message (message.id)}
+
{message.text}
+
+ {/each}
+
+```
+
+## `child.test.js`
+
+```js file=./child.test.js
+import { render, screen } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './child.svelte'
+import Wrapper from './wrapper.svelte'
+
+test('notifications with messages from context', () => {
+ const messages = [
+ { id: 'abc', text: 'hello' },
+ { id: 'def', text: 'world' },
+ ]
+
+ render(
+ Subject,
+ { label: 'Notifications' },
+ { wrapper: Wrapper, wrapperProps: { messages } }
+ )
+
+ const status = screen.getByRole('status', { name: 'Notifications' })
+
+ expect(status).toHaveTextContent('hello world')
+})
+```
diff --git a/examples/wrappers/wrapper.svelte b/examples/wrappers/wrapper.svelte
new file mode 100644
index 0000000..098b03e
--- /dev/null
+++ b/examples/wrappers/wrapper.svelte
@@ -0,0 +1,13 @@
+
+
+{@render children?.()}
diff --git a/packages/svelte-core/README.md b/packages/svelte-core/README.md
index 174399a..8010664 100644
--- a/packages/svelte-core/README.md
+++ b/packages/svelte-core/README.md
@@ -68,19 +68,20 @@ const { baseElement, container, component, rerender, unmount } = render(
)
```
-| Argument | Type | Description |
-| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
-| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
-| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
-| `setupOptions` | `{ baseElement?: HTMLElement }` | Optionally override `baseElement` |
-
-| Result | Type | Description | Default |
-| ------------- | ------------------------------------------ | ---------------------------------------- | ----------------------------------- |
-| `baseElement` | `HTMLElement` | The base element | `document.body` |
-| `container` | `HTMLElement` | The component's immediate parent element | `
` appended to `document.body` |
-| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` | N/A |
-| `rerender` | `(props: Partial) => Promise` | Update the component's props | N/A |
-| `unmount` | `() => void` | Unmount the component from the document | N/A |
+| Argument | Type | Description |
+| ------------------ | ------------------------------------------------------- | ------------------------------------------------------- |
+| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
+| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
+| `setupOptions` | [`SetupOptions`](#setup) | Optionally override `baseElement` or wrap the component |
+
+| Result | Type | Description | Default |
+| ------------- | ------------------------------------------ | -------------------------------------------------- | ----------------------------------- |
+| `baseElement` | `HTMLElement` | The base element | `document.body` |
+| `container` | `HTMLElement` | The component's immediate parent element | `
` appended to `document.body` |
+| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` | N/A |
+| `wrapper` | [component exports][svelte-mount-docs] | The wrapper's exports, if a `wrapper` was provided | `undefined` |
+| `rerender` | `(props: Partial) => Promise` | Update the component's props | N/A |
+| `unmount` | `() => void` | Unmount the component from the document | N/A |
> \[!TIP]
> Calling `render` is equivalent to calling [`setup`](#setup) followed by [`mount`](#mount)
@@ -90,7 +91,11 @@ const { baseElement, container, component, rerender, unmount } = render(
> componentOptions,
> setupOptions
> )
-> const { component, rerender, unmount } = mount(Component, mountOptions)
+> const { component, rerender, unmount } = mount(
+> Component,
+> mountOptions,
+> setupOptions
+> )
> ```
[svelte-component-docs]: https://svelte.dev/docs/svelte-components
@@ -110,7 +115,15 @@ const { baseElement, container, mountOptions } = setup(
| Argument | Type | Description |
| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
-| `setupOptions` | `{ baseElement?: HTMLElement }` | Optionally override `baseElement` |
+| `setupOptions` | `SetupOptions` | Configure the document and wrap the component |
+
+`setupOptions` accepts the following fields, all optional:
+
+| Field | Type | Description | Default |
+| -------------- | ----------------------------------------- | --------------------------------------------------------------------- | --------------- |
+| `baseElement` | `HTMLElement` | The base element to bind queries to | `document.body` |
+| `wrapper` | [Svelte component][svelte-component-docs] | A component to wrap the component under test, e.g. a context provider | `undefined` |
+| `wrapperProps` | `Props` | Props to pass to the `wrapper` component | `undefined` |
| Result | Type | Description | Default |
| -------------- | ------------------------------------ | ---------------------------------------- | ----------------------------------- |
@@ -123,19 +136,25 @@ const { baseElement, container, mountOptions } = setup(
Mount a Svelte component into the document.
```ts
-const { component, unmount, rerender } = mount(Component, mountOptions)
+const { component, wrapper, unmount, rerender } = mount(
+ Component,
+ mountOptions,
+ setupOptions
+)
```
-| Argument | Type | Description |
-| -------------- | ----------------------------------------- | -------------------------------------------- |
-| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
-| `mountOptions` | [component options][svelte-mount-docs] | Options to pass to Svelte's `mount` function |
-
-| Result | Type | Description |
-| ----------- | ------------------------------------------ | --------------------------------------- |
-| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` |
-| `unmount` | `() => void` | Unmount the component from the document |
-| `rerender` | `(props: Partial) => Promise` | Update the component's props |
+| Argument | Type | Description |
+| -------------- | ----------------------------------------- | --------------------------------------------------------- |
+| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
+| `mountOptions` | [component options][svelte-mount-docs] | Options to pass to Svelte's `mount` function |
+| `setupOptions` | [`SetupOptions`](#setup) | Optionally wrap the component (`wrapper`, `wrapperProps`) |
+
+| Result | Type | Description |
+| ----------- | ------------------------------------------ | -------------------------------------------------- |
+| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` |
+| `wrapper` | [component exports][svelte-mount-docs] | The wrapper's exports, if a `wrapper` was provided |
+| `unmount` | `() => void` | Unmount the component from the document |
+| `rerender` | `(props: Partial) => Promise` | Update the component's props |
### `cleanup`
diff --git a/packages/svelte-core/src/mount.js b/packages/svelte-core/src/mount.js
index 2927437..e21ae9c 100644
--- a/packages/svelte-core/src/mount.js
+++ b/packages/svelte-core/src/mount.js
@@ -6,6 +6,7 @@ import * as Svelte from 'svelte'
import { addCleanupTask, removeCleanupTask } from './cleanup.js'
import { createProps } from './props.svelte.js'
import { IS_MODERN_SVELTE } from './svelte-version.js'
+import WrapperScaffold from './wrapper-scaffold.svelte'
/**
* Mount a modern Svelte 5 component into the DOM.
@@ -73,22 +74,82 @@ const mountLegacy = (Component, options) => {
/** The mount method in use. */
const mountComponent = IS_MODERN_SVELTE ? mountModern : mountLegacy
+/**
+ * Extract a component from an import.
+ *
+ * Allows a dynamic `import('component.svelte')` to be used
+ * for component values.
+ *
+ * @template {import('../types.js').Component} C
+ * @param {import('../types.js').ComponentImport} componentImport
+ * @returns {import('../types.js').ComponentType}
+ */
+const unwrapComponentImport = (componentImport) => {
+ return 'default' in componentImport
+ ? componentImport.default
+ : componentImport
+}
+
/**
* Render a Svelte component into the document.
*
* @template {import('../types.js').Component} C
+ * @template {import('../types.js').Component} [W=never]
+ *
+ * @param {import('../types.js').ComponentImport} Component
+ * @param {import('../types.js').MountOptions} mountOptions
+ * @param {import('../types.js').SetupOptions} [setupOptions]
+ * @returns {{componentToMount: import('../types.js').ComponentType, mountOptions: import('../types.js').MountOptions, isWrapper: boolean}}
+ */
+const setupComponent = (Component, mountOptions, setupOptions = {}) => {
+ const componentToMount = unwrapComponentImport(Component)
+ const { wrapper, wrapperProps } = setupOptions
+
+ if (wrapper) {
+ return {
+ isWrapper: true,
+ componentToMount: WrapperScaffold,
+ mountOptions: {
+ ...mountOptions,
+ props: {
+ wrapper: unwrapComponentImport(wrapper),
+ wrapperProps,
+ component: componentToMount,
+ componentProps: mountOptions.props,
+ },
+ },
+ }
+ }
+
+ return { isWrapper: false, componentToMount, mountOptions }
+}
+
+/**
+ * Render a Svelte component into the document.
+ *
+ * @template {import('../types.js').Component} C
+ * @template {import('../types.js').Component} [W=never]
+ *
* @param {import('../types.js').ComponentImport} Component
* @param {import('../types.js').MountOptions} options
+ * @param {import('../types.js').SetupOptions} [setupOptions]
* @returns {import('../types.js').MountResult}
*/
-const mount = (Component, options) => {
+const mount = (Component, options, setupOptions = {}) => {
+ const { componentToMount, mountOptions, isWrapper } = setupComponent(
+ Component,
+ options,
+ setupOptions
+ )
+
const { component, unmount, rerender } = mountComponent(
- 'default' in Component ? Component.default : Component,
- options
+ componentToMount,
+ mountOptions
)
return {
- component,
+ component: isWrapper ? component.getComponent() : component,
+ wrapper: isWrapper ? component.getWrapper() : undefined,
unmount,
rerender: async (props) => {
if ('props' in props) {
@@ -98,6 +159,12 @@ const mount = (Component, options) => {
props = props.props
}
+ if (isWrapper) {
+ props = {
+ componentProps: { ...component.getComponentProps(), ...props },
+ }
+ }
+
rerender(props)
// Await the next tick for Svelte 3/4, which cannot flush changes synchronously
await Svelte.tick()
diff --git a/packages/svelte-core/src/render.js b/packages/svelte-core/src/render.js
index 0c0ff28..bfe495c 100644
--- a/packages/svelte-core/src/render.js
+++ b/packages/svelte-core/src/render.js
@@ -5,15 +5,16 @@ import { setup } from './setup.js'
* Render a component into the document.
*
* @template {import('../types.js').Component} C
+ * @template {import('../types.js').Component} [W=never]
*
* @param {import('../types.js').ComponentImport} Component - The component to render.
* @param {import('../types.js').ComponentOptions} componentOptions - Customize how Svelte renders the component.
- * @param {import('../types.js').SetupOptions} setupOptions - Customize how the document is set up.
- * @returns {import('../types.js').RenderResult} The rendered component.
+ * @param {import('../types.js').SetupOptions} setupOptions - Customize how the document and component is set up.
+ * @returns {import('../types.js').RenderResult} The rendered component.
*/
const render = (Component, componentOptions, setupOptions = {}) => {
const { mountOptions, ...setupResult } = setup(componentOptions, setupOptions)
- const mountResult = mount(Component, mountOptions)
+ const mountResult = mount(Component, mountOptions, setupOptions)
return { ...setupResult, ...mountResult }
}
diff --git a/packages/svelte-core/src/setup.js b/packages/svelte-core/src/setup.js
index 04c6c4f..4fa560c 100644
--- a/packages/svelte-core/src/setup.js
+++ b/packages/svelte-core/src/setup.js
@@ -56,8 +56,9 @@ const validateOptions = (options) => {
* Set up the document to render a component.
*
* @template {import('../types.js').Component} C
+ *
* @param {import('../types.js').ComponentOptions} componentOptions - props or mount options
- * @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries
+ * @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries
* @returns {import('../types.js').SetupResult}
*/
const setup = (componentOptions, setupOptions = {}) => {
diff --git a/packages/svelte-core/src/wrapper-scaffold.svelte b/packages/svelte-core/src/wrapper-scaffold.svelte
new file mode 100644
index 0000000..7679834
--- /dev/null
+++ b/packages/svelte-core/src/wrapper-scaffold.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/packages/svelte-core/types.d.ts b/packages/svelte-core/types.d.ts
index 1a3b3ab..418beec 100644
--- a/packages/svelte-core/types.d.ts
+++ b/packages/svelte-core/types.d.ts
@@ -91,19 +91,25 @@ export type Rerender = (
) => Promise
/** The result of mounting a component into the document. */
-export interface MountResult {
+export interface MountResult {
/** The mounted component's exports. */
component: Exports
+ /** The mounted wrapper's exports, if a wrapper was provided. */
+ wrapper: Exports
/** Unmount the component. */
unmount: () => void
/** Rerender the component. */
rerender: Rerender
}
-/** Options for configuring the document. */
-export interface SetupOptions {
+/** Options for configuring the component and document. */
+export interface SetupOptions {
/** The base document element, `document.body` if unspecified. */
baseElement?: HTMLElement
+ /** A wrapper component. */
+ wrapper?: ComponentImport
+ /** Wrapper component props. */
+ wrapperProps?: Props
}
/** The result of setting up the document for rendering. */
@@ -117,13 +123,18 @@ export interface SetupResult {
}
/** The result of setting up the document and rendering the component. */
-export interface RenderResult {
+export interface RenderResult<
+ C extends Component,
+ W extends Component = never,
+> {
/** The base document element, usually `document.body`. */
baseElement: HTMLElement
/** The component's immediate container element, usually a `