diff --git a/.changeset/nice-mirrors-scream.md b/.changeset/nice-mirrors-scream.md
new file mode 100644
index 000000000000..eded4ba724cc
--- /dev/null
+++ b/.changeset/nice-mirrors-scream.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: add official test utils for remote functions
diff --git a/documentation/docs/30-advanced/75-testing.md b/documentation/docs/30-advanced/75-testing.md
new file mode 100644
index 000000000000..df61fba71114
--- /dev/null
+++ b/documentation/docs/30-advanced/75-testing.md
@@ -0,0 +1,304 @@
+---
+title: Testing
+---
+
+SvelteKit provides test utilities in `@sveltejs/kit/test` for unit testing [remote functions](remote-functions) and components that use them. The core utilities (`createTestEvent`, `withRequestContext`, `callRemote`, `setLocals`, `mockRemote`) are test-runner agnostic.
+
+A [Vitest](https://vitest.dev) plugin is also provided that supplies automatic request context, virtual module resolution, and `.remote.ts` file transforms.
+
+## Testing remote function logic
+
+Test the server-side business logic of your remote functions — call them directly and assert on the result.
+
+### With the Vitest plugin
+
+The `svelteKitTest` plugin handles virtual module resolution and establishes a request context automatically for each test:
+
+```js
+/// file: vitest.config.js
+import { svelteKitTest } from '@sveltejs/kit/test/vitest';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [+++svelteKitTest()+++]
+});
+```
+
+Remote functions can then be called directly — no wrappers needed:
+
+```js
+/// file: src/routes/blog/data.remote.test.ts
+import { getPosts } from './data.remote';
+
+test('returns blog posts', async () => {
+ const posts = await getPosts();
+ expect(posts.length).toBeGreaterThan(0);
+ expect(posts[0]).toHaveProperty('title');
+});
+```
+
+Use `setLocals` to configure `event.locals` for the current test:
+
+```js
+/// file: src/routes/dashboard/data.remote.test.ts
+import { setLocals } from '@sveltejs/kit/test';
+import { getDashboard } from './data.remote';
+
+beforeEach(() => {
+ setLocals({ user: { id: '123', role: 'admin' } });
+});
+
+test('returns dashboard for authenticated user', async () => {
+ const dashboard = await getDashboard();
+ // getRequestEvent().locals.user is available inside the remote function
+ expect(dashboard.role).toBe('admin');
+});
+```
+
+### Without the Vitest plugin
+
+Use `createTestEvent` and `withRequestContext` directly with any test runner:
+
+```js
+import { createTestEvent, withRequestContext } from '@sveltejs/kit/test';
+import { getPosts } from './data.remote';
+
+test('returns blog posts', async () => {
+ const event = createTestEvent({ locals: { user: { id: '123' } } });
+ const posts = await withRequestContext(event, () => getPosts());
+ expect(posts.length).toBeGreaterThan(0);
+});
+```
+
+You will need to configure virtual module aliases manually in your test runner for `$app/server` and its dependencies.
+
+### callRemote
+
+`callRemote` is a convenience wrapper that auto-detects the function type and sets the HTTP method accordingly (GET for queries, POST for commands and forms):
+
+```js
+import { callRemote } from '@sveltejs/kit/test';
+import { getPosts, addPost } from './data.remote';
+
+// queries auto-detect GET
+const posts = await callRemote(getPosts);
+
+// commands auto-detect POST
+const result = await callRemote(addPost, { title: 'Hello', content: '...' });
+```
+
+For forms, `callRemote` invokes the internal handler and returns the full output:
+
+```js
+import { callRemote } from '@sveltejs/kit/test';
+import { contactForm } from './contact.remote';
+
+const output = await callRemote(contactForm, { name: 'Alice', message: 'Hi' });
+// output.result — the handler's return value
+// output.issues — validation issues (if any)
+```
+
+### Validation errors
+
+When a remote function's schema validation fails, an [`HttpValidationError`](@sveltejs-kit-test#HttpValidationError) is thrown with typed `.issues`:
+
+```js
+import { HttpValidationError } from '@sveltejs/kit/test';
+import { addPost } from './data.remote';
+
+test('rejects invalid input', async () => {
+ try {
+ await addPost({ title: '' }); // schema requires non-empty title
+ } catch (e) {
+ if (e instanceof HttpValidationError) {
+ expect(e.status).toBe(400);
+ expect(e.issues[0].message).toBe('Title is required');
+ }
+ }
+});
+```
+
+`HttpValidationError` extends `HttpError`, so existing `instanceof HttpError` checks still pass.
+
+## Testing components
+
+Test Svelte components that use remote functions by controlling what data they receive, without executing server logic.
+
+> [!NOTE] Component testing with `mockRemote` requires the `svelteKitTest({ mode: 'component' })` Vitest plugin. The plugin transforms `.remote.ts` imports to use a mock runtime that reads from the `mockRemote` registry — without it, the imports resolve to server-side code and mocks have no effect.
+
+### Setup
+
+Use the plugin in component mode. This transforms `.remote.ts` imports to use a mock runtime with reactive objects instead of making HTTP requests:
+
+```js
+/// file: vitest.config.js
+import { sveltekit } from '@sveltejs/kit/vite';
+import { svelteKitTest } from '@sveltejs/kit/test/vitest';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit(), svelteKitTest({ +++mode: 'component'+++ })]
+});
+```
+
+### Mocking queries and commands
+
+Given a component that renders data from a remote query:
+
+```svelte
+
+
+
+{#if posts.loading}
+
Loading...
+{:else}
+
+ {#each posts.current as { title }}
+ - {title}
+ {/each}
+
+{/if}
+```
+
+Use `mockRemote` to control what data the component receives:
+
+```js
+/// file: src/routes/blog/+page.component.test.ts
+import { mockRemote } from '@sveltejs/kit/test';
+import { getPosts } from './data.remote';
+
+test('renders blog posts', async () => {
+ mockRemote(getPosts).returns([
+ { title: 'First post' },
+ { title: 'Second post' }
+ ]);
+
+ // render the component and assert on the output
+});
+```
+
+Mock queries provide the same reactive interface components expect (`.current`, `.loading`, `.ready`, `.error`), backed by Svelte 5 `$state`.
+
+You can also simulate errors:
+
+```js
+mockRemote(getPosts).throws(500, { message: 'Database unavailable' });
+```
+
+Or provide a dynamic resolver:
+
+```js
+mockRemote(getPosts).resolves(() => [{ title: 'Dynamic post' }]);
+```
+
+### Mocking forms
+
+Given a component with a form:
+
+```svelte
+
+
+
+
+```
+
+Use `mockRemote` with form-specific methods to control field values, validation issues, and submission results:
+
+```js
+/// file: src/routes/contact/+page.component.test.ts
+import { mockRemote } from '@sveltejs/kit/test';
+import { contactForm } from './contact.remote';
+
+test('shows validation errors', async () => {
+ mockRemote(contactForm).withFieldIssues({
+ name: [{ message: 'Name is required' }]
+ });
+
+ // render the component — the error message should be visible
+});
+
+test('shows success after submission', async () => {
+ mockRemote(contactForm).returns({ sent: true });
+
+ // render the component — "Message sent!" should be visible
+});
+
+test('pre-populates form fields', async () => {
+ mockRemote(contactForm).withFieldValues({
+ name: 'Alice',
+ message: 'Hello!'
+ });
+
+ // render the component — inputs should have the mocked values
+});
+```
+
+Methods are chainable:
+
+```js
+mockRemote(contactForm)
+ .returns({ sent: true })
+ .withFieldValues({ name: 'Alice' })
+ .withFieldIssues({ message: [{ message: 'Too short' }] });
+```
+
+The mock form implements the [`RemoteForm`](@sveltejs-kit#RemoteForm) interface:
+- `form.fields.name.as('text')` returns input props with the mocked value
+- `form.fields.name.value()` returns the mocked field value
+- `form.fields.name.issues()` returns the mocked validation issues
+- `form.result` returns the mocked submission result
+
+### Using both server + component modes
+
+A project may need both server-mode tests (for remote function logic) and component-mode tests (for rendering). One option is to use [Vitest projects](https://vitest.dev/guide/workspace):
+
+```js
+/// file: vitest.config.js
+import { sveltekit } from '@sveltejs/kit/vite';
+import { svelteKitTest } from '@sveltejs/kit/test/vitest';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ projects: [
+ {
+ extends: true,
+ plugins: [svelteKitTest({ mode: 'server' })],
+ test: {
+ name: 'server',
+ include: ['src/**/*.test.ts']
+ }
+ },
+ {
+ extends: true,
+ plugins: [svelteKitTest({ mode: 'component' })],
+ test: {
+ name: 'component',
+ include: ['src/**/*.component.test.ts']
+ }
+ }
+ ]
+ }
+});
+```
diff --git a/packages/kit/kit.vitest.config.js b/packages/kit/kit.vitest.config.js
index 6683a04297c4..08292351de3e 100644
--- a/packages/kit/kit.vitest.config.js
+++ b/packages/kit/kit.vitest.config.js
@@ -1,8 +1,10 @@
-import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
+import { svelteKitTest } from './src/exports/test/vitest.js';
// this file needs a custom name so that the numerous test subprojects don't all pick it up
export default defineConfig({
+ // dogfood our own plugin
+ plugins: [svelteKitTest()],
define: {
__SVELTEKIT_SERVER_TRACING_ENABLED__: false
},
@@ -12,9 +14,6 @@ export default defineConfig({
}
},
test: {
- alias: {
- '__sveltekit/paths': fileURLToPath(new URL('./test/mocks/path.js', import.meta.url))
- },
pool: 'threads',
maxWorkers: 1,
include: ['src/**/*.spec.js'],
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 622537590943..cdfafab5fda1 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -126,6 +126,14 @@
"./vite": {
"types": "./types/index.d.ts",
"import": "./src/exports/vite/index.js"
+ },
+ "./test": {
+ "types": "./types/index.d.ts",
+ "import": "./src/exports/test/index.js"
+ },
+ "./test/vitest": {
+ "types": "./types/index.d.ts",
+ "import": "./src/exports/test/vitest.js"
}
},
"types": "types/index.d.ts",
diff --git a/packages/kit/scripts/generate-dts.js b/packages/kit/scripts/generate-dts.js
index b470d6d63494..04adffd704f1 100644
--- a/packages/kit/scripts/generate-dts.js
+++ b/packages/kit/scripts/generate-dts.js
@@ -9,6 +9,8 @@ await createBundle({
'@sveltejs/kit/node': 'src/exports/node/index.js',
'@sveltejs/kit/node/polyfills': 'src/exports/node/polyfills.js',
'@sveltejs/kit/vite': 'src/exports/vite/index.js',
+ '@sveltejs/kit/test': 'src/exports/test/index.js',
+ '@sveltejs/kit/test/vitest': 'src/exports/test/vitest.js',
'$app/environment': 'src/runtime/app/environment/types.d.ts',
'$app/forms': 'src/runtime/app/forms.js',
'$app/navigation': 'src/runtime/app/navigation.js',
diff --git a/packages/kit/src/exports/internal/event.js b/packages/kit/src/exports/internal/event.js
index 9d658494e1be..825c56f92ea0 100644
--- a/packages/kit/src/exports/internal/event.js
+++ b/packages/kit/src/exports/internal/event.js
@@ -83,3 +83,27 @@ export function with_request_store(store, fn) {
}
}
}
+
+/**
+ * Sets a persistent request store for the current async context using `als.enterWith()`.
+ * Unlike `with_request_store` (which scopes to a callback), this persists through
+ * the remainder of the current execution — including across nested `with_request_store`
+ * calls, which properly restore the outer ALS context when they complete.
+ *
+ * Intended for usage in test environments (e.g. called from `beforeEach`
+ * in the svelteKitTest plugin setup).
+ *
+ * @param {RequestStore} store
+ */
+export function __test_set_request_store(store) {
+ als?.enterWith(store);
+}
+
+/**
+ * Clears the request store set by `__test_set_request_store`.
+ * Intended for usage in test environments (e.g. called from `afterEach`
+ * in the svelteKitTest plugin setup).
+ */
+export function __test_clear_request_store() {
+ als?.enterWith(/** @type {any} */ (null));
+}
diff --git a/packages/kit/src/exports/internal/server.js b/packages/kit/src/exports/internal/server.js
index ed425062637f..52ea7a2104e0 100644
--- a/packages/kit/src/exports/internal/server.js
+++ b/packages/kit/src/exports/internal/server.js
@@ -18,5 +18,7 @@ export {
with_request_store,
getRequestEvent,
get_request_store,
- try_get_request_store
+ try_get_request_store,
+ __test_set_request_store,
+ __test_clear_request_store
} from './event.js';
diff --git a/packages/kit/src/exports/test/fixtures/sample.remote.js b/packages/kit/src/exports/test/fixtures/sample.remote.js
new file mode 100644
index 000000000000..396235193fa9
--- /dev/null
+++ b/packages/kit/src/exports/test/fixtures/sample.remote.js
@@ -0,0 +1,24 @@
+import { query, command, form } from '$app/server';
+
+export const echo = query('unchecked', (/** @type {string} */ value) => value);
+
+export const say_ok = command(() => {
+ return 'ok';
+});
+
+const name_schema =
+ /** @type {import('@standard-schema/spec').StandardSchemaV1<{ name: string }>} */ ({
+ '~standard': {
+ validate: (/** @type {unknown} */ value) => {
+ const v = /** @type {any} */ (value);
+ if (typeof v === 'object' && v !== null && typeof v.name === 'string') {
+ return { value };
+ }
+ return { issues: [{ message: 'name is required', path: [{ key: 'name' }] }] };
+ }
+ }
+ });
+
+export const greeting_form = form(name_schema, (/** @type {{ name: string }} */ data) => {
+ return { greeting: `Hello, ${data.name}!` };
+});
diff --git a/packages/kit/src/exports/test/index.js b/packages/kit/src/exports/test/index.js
new file mode 100644
index 000000000000..326b46ca329e
--- /dev/null
+++ b/packages/kit/src/exports/test/index.js
@@ -0,0 +1,338 @@
+/** @import { RequestEvent, Cookies, RemoteQueryFunction, RemoteCommand, RemoteForm, RemoteFormInput } from '@sveltejs/kit' */
+/** @import { RequestState, RequestStore } from 'types' */
+/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
+
+import { with_request_store, try_get_request_store } from '@sveltejs/kit/internal/server';
+import { HttpError } from '@sveltejs/kit/internal';
+import { noop_span } from '../../runtime/telemetry/noop.js';
+import { get_cookies } from '../../runtime/server/cookie.js';
+
+/**
+ * An `HttpError` subclass thrown when a remote function's schema validation fails
+ * during testing. Extends `HttpError` so `instanceof HttpError` checks still pass,
+ * but also exposes the Standard Schema `.issues` for test assertions.
+ *
+ * @example
+ * ```js
+ * import { HttpValidationError } from '@sveltejs/kit/test';
+ *
+ * try {
+ * await myQuery(invalidArg);
+ * } catch (e) {
+ * if (e instanceof HttpValidationError) {
+ * console.log(e.status); // 400
+ * console.log(e.issues); // [{ message: 'Expected a string' }]
+ * }
+ * }
+ * ```
+ */
+export class HttpValidationError extends HttpError {
+ /** @type {StandardSchemaV1.Issue[]} */
+ issues;
+
+ /**
+ * @param {number} status
+ * @param {App.Error} body
+ * @param {StandardSchemaV1.Issue[]} issues
+ */
+ constructor(status, body, issues) {
+ super(status, body);
+ this.issues = issues;
+ }
+}
+
+/**
+ * Creates a mock `RequestEvent` for use in test environments.
+ *
+ * @example
+ * ```js
+ * import { createTestEvent } from '@sveltejs/kit/test';
+ *
+ * const event = createTestEvent({
+ * url: 'http://localhost/blog/hello',
+ * method: 'POST',
+ * locals: { user: { id: '123' } }
+ * });
+ * ```
+ *
+ * @param {object} [options]
+ * @param {string} [options.url] The URL of the request. Defaults to `'http://localhost/'`.
+ * @param {string} [options.method] The HTTP method. Defaults to `'GET'`.
+ * @param {Record} [options.headers] Request headers.
+ * @param {App.Locals} [options.locals] Custom data for `event.locals`.
+ * @param {Record} [options.params] Route parameters.
+ * @param {Record} [options.cookies] Initial cookies as name-value pairs.
+ * @param {Cookies} [options.cookiesObject] A full Cookies implementation (overrides `cookies`).
+ * @param {string | null} [options.routeId] The route ID. Defaults to `'/'`.
+ * @param {typeof fetch} [options.fetch] Custom fetch implementation.
+ * @param {() => string} [options.getClientAddress] Custom client address function.
+ * @param {Readonly} [options.platform] Platform-specific data.
+ * @param {BodyInit | null} [options.body] Request body.
+ * @returns {RequestEvent}
+ */
+export function createTestEvent(options = {}) {
+ const url = new URL(options.url ?? 'http://localhost/');
+ const method = options.method ?? 'GET';
+
+ // build cookie header from the initial cookies map, if provided
+ const cookie_header = options.cookies
+ ? Object.entries(options.cookies)
+ .map(([k, v]) => `${k}=${v}`)
+ .join('; ')
+ : '';
+
+ const incoming_headers = new Headers(options.headers);
+ if (cookie_header) {
+ incoming_headers.set('cookie', cookie_header);
+ }
+
+ const request = new Request(url, {
+ method,
+ headers: incoming_headers,
+ body: options.body ?? null
+ });
+
+ let cookies;
+ if (options.cookiesObject) {
+ cookies = options.cookiesObject;
+ } else {
+ const cookie_state = get_cookies(request, url);
+ cookie_state.set_trailing_slash('never');
+ cookies = cookie_state.cookies;
+ }
+
+ return /** @type {RequestEvent} */ ({
+ cookies,
+ fetch: options.fetch ?? globalThis.fetch,
+ getClientAddress: options.getClientAddress ?? (() => '127.0.0.1'),
+ locals: /** @type {App.Locals} */ (options.locals ?? {}),
+ params: options.params ?? {},
+ platform: options.platform,
+ request,
+ route: { id: options.routeId ?? '/' },
+ setHeaders: () => {},
+ url,
+ isDataRequest: false,
+ isSubRequest: false,
+ isRemoteRequest: false,
+ tracing: {
+ enabled: false,
+ root: noop_span,
+ current: noop_span
+ }
+ });
+}
+
+/**
+ * Creates a default `RequestState` suitable for test environments.
+ *
+ * The `handleValidationError` hook throws `HttpValidationError` directly,
+ * short-circuiting the framework's `error(400, ...)` call. Since
+ * `HttpValidationError` extends `HttpError`, existing `instanceof HttpError`
+ * checks still pass — the only difference is the `.issues` property is
+ * available for test assertions. This works identically regardless of whether
+ * context was established via `withRequestContext` or auto-context.
+ *
+ * @param {object} [options]
+ * @param {Record any, decode: (value: any) => any }>} [options.transport]
+ * @returns {RequestState}
+ */
+export function createTestState(options = {}) {
+ return /** @type {RequestState} */ ({
+ prerendering: undefined,
+ transport: options.transport ?? {},
+ handleValidationError: ({ issues }) => {
+ throw new HttpValidationError(400, { message: 'Bad Request' }, issues);
+ },
+ tracing: {
+ record_span: ({ fn }) => fn(noop_span)
+ },
+ remote: {
+ data: null,
+ forms: null,
+ refreshes: null,
+ requested: null,
+ validated: null
+ },
+ is_in_remote_function: false,
+ is_in_render: false,
+ is_in_universal_load: false
+ });
+}
+
+/**
+ * Wraps a function call in a SvelteKit request context, making `getRequestEvent()`
+ * and remote functions (`query`, `command`, `form`) work inside the callback.
+ *
+ * @example
+ * ```js
+ * import { createTestEvent, withRequestContext } from '@sveltejs/kit/test';
+ * import { getRequestEvent } from '$app/server';
+ *
+ * const event = createTestEvent({ locals: { user: { id: '123' } } });
+ * const locals = withRequestContext(event, () => getRequestEvent().locals);
+ * // locals === { user: { id: '123' } }
+ * ```
+ *
+ * @template T
+ * @param {RequestEvent} event The mock request event (use `createTestEvent` to create one)
+ * @param {() => T} fn The function to execute within the request context
+ * @param {object} [options]
+ * @param {Record any, decode: (value: any) => any }>} [options.transport] Custom transport encoders/decoders
+ * @returns {T}
+ */
+export function withRequestContext(event, fn, options = {}) {
+ /** @type {RequestStore} */
+ const store = {
+ event,
+ state: createTestState(options)
+ };
+
+ return with_request_store(store, fn);
+}
+
+const MUTATIVE_TYPES = ['command', 'form'];
+
+/** @typedef {object} CallRemoteOptions
+ * @property {string} [url] The URL of the request
+ * @property {string} [method] Override the auto-detected HTTP method
+ * @property {Record} [headers] Request headers
+ * @property {App.Locals} [locals] Custom data for `event.locals`
+ * @property {Record} [params] Route parameters
+ * @property {Record} [cookies] Initial cookies
+ * @property {string | null} [routeId] The route ID
+ * @property {Record any, decode: (value: any) => any }>} [transport] Custom transport
+ */
+
+/**
+ * Calls a RemoteQueryFunction with a test request context.
+ *
+ * If a remote function's schema validation fails, the resulting `HttpError` is caught
+ * and rethrown as an `HttpValidationError` with the Standard Schema `.issues` attached.
+ *
+ * @template QueryOutput
+ * @overload
+ * @param {RemoteQueryFunction} fn
+ * @param {void} [arg]
+ * @param {CallRemoteOptions} [options]
+ * @returns {Promise}
+ */
+/**
+ * @template QueryInput
+ * @template QueryOutput
+ * @overload
+ * @param {RemoteQueryFunction} fn
+ * @param {QueryInput} arg
+ * @param {CallRemoteOptions} [options]
+ * @returns {Promise}
+ */
+
+/**
+ * Calls a RemoteCommand with a test request context.
+ *
+ * If a remote function's schema validation fails, the resulting `HttpError` is caught
+ * and rethrown as an `HttpValidationError` with the Standard Schema `.issues` attached.
+ *
+ * @template CommandOutput
+ * @overload
+ * @param {RemoteCommand} fn
+ * @param {void} [arg]
+ * @param {CallRemoteOptions} [options]
+ * @returns {Promise}
+ */
+/**
+ * @template CommandInput
+ * @template CommandOutput
+ * @overload
+ * @param {RemoteCommand} fn
+ * @param {CommandInput} arg
+ * @param {CallRemoteOptions} [options]
+ * @returns {Promise}
+ */
+
+/**
+ * Calls a RemoteForm's handler with a test request context.
+ *
+ * If a remote function's schema validation fails, issues are
+ * returned in output object (not thrown).
+ *
+ * @template FormOutput
+ * @overload
+ * @param {RemoteForm} fn
+ * @param {void} [arg]
+ * @param {CallRemoteOptions} [options]
+ * @returns {Promise<{ submission: true, result?: FormOutput, issues?: import('@sveltejs/kit').RemoteFormIssue[] }>}
+ */
+/**
+ * @template {RemoteFormInput} FormInput
+ * @template FormOutput
+ * @overload
+ * @param {RemoteForm} fn
+ * @param {Record} arg
+ * @param {CallRemoteOptions} [options]
+ * @returns {Promise<{ submission: true, result?: FormOutput, issues?: import('@sveltejs/kit').RemoteFormIssue[] }>}
+ */
+
+/**
+ * Calls a remote function with a test request context. Auto-detects the HTTP
+ * method from the function type (GET for queries, POST for commands and forms).
+ *
+ * @example
+ * ```js
+ * import { callRemote } from '@sveltejs/kit/test';
+ * import { myQuery, myCommand, myForm } from './data.remote.ts';
+ *
+ * const value = await callRemote(myQuery, 'arg');
+ * const result = await callRemote(myCommand, { name: 'Alice' });
+ * const output = await callRemote(myForm, { name: 'Alice' });
+ * // output.result, output.issues
+ * ```
+ *
+ * @param {any} fn
+ * @param {any} [arg]
+ * @param {CallRemoteOptions} [options]
+ * @returns {Promise}
+ */
+export async function callRemote(fn, arg, options = {}) {
+ const type = fn.__?.type;
+ const method = options.method ?? (MUTATIVE_TYPES.includes(type) ? 'POST' : 'GET');
+ const event = createTestEvent({ ...options, method });
+
+ if (type === 'form') {
+ // Forms aren't callable — invoke the internal handler directly with
+ // form data as a POJO. Returns { submission, result, issues? } matching
+ // actual form behavior (e.g. forms don't throw on validation failure).
+ return withRequestContext(event, () => fn.__.fn(arg ?? {}, {}, null), options);
+ }
+
+ return withRequestContext(event, () => fn(arg), options);
+}
+
+/**
+ * Sets `event.locals` on the current test's request context.
+ * Can be called inside `withRequestContext`, or inside a test when
+ * auto-context is active via the svelteKitTest Vitest plugin.
+ *
+ * @example
+ * ```js
+ * import { setLocals } from '@sveltejs/kit/test';
+ * import { getRequestEvent } from '$app/server';
+ *
+ * setLocals({ user: { id: '123' } });
+ * const { locals } = getRequestEvent();
+ * // locals.user.id === '123'
+ * ```
+ *
+ * @param {App.Locals} locals
+ */
+export function setLocals(locals) {
+ const store = try_get_request_store();
+ if (!store) {
+ throw new Error(
+ 'No request context found. Call setLocals inside withRequestContext or ensure auto-context is active.'
+ );
+ }
+ Object.assign(store.event.locals, locals);
+}
+
+export { mockRemote } from './mock-remote.js';
diff --git a/packages/kit/src/exports/test/index.spec.js b/packages/kit/src/exports/test/index.spec.js
new file mode 100644
index 000000000000..3680d91a29b4
--- /dev/null
+++ b/packages/kit/src/exports/test/index.spec.js
@@ -0,0 +1,155 @@
+import { assert, expect, test, describe } from 'vitest';
+import {
+ createTestEvent,
+ withRequestContext,
+ callRemote,
+ setLocals,
+ mockRemote,
+ HttpValidationError
+} from './index.js';
+import { getRequestEvent } from '@sveltejs/kit/internal/server';
+import { query } from '../../runtime/app/server/remote/query.js';
+import { command } from '../../runtime/app/server/remote/command.js';
+import { HttpError } from '@sveltejs/kit/internal';
+
+describe('createTestEvent', () => {
+ test('produces a valid RequestEvent with defaults', () => {
+ const event = createTestEvent();
+
+ assert.ok(event.url instanceof URL);
+ assert.equal(event.url.href, 'http://localhost/');
+ assert.equal(event.request.method, 'GET');
+ assert.equal(event.isDataRequest, false);
+ assert.equal(event.isSubRequest, false);
+ assert.equal(event.isRemoteRequest, false);
+ assert.equal(event.route.id, '/');
+ assert.equal(typeof event.getClientAddress, 'function');
+ assert.equal(event.getClientAddress(), '127.0.0.1');
+ assert.equal(typeof event.setHeaders, 'function');
+ assert.equal(typeof event.fetch, 'function');
+ assert.ok(event.cookies);
+ assert.ok(event.tracing);
+ assert.equal(event.tracing.enabled, false);
+ });
+
+ test('applies custom options', () => {
+ const event = createTestEvent({
+ url: 'http://example.com/blog/hello',
+ method: 'POST',
+ locals: { user: { id: '123' } },
+ params: { slug: 'hello' },
+ cookies: { session: 'abc' },
+ routeId: '/blog/[slug]'
+ });
+
+ assert.equal(event.url.pathname, '/blog/hello');
+ assert.equal(event.request.method, 'POST');
+ expect(event.locals).toEqual({ user: { id: '123' } });
+ expect(event.params).toEqual({ slug: 'hello' });
+ assert.equal(event.cookies.get('session'), 'abc');
+ assert.equal(event.route.id, '/blog/[slug]');
+ });
+});
+
+describe('withRequestContext', () => {
+ test('overrides auto-context with custom event', () => {
+ // auto-context provides a default event (empty locals)
+ // withRequestContext should use our custom event instead
+ const event = createTestEvent({ locals: { test_value: 42 } });
+
+ const result = withRequestContext(event, () => {
+ const req = getRequestEvent();
+ return req.locals;
+ });
+
+ expect(result).toEqual({ test_value: 42 });
+ });
+
+ test('propagates return value', () => {
+ const event = createTestEvent();
+
+ const result = withRequestContext(event, () => 'hello from test');
+
+ assert.equal(result, 'hello from test');
+ });
+
+ test('works with async', async () => {
+ const event = createTestEvent({ locals: { async_test: true } });
+
+ const result = await withRequestContext(event, async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ const req = getRequestEvent();
+ return req.locals;
+ });
+
+ expect(result).toEqual({ async_test: true });
+ });
+
+ test('surfaces validation errors from schema-validated remote functions', async () => {
+ // avoid a dev dependency on a validation library
+ const schema = /** @type {import('@standard-schema/spec').StandardSchemaV1} */ ({
+ '~standard': {
+ validate: (/** @type {unknown} */ value) => {
+ if (typeof value !== 'string') {
+ return { issues: [{ message: 'Expected a string' }] };
+ }
+ return { value };
+ }
+ }
+ });
+
+ const validated_query = query(schema, (arg) => {
+ return arg.toUpperCase();
+ });
+
+ const event = createTestEvent();
+
+ // valid input succeeds
+ const result = await withRequestContext(event, () => validated_query('hello'));
+ assert.equal(result, 'HELLO');
+
+ // invalid input throws an HttpValidationError with status 400 and typed issues
+ try {
+ await withRequestContext(event, () => validated_query(/** @type {any} */ (123)));
+ assert.fail('should have thrown');
+ } catch (e) {
+ // HttpValidationError extends HttpError, so both checks pass
+ assert.ok(e instanceof HttpValidationError);
+ assert.ok(e instanceof HttpError);
+ assert.equal(e.status, 400);
+ assert.equal(e.body.message, 'Bad Request');
+ expect(e.issues).toEqual([{ message: 'Expected a string' }]);
+ }
+ });
+});
+
+describe('callRemote', () => {
+ test('auto-detects GET for queries', async () => {
+ const my_query = query('unchecked', (/** @type {string} */ val) => val.toUpperCase());
+ const result = await callRemote(my_query, 'hello');
+ assert.equal(result, 'HELLO');
+ });
+
+ test('auto-detects POST for commands', async () => {
+ const my_command = command('unchecked', (/** @type {number} */ n) => n * 2);
+ const result = await callRemote(my_command, 5);
+ assert.equal(result, 10);
+ });
+});
+
+describe('setLocals', () => {
+ test('modifies the current request context event', () => {
+ const event = createTestEvent();
+ withRequestContext(event, () => {
+ setLocals({ custom_value: 'from setLocals' });
+ expect(getRequestEvent().locals).toEqual({ custom_value: 'from setLocals' });
+ });
+ });
+});
+
+describe('mockRemote', () => {
+ test('throws with a clear error if function has no ID', () => {
+ assert.throws(() => mockRemote({}), /not a remote function/);
+ assert.throws(() => mockRemote(null), /not a remote function/);
+ });
+});
diff --git a/packages/kit/src/exports/test/mock-registry.js b/packages/kit/src/exports/test/mock-registry.js
new file mode 100644
index 000000000000..37c66f59043a
--- /dev/null
+++ b/packages/kit/src/exports/test/mock-registry.js
@@ -0,0 +1,46 @@
+/**
+ * Central registry for mock remote function data.
+ * Used by mockRemote() to register mocks, and by the mock remote
+ * runtime to look up data during component rendering.
+ */
+
+/**
+ * @typedef {object} MockConfig
+ * @property {any} [data] Result data (queries, commands, form result)
+ * @property {{ status: number, body: any }} [error] Error to throw (queries, commands)
+ * @property {(arg: any) => any} [resolver] Dynamic resolver function
+ * @property {number} [delay] Delay in ms before resolving
+ * @property {Record} [fieldValues] Form field values
+ * @property {Record }>>} [fieldIssues] Form field validation issues
+ */
+
+/** @type {Map} */
+const registry = new Map();
+
+/**
+ * Gets the existing config for an ID, or creates a new empty one.
+ * This allows chainable builders to incrementally add properties.
+ *
+ * @param {string} id
+ * @returns {MockConfig}
+ */
+export function getOrCreateMock(id) {
+ let config = registry.get(id);
+ if (!config) {
+ config = {};
+ registry.set(id, config);
+ }
+ return config;
+}
+
+/**
+ * @param {string} id
+ * @returns {MockConfig | undefined}
+ */
+export function getMock(id) {
+ return registry.get(id);
+}
+
+export function resetMocks() {
+ registry.clear();
+}
diff --git a/packages/kit/src/exports/test/mock-remote.js b/packages/kit/src/exports/test/mock-remote.js
new file mode 100644
index 000000000000..e304caad2b73
--- /dev/null
+++ b/packages/kit/src/exports/test/mock-remote.js
@@ -0,0 +1,90 @@
+import { getOrCreateMock } from './mock-registry.js';
+
+/**
+ * Registers mock data for a remote function in component tests.
+ * The mock is keyed by the function's ID and read by the mock
+ * remote runtime during component rendering.
+ *
+ * Methods are chainable — call multiple to build up the mock state:
+ *
+ * @example
+ * ```js
+ * import { mockRemote } from '@sveltejs/kit/test';
+ * import { getUser } from './data.remote.ts';
+ * import { myForm } from './form.remote.ts';
+ *
+ * // Query/command — set return data
+ * mockRemote(getUser).returns({ name: 'Alice' });
+ *
+ * // Form — set result, field values, and validation issues
+ * mockRemote(myForm)
+ * .returns({ success: true })
+ * .withFieldValues({ email: 'alice@example.com' })
+ * .withFieldIssues({ name: [{ message: 'Required' }] });
+ * ```
+ *
+ * @param {any} fn The remote function to mock (imported from a .remote.ts file)
+ */
+export function mockRemote(fn) {
+ const id = fn?.__mock_id ?? fn?.__?.id;
+ if (!id) {
+ throw new Error(
+ 'mockRemote: argument is not a remote function (no ID found). ' +
+ 'Make sure you are importing from a .remote.ts file with the svelteKitTest({ mode: "component" }) plugin active.'
+ );
+ }
+
+ const config = getOrCreateMock(id);
+
+ const builder = {
+ /**
+ * Mock the function to return this data when called (or set form result)
+ * @param {any} data
+ * @param {{ delay?: number }} [options]
+ */
+ returns(data, options) {
+ config.data = data;
+ if (options?.delay) config.delay = options.delay;
+ return builder;
+ },
+ /**
+ * Mock the function to throw an HttpError with this status and body
+ * @param {number} status
+ * @param {any} body
+ * @param {{ delay?: number }} [options]
+ */
+ throws(status, body, options) {
+ config.error = { status, body };
+ if (options?.delay) config.delay = options.delay;
+ return builder;
+ },
+ /**
+ * Mock the function to call this resolver with the argument
+ * @param {(arg: any) => any} fn
+ * @param {{ delay?: number }} [options]
+ */
+ resolves(fn, options) {
+ config.resolver = fn;
+ if (options?.delay) config.delay = options.delay;
+ return builder;
+ },
+ /**
+ * Set form field values (for pre-populated forms or edit scenarios)
+ * @param {Record} values
+ */
+ withFieldValues(values) {
+ config.fieldValues = values;
+ return builder;
+ },
+ /**
+ * Set form field validation issues
+ * @param {Record }>>} issues
+ */
+ withFieldIssues(issues) {
+ config.fieldIssues = issues;
+ return builder;
+ }
+ };
+
+ return builder;
+}
diff --git a/packages/kit/src/exports/test/setup-component.js b/packages/kit/src/exports/test/setup-component.js
new file mode 100644
index 000000000000..598bfd0c15c5
--- /dev/null
+++ b/packages/kit/src/exports/test/setup-component.js
@@ -0,0 +1,14 @@
+/**
+ * Component test setup file, injected by svelteKitTest({ mode: 'component' }).
+ * Resets the mock registry between tests to ensure isolation.
+ */
+import { beforeEach, afterEach } from 'vitest';
+import { resetMocks } from './mock-registry.js';
+
+beforeEach(() => {
+ resetMocks();
+});
+
+afterEach(() => {
+ resetMocks();
+});
diff --git a/packages/kit/src/exports/test/setup.js b/packages/kit/src/exports/test/setup.js
new file mode 100644
index 000000000000..9c1ce2949b9b
--- /dev/null
+++ b/packages/kit/src/exports/test/setup.js
@@ -0,0 +1,25 @@
+/**
+ * Auto-context setup file, injected by the svelteKitTest() Vitest plugin.
+ *
+ * Establishes a default request store before each test using als.enterWith(),
+ * so remote functions can be called directly without withRequestContext wrappers.
+ * The ALS context survives nested with_request_store calls (used internally by
+ * run_remote_function) because AsyncLocalStorage maintains a context stack.
+ */
+import { beforeEach, afterEach } from 'vitest';
+import {
+ __test_set_request_store,
+ __test_clear_request_store
+} from '@sveltejs/kit/internal/server';
+import { createTestEvent, createTestState } from '@sveltejs/kit/test';
+
+beforeEach(() => {
+ __test_set_request_store({
+ event: createTestEvent(),
+ state: createTestState()
+ });
+});
+
+afterEach(() => {
+ __test_clear_request_store();
+});
diff --git a/packages/kit/src/exports/test/stubs/environment.js b/packages/kit/src/exports/test/stubs/environment.js
new file mode 100644
index 000000000000..e38d4e06c625
--- /dev/null
+++ b/packages/kit/src/exports/test/stubs/environment.js
@@ -0,0 +1,7 @@
+// Stub for the __sveltekit/environment virtual module.
+// Remote functions only read `prerendering` (in query.js); the rest are unused in tests.
+export const prerendering = false;
+export const building = false;
+export const version = 'test';
+export function set_building() {}
+export function set_prerendering() {}
diff --git a/packages/kit/src/exports/test/stubs/paths.js b/packages/kit/src/exports/test/stubs/paths.js
new file mode 100644
index 000000000000..6dfd481b78b4
--- /dev/null
+++ b/packages/kit/src/exports/test/stubs/paths.js
@@ -0,0 +1,5 @@
+// Stub for the __sveltekit/paths virtual module (also aliased from $app/paths).
+// Remote functions only read `base` and `app_dir`; defaults are sufficient for tests.
+export const base = '';
+export const assets = '';
+export const app_dir = '_app';
diff --git a/packages/kit/src/exports/test/stubs/remote.svelte.js b/packages/kit/src/exports/test/stubs/remote.svelte.js
new file mode 100644
index 000000000000..c0125a3f9ade
--- /dev/null
+++ b/packages/kit/src/exports/test/stubs/remote.svelte.js
@@ -0,0 +1,378 @@
+/**
+ * Mock version of the internal remote module for component testing.
+ *
+ * Provides query(), command(), form(), prerender() factories that create
+ * simplified reactive objects backed by the mock registry. These objects
+ * implement the same interface components expect (.current, .loading,
+ * .ready, .error, Promise protocol) but resolve from mockRemote() data
+ * instead of making HTTP requests.
+ */
+import { tick } from 'svelte';
+import { getMock, getOrCreateMock } from '../mock-registry.js';
+import { HttpError } from '@sveltejs/kit/internal';
+
+/**
+ * @param {string} id
+ */
+export function query(id) {
+ /** @param {any} [arg] */
+ function factory(arg) {
+ return new MockQueryProxy(id, arg);
+ }
+ factory.__mock_id = id;
+ return factory;
+}
+
+// query.batch uses the same interface as query for component consumers
+query.batch = query;
+
+/**
+ * @param {string} id
+ */
+export function command(id) {
+ let pending_count = $state(0);
+
+ /** @param {any} [arg] */
+ async function cmd(arg) {
+ pending_count++;
+ try {
+ await tick();
+ const mock = getMock(id);
+ if (!mock) {
+ throw new Error(
+ `No mock registered for command "${id}". Call mockRemote(fn).returns(data).`
+ );
+ }
+ if (mock.delay) await new Promise((r) => setTimeout(r, mock.delay));
+ if (mock.error) throw new HttpError(mock.error.status, mock.error.body);
+ if (mock.resolver) return await mock.resolver(arg);
+ return mock.data;
+ } finally {
+ pending_count--;
+ }
+ }
+
+ Object.defineProperty(cmd, 'pending', { get: () => pending_count });
+ cmd.__mock_id = id;
+
+ // stub .updates() — in production this refreshes queries after a command
+ const original = cmd;
+ /** @param {any} [arg] */
+ function cmd_with_updates(arg) {
+ const promise = original(arg);
+ /** @type {any} */ (promise).updates = (/** @type {any[]} */ ..._queries) => promise;
+ return promise;
+ }
+ Object.defineProperty(cmd_with_updates, 'pending', { get: () => pending_count });
+ cmd_with_updates.__mock_id = id;
+ return cmd_with_updates;
+}
+
+/**
+ * Creates a recursive proxy for form field access.
+ * Supports deep nesting: form.fields.nested.deep.field.as('text')
+ *
+ * @param {string} id — the mock registry ID
+ * @param {string[]} path — accumulated property path
+ */
+function create_field_proxy(id, path = []) {
+ const path_key = path.join('.');
+
+ return new Proxy(/** @type {any} */ ({}), {
+ get(_target, prop) {
+ // form.fields.value() — returns all field values
+ if (prop === 'value' && path.length === 0) {
+ return () => getMock(id)?.fieldValues ?? {};
+ }
+
+ // form.fields.allIssues() — returns all issues
+ if (prop === 'allIssues' && path.length === 0) {
+ return () => {
+ const issues = getMock(id)?.fieldIssues;
+ if (!issues) return undefined;
+ return Object.entries(issues).flatMap(([field, field_issues]) =>
+ field_issues.map((issue) => ({
+ ...issue,
+ path: issue.path ?? [{ key: field }]
+ }))
+ );
+ };
+ }
+
+ // form.fields.set({...}) — set all field values
+ if (prop === 'set' && path.length === 0) {
+ return (/** @type {Record} */ values) => {
+ const config = getOrCreateMock(id);
+ config.fieldValues = { ...(config.fieldValues ?? {}), ...values };
+ };
+ }
+
+ // field.as(type) — returns input props matching the real RemoteFormField.as() shape.
+ // The real implementation conditionally includes `type` (omitted for 'text'/'select'),
+ // and uses the type to determine name prefixes and value handling.
+ if (prop === 'as') {
+ return (/** @type {string} */ type, /** @type {any} */ value) => {
+ const field_values = getMock(id)?.fieldValues ?? {};
+ const field_issues = getMock(id)?.fieldIssues;
+ const current_value = get_nested(field_values, path);
+
+ /** @type {Record} */
+ const props = {
+ name: path_key,
+ get 'aria-invalid'() {
+ return field_issues?.[path_key] ? 'true' : undefined;
+ }
+ };
+
+ // Real implementation only adds type for non-text, non-select inputs
+ if (type !== 'text' && type !== 'select' && type !== 'select multiple') {
+ props.type = type === 'file multiple' ? 'file' : type;
+ }
+
+ Object.defineProperty(props, 'value', {
+ get: () => value ?? current_value,
+ set: () => {},
+ enumerable: true
+ });
+
+ return props;
+ };
+ }
+
+ // field.value() — returns current field value
+ if (prop === 'value') {
+ return () => {
+ const field_values = getMock(id)?.fieldValues ?? {};
+ return get_nested(field_values, path);
+ };
+ }
+
+ // field.set(value) — updates field value
+ if (prop === 'set') {
+ return (/** @type {any} */ value) => {
+ const config = getOrCreateMock(id);
+ config.fieldValues = config.fieldValues ?? {};
+ set_nested(config.fieldValues, path, value);
+ };
+ }
+
+ // field.issues() — returns validation issues for this field
+ if (prop === 'issues') {
+ return () => {
+ const issues = getMock(id)?.fieldIssues;
+ return issues?.[path_key];
+ };
+ }
+
+ // Otherwise, descend into nested field: form.fields.nested.deeper
+ return create_field_proxy(id, [...path, String(prop)]);
+ }
+ });
+}
+
+/**
+ * @param {Record} obj
+ * @param {string[]} path
+ * @returns {any}
+ */
+function get_nested(obj, path) {
+ let current = obj;
+ for (const key of path) {
+ if (current == null) return undefined;
+ current = current[key];
+ }
+ return current;
+}
+
+/**
+ * @param {Record} obj
+ * @param {string[]} path
+ * @param {any} value
+ */
+function set_nested(obj, path, value) {
+ let current = obj;
+ for (let i = 0; i < path.length - 1; i++) {
+ current[path[i]] = current[path[i]] ?? {};
+ current = current[path[i]];
+ }
+ current[path[path.length - 1]] = value;
+}
+
+/**
+ * @param {string} id
+ */
+export function form(id) {
+ let pending_count = $state(0);
+
+ // Only method and action should be enumerable — these are what
+ // {...form} spreads onto the