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.

Bash
npm install --save-dev vitest @testing-library/svelte @testing-library/jest-dom @testing-library/user-event jsdom
Javascript
// 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:

HTML
<!-- 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:

Javascript
// 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

HTML
<!-- 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>
Javascript
// 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 of getByRole/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 await before a userEvent/fireEvent call 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 of undefined bugs, 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.