Testing Svelte Apps
Vitest and Testing Library for Svelte, with complete component test examples.
Vitest and Testing Library for Svelte
Vitest is the standard test runner for a Svelte (or SvelteKit) project, and pairs naturally with Vite's own build pipeline — no separate configuration layer needed to understand .svelte files, since the same Vite plugin used to build the app also lets Vitest compile them for tests. @testing-library/svelte provides the actual rendering and querying API, following the same philosophy as every other Testing Library variant covered in this app's other framework tracks: query a rendered component the way a real user would — by visible text, label, or role — rather than reaching into its internal reactive variables directly.
npm install --save-dev vitest @testing-library/svelte @testing-library/jest-dom @testing-library/user-event jsdom
// vitest.config.js
import { defineConfig } from "vitest/config";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte({ hot: false })],
test: {
environment: "jsdom",
globals: true,
},
});
A complete example: testing a counter component
The component under test:
<!-- Counter.svelte -->
<script>
let count = 0;
function increment() {
count += 1;
}
function reset() {
count = 0;
}
</script>
<p>Count: {count}</p>
<button on:click={increment}>Increment</button>
<button on:click={reset}>Reset</button>
Its test:
// Counter.test.js
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter.svelte";
describe("Counter", () => {
it("starts at zero", () => {
render(Counter);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
it("increments when the Increment button is clicked", async () => {
const user = userEvent.setup();
render(Counter);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
});
it("resets back to zero", async () => {
const user = userEvent.setup();
render(Counter);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
});
render(Counter) mounts the component into a jsdom-simulated DOM. screen.getByRole("button", { name: "Increment" }) finds the button by its accessible role and visible label — the same query style used across React, Vue, and Angular's Testing Library variants, deliberately consistent so a test written this way keeps passing through an internal refactor (say, moving count into a store) as long as the component's actual rendered behavior doesn't change. userEvent.setup() plus await user.click(...) fires a realistic sequence of browser events, the more accurate choice over the lower-level fireEvent.click(...) for simulating genuine user interaction.
Testing a component with props and dispatched events
<!-- LikeButton.svelte -->
<script>
import { createEventDispatcher } from "svelte";
export let initiallyLiked = false;
const dispatch = createEventDispatcher();
function handleClick() {
dispatch("liked", { timestamp: Date.now() });
}
</script>
<button on:click={handleClick}>
{initiallyLiked ? "Liked" : "Like"}
</button>
// LikeButton.test.js
import { render, screen, fireEvent } from "@testing-library/svelte";
import LikeButton from "./LikeButton.svelte";
describe("LikeButton", () => {
it("renders the initial label based on the initiallyLiked prop", () => {
render(LikeButton, { initiallyLiked: true });
expect(screen.getByRole("button", { name: "Liked" })).toBeInTheDocument();
});
it("dispatches a liked event with a timestamp when clicked", async () => {
const { component } = render(LikeButton);
const handler = vi.fn();
component.$on("liked", handler);
await fireEvent.click(screen.getByRole("button", { name: "Like" }));
expect(handler).toHaveBeenCalledOnce();
expect(handler.mock.calls[0][0].detail).toHaveProperty("timestamp");
});
});
render(LikeButton, { initiallyLiked: true }) passes props into the component under test, as the second argument. Listening for a dispatched custom event needs the component instance render returns — component.$on("liked", handler) subscribes a mock function the same way a parent component's on:liked={handler} would in real markup, and the event's payload arrives as handler's argument's .detail property, exactly matching how a dispatched Svelte event is consumed anywhere else.
Common mistakes
- Querying with
container.querySelector("button.primary")instead ofgetByRole/getByText— it works, but couples the test to CSS class names and DOM structure instead of what a real user would actually perceive on the page. - Forgetting
awaitbefore auserEvent/fireEventcall or an assertion that depends on Svelte's reactivity having already updated the DOM — like Vue, Svelte's DOM updates aren't necessarily synchronous with the triggering event, so an unawaited assertion can run before the update renders. - Expecting a dispatched custom event's payload to arrive directly as the handler's argument, rather than as
argument.detail— a common source ofundefinedbugs, mirroring the same gotcha covered on the props-and-events page for real (non-test) code. - Reaching for a full rendered-component test when a plain function extracted from the component's logic could be unit-tested directly, with no DOM or rendering involved at all — simpler and faster when the logic genuinely doesn't depend on markup.