├── .eslintignore ├── .eslintrc.cjs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── setupTest.ts ├── src ├── app.css ├── app.d.ts ├── app.html ├── lib │ ├── actions │ │ ├── clickOutside.test.ts │ │ └── clickOutside.ts │ ├── bindings │ │ ├── Keypad.svelte │ │ └── Keypad.test.ts │ ├── contexts │ │ ├── ContextComponent.svelte │ │ └── ContextComponent.test.ts │ ├── data-fetching │ │ ├── ExternalFetch.svelte │ │ ├── ExternalFetch.test.ts │ │ ├── InternalFetch.svelte │ │ └── InternalFetch.test.ts │ ├── events │ │ ├── ComponentEvent.svelte │ │ └── ComponentEvent.test.ts │ ├── header │ │ ├── Header.svelte │ │ ├── Header.test.ts │ │ └── svelte-logo.svg │ ├── named-slots │ │ ├── ContactCard.svelte │ │ └── ContactCard.test.ts │ ├── optional-slots │ │ ├── Comment.svelte │ │ ├── Project.svelte │ │ └── Project.test.ts │ ├── props │ │ ├── DefaultProps.svelte │ │ └── DefaultProps.test.ts │ ├── slot-fallbacks │ │ ├── Box.svelte │ │ └── Box.test.ts │ └── slot-props │ │ ├── Hoverable.svelte │ │ └── Hoverable.test.ts ├── mocks │ ├── handlers.ts │ ├── server.ts │ └── setup.ts └── routes │ ├── +layout.svelte │ ├── +page.svelte │ ├── actions │ └── +page.svelte │ ├── bindings │ └── +page.svelte │ ├── contexts │ └── +page.svelte │ ├── data-fetching │ └── +page.svelte │ ├── events │ └── +page.svelte │ ├── props │ └── +page.svelte │ ├── random-number │ └── +server.ts │ └── slots │ ├── named-slots │ └── +page.svelte │ ├── optional-slots │ └── +page.svelte │ ├── slot-fallbacks │ └── +page.svelte │ └── slot-props │ └── +page.svelte ├── static ├── demo-props-test.gif ├── favicon.png └── runtime-module-error.jpg ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node: [18] 15 | name: Node ${{ matrix.node }} sample 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Setup node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node }} 22 | 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | shell: bash 33 | run: | 34 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 35 | 36 | - uses: actions/cache@v4 37 | name: Setup pnpm cache 38 | with: 39 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm-store- 43 | 44 | - name: Install dependencies 45 | run: pnpm install 46 | - run: npx vitest run 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .DS_Store 3 | node_modules 4 | /build 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | coverage 10 | *.md 11 | 12 | # Ignore files for PNPM, NPM and YARN 13 | pnpm-lock.yaml 14 | package-lock.json 15 | yarn.lock 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "pluginSearchDirs": ["."], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Component Test Recipes 2 | 3 | > **Note** This repo has been upgraded to SvelteKit 1.0 🎉 4 | 5 | Svelte component test recipes using Vitest & Testing Library with TypeScript 6 | 7 | In this repo, we'll use `vitest`, `@testing-library/svelte`, and `svelte-htm` to test Svelte components that seemed to be hard to test. Such as **two-way bindings**, **name slots**, **Context API**, ...etc. 8 | 9 | As a Svelte advocate, the fantastic DX is one of the reasons I love Svelte. While component testing is something that I think we can still improve over time. 10 | 11 | Feel free to open an issue or send a PR to add more test recipes. 😉 12 | 13 | Blog post @hashnode: https://davipon.hashnode.dev/svelte-component-test-recipes 14 | 15 | --- 16 | 17 | ## Table of Contents 18 | 19 | - [Svelte Component Test Recipes](#svelte-component-test-recipes) 20 | - [Table of Contents](#table-of-contents) 21 | - [Setup](#setup) 22 | - [`vite.config.ts`](#viteconfigts) 23 | - [`setupTest.ts`](#setuptestts) 24 | - [Testing component props](#testing-component-props) 25 | - [Get your component props type](#get-your-component-props-type) 26 | - [Testing component events](#testing-component-events) 27 | - [Pure `vitest` events testing](#pure-vitest-events-testing) 28 | - [Testing the `bind:` directive (two-way binding)](#testing-the-bind-directive-two-way-binding) 29 | - [Testing the `use:` directive (Svelte Actions)](#testing-the-use-directive-svelte-actions) 30 | - [Testing slots](#testing-slots) 31 | - [Slot fallbacks](#slot-fallbacks) 32 | - [Named slots](#named-slots) 33 | - [Optional slots ($$slot)](#optional-slots-slot) 34 | - [Slot props](#slot-props) 35 | - [Testing the Context API](#testing-the-context-api) 36 | - [Testing components that use SvelteKit runtime modules (`$app/*`)](#testing-components-that-use-sveltekit-runtime-modules-app) 37 | - [Testing data fetching components using `msw`](#testing-data-fetching-components-using-msw) 38 | - [Setup `msw`](#setup-msw) 39 | - [Mock REST API](#mock-rest-api) 40 | - [Credits](#credits) 41 | - [Resources](#resources) 42 | 43 | ## Setup 44 | 45 | Let's install libraries in your SvelteKit project. 46 | 47 | ```bash 48 | # Minimal setup 49 | npm install -D vitest @vitest/coverage-c8 @testing-library/svelte jsdom 50 | # Companion libraries for Testing Library 51 | npm install -D @testing-library/jest-dom @testing-library/dom @testing-library/user-event @types/testing-library__jest-dom 52 | # Test harness libraries 53 | npm install -D svelte-htm svelte-fragment-component 54 | 55 | ``` 56 | 57 | ### `vite.config.ts` 58 | 59 | `Vitest` can read your root `vite.config.(js|ts)` to match with the plugins and setup as your Vite app (SvelteKit), and here is my setup: 60 | 61 | ```ts 62 | // vite.config.ts 63 | import { sveltekit } from '@sveltejs/kit/vite'; 64 | import type { UserConfig } from 'vite'; 65 | import { configDefaults, type UserConfig as VitestConfig } from 'vitest/config'; 66 | 67 | const config: UserConfig & { test: VitestConfig['test'] } = { 68 | plugins: [sveltekit()], 69 | define: { 70 | // Eliminate in-source test code 71 | 'import.meta.vitest': 'undefined' 72 | }, 73 | test: { 74 | // jest like globals 75 | globals: true, 76 | environment: 'jsdom', 77 | // in-source testing 78 | includeSource: ['src/**/*.{js,ts,svelte}'], 79 | // Add @testing-library/jest-dom matchers & mocks of SvelteKit modules 80 | setupFiles: ['./setupTest.ts'], 81 | // Exclude files in c8 82 | coverage: { 83 | exclude: ['setupTest.ts'] 84 | }, 85 | // Exclude playwright tests folder 86 | exclude: [...configDefaults.exclude, 'tests'] 87 | } 88 | }; 89 | 90 | export default config; 91 | ``` 92 | 93 | ### `setupTest.ts` 94 | 95 | You may notice that there is a `setupTest.ts` file. We can add `@testing-library/jest-dom` matchers & mocks of SvelteKit there: 96 | 97 | ```ts 98 | // setupTest.ts 99 | /* eslint-disable @typescript-eslint/no-empty-function */ 100 | import matchers from '@testing-library/jest-dom/matchers'; 101 | import { expect, vi } from 'vitest'; 102 | import type { Navigation, Page } from '@sveltejs/kit'; 103 | import { readable } from 'svelte/store'; 104 | import * as environment from '$app/environment'; 105 | import * as navigation from '$app/navigation'; 106 | import * as stores from '$app/stores'; 107 | 108 | // Add custom jest matchers 109 | expect.extend(matchers); 110 | 111 | // Mock SvelteKit runtime module $app/environment 112 | vi.mock('$app/environment', (): typeof environment => ({ 113 | browser: false, 114 | dev: true, 115 | building: false, 116 | version: 'any', 117 | })); 118 | 119 | // Mock SvelteKit runtime module $app/navigation 120 | vi.mock('$app/navigation', (): typeof navigation => ({ 121 | afterNavigate: () => {}, 122 | beforeNavigate: () => {}, 123 | disableScrollHandling: () => {}, 124 | goto: () => Promise.resolve(), 125 | invalidate: () => Promise.resolve(), 126 | invalidateAll: () => Promise.resolve(), 127 | preloadData: () => Promise.resolve(), 128 | preloadCode: () => Promise.resolve(), 129 | })); 130 | 131 | // Mock SvelteKit runtime module $app/stores 132 | vi.mock('$app/stores', (): typeof stores => { 133 | const getStores: typeof stores.getStores = () => { 134 | const navigating = readable(null); 135 | const page = readable({ 136 | url: new URL('http://localhost'), 137 | params: {}, 138 | route: { 139 | id: null 140 | }, 141 | status: 200, 142 | error: null, 143 | data: {}, 144 | form: undefined 145 | }); 146 | const updated = { subscribe: readable(false).subscribe, check: async () => false }; 147 | 148 | return { navigating, page, updated }; 149 | }; 150 | 151 | const page: typeof stores.page = { 152 | subscribe(fn) { 153 | return getStores().page.subscribe(fn); 154 | } 155 | }; 156 | const navigating: typeof stores.navigating = { 157 | subscribe(fn) { 158 | return getStores().navigating.subscribe(fn); 159 | } 160 | }; 161 | const updated: typeof stores.updated = { 162 | subscribe(fn) { 163 | return getStores().updated.subscribe(fn); 164 | }, 165 | check: async () => false 166 | }; 167 | 168 | return { 169 | getStores, 170 | navigating, 171 | page, 172 | updated 173 | }; 174 | }); 175 | 176 | ``` 177 | 178 | > The `@testing-library/jest-dom` library provides a set of custom jest matchers that you can use to extend vitest. These will make your tests more declarative and clear to read and maintain. You can also check [Common mistakes with React Testing Library #Using the wrong-assertion](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-the-wrong-assertion). 179 | 180 | > SvelteKit runtime modules like `$app/stores` and `$app/navigation` are not set until SvelteKit's start function is called, which means you won't have them in a test environment because tests are isolated. 181 | > 182 | > This error occurs if we test the component which uses `$app` modules without mocking them: 183 | > ![runtime-module-error](./static/runtime-module-error.jpg) 184 | > 185 | >In the context of unit testing, any small gaps in functionality can be resolved by simply mocking that module. 186 | 187 | OK! The setup is ready. Let's start with a simple component test. 188 | 189 | ## Testing component props 190 | 191 | Here's our svelte component: 192 | 193 | `$lib/props/DefaultProps.svelte` 194 | 195 | ```svelte 196 | 199 | 200 |

The answer is {answer}

201 | ``` 202 | 203 | It's a simple component with only one prop `answer` with a default value. 204 | 205 | Let's see how to test the default value and pass a new one: 206 | 207 | ```ts 208 | // $lib/props/DefaultProps.test.ts 209 | import { render, screen } from '@testing-library/svelte'; 210 | import DefaultProps from './DefaultProps.svelte'; 211 | 212 | it("doesn't pass prop", () => { 213 | render(DefaultProps); 214 | expect(screen.queryByText('The answer is a mystery')).toBeInTheDocument(); 215 | }); 216 | 217 | it('set and update prop', async () => { 218 | // Pass your prop to the render function 219 | const { component } = render(DefaultProps, { answer: 'I dunno' }); 220 | 221 | expect(screen.queryByText('The answer is I dunno')).toBeInTheDocument(); 222 | 223 | // Update prop using Svelte's Client-side component API 224 | await component.$set({ answer: 'another mystery' }); 225 | expect(screen.queryByText('The answer is another mystery')).toBeInTheDocument(); 226 | }); 227 | ``` 228 | 229 | ### Get your component props type 230 | 231 | If you're using TypeScript, a recent release of `@testing-library/svelte` 232 | had improved props typing for `render` function: 233 | 234 | ![demo-props-test](./static/demo-props-test.gif) 235 | 236 | Sometimes you may want to predefine your props before passing. We can use Svelte's native utility type `ComponentProps`. `ComponentProps` takes in a Svelte component type and gives you a type corresponding to the component’s props. 237 | 238 | ```ts 239 | // $lib/props/DefaultProps.test.ts 240 | import type { ComponentProps } from 'svelte'; 241 | import { render, screen } from '@testing-library/svelte'; 242 | import DefaultProps from './DefaultProps.svelte'; 243 | 244 | it('Pass predefined prop to the component', () => { 245 | const prop: ComponentProps = { answer: 'TypeScript!' }; 246 | 247 | render(DefaultProps, prop); 248 | 249 | expect(screen.getByText('The answer is TypeScript!')); 250 | }); 251 | ``` 252 | 253 | > I highly recommend reading this excellent post from Andrew Lester: [Typing Components in Svelte](https://www.viget.com/articles/typing-components-in-svelte/). 254 | 255 | ## Testing component events 256 | 257 | The component we're going to test has a button that'll dispatch a custom event `message` when you click on it. It's the component from [svelte.dev/tutorials/component-events](https://svelte.dev/tutorial/component-events). 258 | 259 | `$lib/events/ComponentEvent.svelte` 260 | 261 | ```svelte 262 | 273 | 274 | 275 | ``` 276 | 277 | To test component events, we use a combination of vitest utility function [`vi.fn`](https://vitest.dev/api/#vi-fn) and the Svelte client-side component API [`component.$on`](https://svelte.dev/docs#run-time-client-side-component-api-$on). We also use [`@testing-library/user-event`](https://github.com/testing-library/user-event) instead of the built-in `fireEvent` to simulate user interactions. 278 | 279 | > `user-event` applies workarounds and mocks the UI layer to simulate user interactions like they would happen in the browser. Check [Common mistakes with React Testing Library #Not using @testing-library/user-event](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-using-testing-libraryuser-event). 280 | 281 | ```ts 282 | // $lib/events/ComponentEvent.test.ts 283 | import { render, screen } from '@testing-library/svelte'; 284 | import ComponentEvent from './ComponentEvent.svelte'; 285 | import userEvent from '@testing-library/user-event'; 286 | 287 | it('Test ComponentEvent component', async () => { 288 | const user = userEvent.setup(); 289 | 290 | const { component } = render(ComponentEvent); 291 | 292 | // Mock function 293 | let text = ''; 294 | const mock = vi.fn((event) => (text = event.detail.text)); 295 | component.$on('message', mock); 296 | 297 | const button = screen.getByRole('button'); 298 | await user.click(button); 299 | 300 | expect(mock).toHaveBeenCalled(); 301 | expect(text).toBe('Hello!'); 302 | }); 303 | ``` 304 | 305 | We first create a mock function and pass it to the `component.$on`, so we can monitor it whenever the component dispatch a `message` event. 306 | 307 | ### Pure `vitest` events testing 308 | 309 | You can also test component events without `@testing-library` using only `vitest`: 310 | 311 | ```ts 312 | import Component from '$lib/MyComponent.svelte' 313 | import { expect, test, vi } from 'vitest' 314 | 315 | test(`invokes callback functions`, async () => { 316 | const keyup = vi.fn() 317 | const click = vi.fn() 318 | 319 | const instance = new Component({ 320 | target: document.body, 321 | props: { foo: `bar` }, 322 | }) 323 | 324 | instance.$on(`keyup`, keyup) 325 | instance.$on(`click`, click) 326 | 327 | const node = document.querySelector(`.some-css-selector`) 328 | 329 | if (!node) throw new Error(`DOM node not found`) 330 | 331 | node.dispatchEvent(new KeyboardEvent(`keyup`, { key: `Enter` })) 332 | expect(keyup).toHaveBeenCalledTimes(1) 333 | 334 | node.dispatchEvent(new MouseEvent(`click`)) 335 | expect(click).toHaveBeenCalledTimes(1) 336 | }) 337 | ``` 338 | 339 | ## Testing the `bind:` directive (two-way binding) 340 | 341 | We use `Keypad.svelte` from [svelte.dev/tutorial/component-bindings](https://svelte.dev/tutorial/component-bindings): 342 | 343 | `$lib/bindings/Keypad.svelte` 344 | 345 | ```svelte 346 | 357 | 358 |
359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 |
373 | 374 | 386 | ``` 387 | 388 | There is no programmatic interface to test `bind:`, `use:`, slots, and Context API. Instead of creating a dummy svelte component (e.g. `TestHarness.svelte`) to test your component, we can use [`svelte-htm`](https://github.com/kenoxa/svelte-htm) to simplify your testing code. 389 | 390 | Assume we use `Keypad.svelte` in one of the `+page.svelte`: 391 | 392 | ```svelte 393 | 394 | ``` 395 | 396 | To test the two-way binding of `value`, we use a writeable store as property value: 397 | 398 | ```ts 399 | // $lib/bindings/Keypad.test.ts 400 | import { get, writable } from 'svelte/store'; 401 | import { render, screen } from '@testing-library/svelte'; 402 | import html from 'svelte-htm'; 403 | import Keypad from './Keypad.svelte'; 404 | import userEvent from '@testing-library/user-event'; 405 | 406 | describe('Test Keypad component', async () => { 407 | const user = userEvent.setup(); 408 | 409 | it('Test two-way binding', async () => { 410 | // Create a writable store to enable reactivity 411 | const pin = writable(''); 412 | const mock = vi.fn(); 413 | 414 | render(html`<${Keypad} bind:value=${pin} on:submit=${mock} />`); 415 | 416 | // Get Keypad button "1" 417 | const button1 = screen.getByText('1'); 418 | await user.click(button1); 419 | await user.click(button1); 420 | expect(get(pin)).toBe('11'); 421 | 422 | const submitButton = screen.getByText('submit'); 423 | await user.click(submitButton); 424 | expect(mock).toHaveBeenCalled(); 425 | 426 | const clearButton = screen.getByText('clear'); 427 | await user.click(clearButton); 428 | expect(get(pin)).toBe(''); 429 | }); 430 | }); 431 | 432 | ``` 433 | 434 | ## Testing the `use:` directive (Svelte Actions) 435 | 436 | We use the example from [svelte.dev/tutorial/actions](https://svelte.dev/tutorial/actions): 437 | 438 | ```ts 439 | // $lib/actions/clickOutside.ts 440 | export function clickOutside(node: HTMLElement) { 441 | const handleClick = (event: Event) => { 442 | if (!node.contains(event.target as Node)) { 443 | node.dispatchEvent(new CustomEvent('outclick')); 444 | } 445 | }; 446 | 447 | document.addEventListener('click', handleClick, true); 448 | 449 | return { 450 | destroy() { 451 | document.removeEventListener('click', handleClick, true); 452 | } 453 | }; 454 | } 455 | ``` 456 | 457 | Then use it in `src/routes/actions/+page.svelte`: 458 | 459 | ```svelte 460 | 465 | 466 | 467 | {#if showModal} 468 |
(showModal = false)}>Click outside me!
469 | {/if} 470 | ``` 471 | 472 | To test svelte actions, we test the function but not the component which uses the function. Be careful that we need to use `use:action=${/** yourActionFunction *}` in `svelte-htm`'s tagged templates: 473 | 474 | ```ts 475 | import { render, screen } from '@testing-library/svelte'; 476 | import html from 'svelte-htm'; 477 | import userEvent from '@testing-library/user-event'; 478 | import { clickOutside } from './clickOutside'; 479 | 480 | it('Test clickOutside svelte action', async () => { 481 | const user = userEvent.setup(); 482 | const mock = vi.fn(); 483 | 484 | render(html` 485 | 486 | 492 | `); 493 | 494 | const button = screen.getByText('Outside the button'); 495 | await user.click(button); 496 | expect(mock).toHaveBeenCalled(); 497 | }); 498 | ``` 499 | 500 | ## Testing slots 501 | 502 | We have four examples from [svelte.dev/slots](https://svelte.dev/tutorial/slot-fallbacks): 503 | 504 | - Slot fallbacks 505 | - Named slots 506 | - Optional slots 507 | - Slot props 508 | 509 | ### Slot fallbacks 510 | 511 | `$lib/slot-fallbacks/Box.svelte` 512 | 513 | ```svelte 514 |
515 | 516 | no content was provided 517 | 518 |
519 | ``` 520 | 521 | It's easy to test using `svelte-htm`: 522 | 523 | ```ts 524 | // $lib/slot-fallbacks/Box.test.ts 525 | import { render, screen } from '@testing-library/svelte'; 526 | import html from 'svelte-htm'; 527 | import Box from './Box.svelte'; 528 | 529 | describe('Test slot fallbacks', () => { 530 | it('Put some elements', () => { 531 | render(html`<${Box}>

Hello!

`); 532 | expect(screen.getByText('Hello!')).toBeInTheDocument(); 533 | }); 534 | 535 | it('Test slot fallback', () => { 536 | render(html`<${Box} />`); 537 | expect(screen.getByText('no content was provided')).toBeInTheDocument(); 538 | }); 539 | }); 540 | ``` 541 | 542 | ### Named slots 543 | 544 | `$lib/named-slots/ContactCard.svelte` 545 | 546 | ```svelte 547 |
548 |

549 | 550 | Unknown name 551 | 552 |

553 | 554 |
555 | 556 | Unknown address 557 | 558 |
559 | 560 | 565 |
566 | ``` 567 | 568 | ```ts 569 | // $lib/named-slots/ContactCard.test.ts 570 | import { render, screen } from '@testing-library/svelte'; 571 | import html from 'svelte-htm'; 572 | import ContactCard from './ContactCard.svelte'; 573 | 574 | describe('Test name slots', () => { 575 | it('Only put slot "name"', () => { 576 | render(html` 577 | <${ContactCard}> 578 | P. Sherman 579 | 580 | `); 581 | 582 | // Fallbacks 583 | expect(screen.getByText('Unknown address')).toBeInTheDocument(); 584 | expect(screen.getByText('Unknown email')).toBeInTheDocument(); 585 | }); 586 | }); 587 | ``` 588 | 589 | ### Optional slots ($$slot) 590 | 591 | > Check the implementation here: [svelte.dev/tutorial/optional-slots](https://svelte.dev/tutorial/optional-slots) or `src/lib/optional-slots/*` 592 | 593 | There are two components, and both accept slots: 594 | 595 | - `Comment.svelte`: Accepts any content in the slot 596 | - `Project.svelte`: Check if named slot `comments` exists 597 | 598 | `$lib/optional-slots/Project.svelte` 599 | 600 | ```svelte 601 | 606 | 607 |
608 |
609 |

{title}

610 |

{tasksCompleted}/{totalTasks} tasks completed

611 |
612 | {#if $$slots.comments} 613 |
614 |

Comments

615 | 616 |
617 | {/if} 618 |
619 | ``` 620 | 621 | We can test the optional slot by checking the class name `has-discussion`: 622 | 623 | ```ts 624 | import { render, screen } from '@testing-library/svelte'; 625 | import html from 'svelte-htm'; 626 | import Project from './Project.svelte'; 627 | import Comment from './Comment.svelte'; 628 | 629 | describe('Test optional slots', () => { 630 | it('Put Comment component in Project', () => { 631 | render(html` 632 | <${Project} title="Add TypeScript support" tasksCompleted="{25}" totalTasks="{57}"> 633 |
634 | <${Comment} name="Ecma Script" postedAt=${new Date('2020-08-17T14:12:23')}> 635 |

Those interface tests are now passing.

636 | 637 |
638 | 639 | `); 640 | 641 | const article = screen.getAllByRole('article')[0]; 642 | 643 | expect(article).toHaveClass('has-discussion'); 644 | }); 645 | 646 | it('No slot in Project component', () => { 647 | render(html` 648 | <${Project} title="Update documentation" tasksCompleted="{18}" totalTasks="{21}" /> 649 | `); 650 | 651 | const article = screen.getAllByRole('article')[0]; 652 | 653 | expect(article).not.toHaveClass('has-discussion'); 654 | expect(screen.queryByText('Comments')).not.toBeInTheDocument(); 655 | }); 656 | }); 657 | 658 | ``` 659 | 660 | ### Slot props 661 | 662 | `$lib/slot-props/Hoverable.svelte` 663 | 664 | ```svelte 665 | 676 | 677 |
678 | 679 |
680 | ``` 681 | 682 | To test the slot prop, we can create a writable store and subscribe to the `let:hovering`, and use `user.hover` to simulate hovering, which is not viable using the native `fireEvent`: 683 | 684 | ```ts 685 | import { get, writable } from 'svelte/store'; 686 | import { render, screen } from '@testing-library/svelte'; 687 | import html from 'svelte-htm'; 688 | import Hoverable from './Hoverable.svelte'; 689 | import userEvent from '@testing-library/user-event'; 690 | 691 | it('Test slot props', async () => { 692 | const user = userEvent.setup(); 693 | const hovering = writable(false); 694 | 695 | render(html` 696 | <${Hoverable} let:hovering=${hovering}> 697 |
698 |

Hover over me!

699 |
700 | 701 | `); 702 | 703 | const element = screen.getByText('Hover over me!'); 704 | await user.hover(element); 705 | 706 | expect(get(hovering)).toBeTruthy(); 707 | expect(screen.getByTestId('hover')).toHaveClass('active'); 708 | }); 709 | ``` 710 | 711 | ## Testing the Context API 712 | 713 | Here is the component that uses `getContext`: 714 | 715 | `$lib/contexts/ContextComponent.svelte` 716 | 717 | ```svelte 718 | 723 | 724 |
725 |
726 | User Name: 727 | {userDetails.username} 728 |
729 | 730 |
731 | User Login Status: 732 | {userDetails.islogin} 733 |
734 |
735 | ``` 736 | 737 | We can create a parent component that does `setContext` to test the Context API. We use [`svelte-fragment-component`](https://github.com/kenoxa/svelte-fragment-component#readme), which provides some useful component lifecycle properties and a context property. In combination with `svelte-htm` the example could be written as: 738 | 739 | ```ts 740 | // $lib/contexts/ContextComponent.test.ts 741 | import { render, screen } from '@testing-library/svelte'; 742 | import html from 'svelte-htm'; 743 | import Fragment from 'svelte-fragment-component'; 744 | import ContextComponent from './ContextComponent.svelte'; 745 | 746 | it('Test Context API', () => { 747 | const userDetails = { username: 'abc@example.com', islogin: 'yes' }; 748 | 749 | render(html` 750 | <${Fragment} context=${{ 'user-details': userDetails }}> 751 | <${ContextComponent}/> 752 | 753 | `); 754 | 755 | expect(screen.getByText('abc@example.com')).toBeInTheDocument(); 756 | expect(screen.getByText('yes')).toBeInTheDocument(); 757 | }); 758 | 759 | ``` 760 | 761 | ## Testing components that use SvelteKit runtime modules (`$app/*`) 762 | 763 | Sometimes your component needs to interact with SvelteKit modules like `$app/stores` or `$app/navigation`: 764 | 765 | ```svelte 766 | 770 | 771 |
772 | 785 |
786 | ``` 787 | 788 | And a test might look like this: 789 | 790 | ```ts 791 | import { render, screen } from '@testing-library/svelte'; 792 | import Header from './Header.svelte'; 793 | 794 | it('Render About page', () => { 795 | render(Header); 796 | 797 | const home = screen.getByText('Home'); 798 | expect(home).toBeInTheDocument(); 799 | 800 | const about = screen.getByText('About'); 801 | expect(about).toBeInTheDocument(); 802 | }); 803 | ``` 804 | 805 | To pass our test, we need to mock the SvelteKit runtime modules. (Please check the [`setupTest.ts`](#setuptestts) section.) 806 | 807 | ## Testing data fetching components using `msw` 808 | 809 | Sometimes you may want to load some data when a component is mounted. 810 | We can use [`msw` - Mock Service Worker](https://github.com/mswjs/msw). It will let you mock REST and GraphQL network requests, no more stubbing of `fetch`, `axios`, ...etc. 811 | 812 | ### Setup `msw` 813 | 814 | First, install `msw`: 815 | `npm install -D msw` 816 | 817 | Create a folder `src/mocks` and three files in it: 818 | 819 | ```ts 820 | // src/mocks/handlers.ts 821 | import { rest } from 'msw' 822 | 823 | // Define handlers that catch the corresponding requests and return the mock data. 824 | // Will add handler later 825 | export const handlers = [] 826 | ``` 827 | 828 | ```ts 829 | // src/mocks/server.ts 830 | import { setupServer } from 'msw/node'; 831 | import { handlers } from './handlers'; 832 | // This configures a Service Worker with the given request handlers. 833 | export const server = setupServer(...handlers); 834 | ``` 835 | 836 | ```ts 837 | // src/mocks/setup.ts 838 | import { server } from './server'; 839 | 840 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 841 | afterAll(() => server.close()); 842 | afterEach(() => server.resetHandlers()); 843 | ``` 844 | 845 | We also need to update our `vite.config.ts`: 846 | 847 | ```diff 848 | - setupFiles: ['setupTest.ts'], 849 | + setupFiles: ['setupTest.ts', 'src/mocks/setup.ts'], 850 | coverage: { 851 | - exclude: ['setupTest.ts'] 852 | + exclude: ['setupTest.ts', 'src/mocks'] 853 | }, 854 | deps: { 855 | - inline: [] 856 | + inline: [/msw/] 857 | } 858 | ``` 859 | 860 | ### Mock REST API 861 | 862 | Here is a component that will fetch posts from an external endpoint: 863 | 864 | `$lib/data-fetching/ExternalFetch.svelte` 865 | 866 | ```svelte 867 | 887 | 888 | {#await getRandomPost()} 889 |

...waiting

890 | {:then posts} 891 | {#each posts as { id, userId, title, body }} 892 |

Post ID: {id}

893 |

User ID: {userId}

894 |

The title is {title}

895 |

{body}

896 | {/each} 897 | {:catch error} 898 |

{error.message}

899 | {/await} 900 | ``` 901 | 902 | > I use `axios` here because there is an issue of `msw`: [Unable to intercept requests on Node 18 #1388](https://github.com/mswjs/msw/issues/1388) 903 | 904 | To intercept the request during testing, we'll update `src/mocks/handlers.ts` like this: 905 | 906 | ```ts 907 | // src/mocks/handlers.ts 908 | import { rest } from 'msw'; 909 | 910 | // Mock Data 911 | const posts = [ 912 | { 913 | userId: 1, 914 | id: 1, 915 | title: 'first post title', 916 | body: 'first post body' 917 | }, 918 | { 919 | userId: 2, 920 | id: 5, 921 | title: 'second post title', 922 | body: 'second post body' 923 | }, 924 | { 925 | userId: 3, 926 | id: 6, 927 | title: 'third post title', 928 | body: 'third post body' 929 | } 930 | ]; 931 | 932 | // Define handlers that catch the corresponding requests and return the mock data. 933 | export const handlers = [ 934 | rest.get('https://jsonplaceholder.typicode.com/posts', (req, res, ctx) => { 935 | return res(ctx.status(200), ctx.json(posts)) 936 | }) 937 | ]; 938 | ``` 939 | 940 | Here is the test file: 941 | 942 | ```ts 943 | import { render, screen } from '@testing-library/svelte'; 944 | import ExternalFetch from './ExternalFetch.svelte'; 945 | 946 | it('render ExternalFetch', async () => { 947 | render(ExternalFetch); 948 | 949 | // Initial component state 950 | expect(screen.getByText('...waiting')).toBeInTheDocument(); 951 | // Use await screen.find* instead of the await tick() or act() 952 | expect(await screen.findByText('first post body')).toBeInTheDocument(); 953 | }); 954 | ``` 955 | 956 | > Please check [Common mistakes with React Testing Library #Using waitFor to wait for elements that can be queried with find*](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-waitfor-to-wait-for-elements-that-can-be-queried-with-find) for the reason of using `await screen.find*` 957 | 958 | ## Credits 959 | 960 | - [Unit Testing Svelte Components by Svelte Society](https://sveltesociety.dev/recipes/testing-and-debugging/unit-testing-svelte-component) 961 | - [[Archived] Testing and Debugging Svelte - svelte-society/recipes-mvp by swyx](https://github.com/svelte-society/recipes-mvp/blob/2c7587ad559b3ee22a0caf5e1528bbac34dd475d/testing.md#debugging-svelte-apps-in-vs-code) 962 | - [Official documents of Svelte Testing Library](https://testing-library.com/docs/svelte-testing-library/intro) 963 | - [svelte-htm (& core implementations) by @sastan](https://github.com/kenoxa/svelte-htm) 964 | - [Vitest for unit testing #5285 by @benmccann](https://github.com/sveltejs/kit/discussions/5285) 965 | 966 | ## Resources 967 | 968 | - [Common mistakes with React Testing Library by Kent C. Dodds](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-using-testing-libraryuser-event) 969 | - [Design Patterns for Building Reusable Svelte Components by Eric Liu](https://render.com/blog/svelte-design-patterns) 970 | - [Typing Components in Svelte by Andrew Lester](https://www.viget.com/articles/typing-components-in-svelte/) 971 | - [davipon/svelte-add-vitest - Svelte adder for Vitest](https://github.com/davipon/svelte-add-vitest) 972 | - [Test Svelte Component Using Vitest & Playwright](https://davipon.hashnode.dev/test-svelte-component-using-vitest-playwright) 973 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-component-test-recipes", 3 | "description": "Svelte component test recipes using Vitest & Testing Library", 4 | "author": "David Peng ", 5 | "version": "0.0.1", 6 | "private": true, 7 | "scripts": { 8 | "dev": "vite dev", 9 | "build": "vite build", 10 | "preview": "vite preview", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "lint": "prettier --check . && eslint .", 14 | "format": "prettier --write .", 15 | "test": "vitest", 16 | "coverage": "vitest run --coverage" 17 | }, 18 | "devDependencies": { 19 | "@sveltejs/adapter-auto": "2.1.1", 20 | "@sveltejs/kit": "1.30.4", 21 | "@testing-library/dom": "^9.0.0", 22 | "@testing-library/jest-dom": "^6.0.0", 23 | "@testing-library/svelte": "^4.0.0", 24 | "@testing-library/user-event": "^14.4.3", 25 | "@types/testing-library__jest-dom": "^5.14.5", 26 | "@typescript-eslint/eslint-plugin": "6.21.0", 27 | "@typescript-eslint/parser": "6.21.0", 28 | "@vitest/coverage-c8": "^0.33.0", 29 | "axios": "^1.2.1", 30 | "eslint": "^8.30.0", 31 | "eslint-config-prettier": "^9.0.0", 32 | "eslint-plugin-svelte3": "^4.0.0", 33 | "jsdom": "^22.0.0", 34 | "msw": "^1.0.0", 35 | "prettier": "^3.0.0", 36 | "prettier-plugin-svelte": "^3.0.0", 37 | "svelte": "^4.0.0", 38 | "svelte-check": "^3.0.0", 39 | "svelte-fragment-component": "^1.2.0", 40 | "svelte-htm": "^1.2.0", 41 | "svelte-preprocess": "^5.0.0", 42 | "tslib": "^2.4.1", 43 | "typescript": "^5.0.0", 44 | "vite": "^4.0.2", 45 | "vitest": "0.34.6" 46 | }, 47 | "type": "module" 48 | } 49 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", "schedule:earlyMondays"], 4 | "packageRules": [ 5 | { 6 | "matchPackagePatterns": ["*"], 7 | "matchUpdateTypes": ["minor", "patch"], 8 | "groupName": "all non-major dependencies", 9 | "groupSlug": "all-minor-patch", 10 | "automerge": true 11 | }, 12 | { 13 | "matchPackagePatterns": ["*"], 14 | "matchUpdateTypes": ["major"], 15 | "groupName": "all major dependencies", 16 | "groupSlug": "all-major-patch" 17 | } 18 | ], 19 | "platformAutomerge": true 20 | } 21 | -------------------------------------------------------------------------------- /setupTest.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import matchers from '@testing-library/jest-dom/matchers'; 3 | import { expect, vi } from 'vitest'; 4 | import type { Navigation, Page } from '@sveltejs/kit'; 5 | import { readable } from 'svelte/store'; 6 | import * as environment from '$app/environment'; 7 | import * as navigation from '$app/navigation'; 8 | import * as stores from '$app/stores'; 9 | 10 | expect.extend(matchers); 11 | 12 | // Mock SvelteKit runtime module $app/environment 13 | vi.mock('$app/environment', (): typeof environment => ({ 14 | browser: false, 15 | dev: true, 16 | building: false, 17 | version: 'any' 18 | })); 19 | 20 | // Mock SvelteKit runtime module $app/navigation 21 | vi.mock('$app/navigation', (): typeof navigation => ({ 22 | afterNavigate: () => {}, 23 | beforeNavigate: () => {}, 24 | disableScrollHandling: () => {}, 25 | goto: () => Promise.resolve(), 26 | invalidate: () => Promise.resolve(), 27 | invalidateAll: () => Promise.resolve(), 28 | preloadData: () => Promise.resolve(), 29 | preloadCode: () => Promise.resolve() 30 | })); 31 | 32 | // Mock SvelteKit runtime module $app/stores 33 | vi.mock('$app/stores', (): typeof stores => { 34 | const getStores: typeof stores.getStores = () => { 35 | const navigating = readable(null); 36 | const page = readable({ 37 | url: new URL('http://localhost'), 38 | params: {}, 39 | route: { 40 | id: null 41 | }, 42 | status: 200, 43 | error: null, 44 | data: {}, 45 | form: undefined 46 | }); 47 | const updated = { subscribe: readable(false).subscribe, check: async() => false }; 48 | 49 | return { navigating, page, updated }; 50 | }; 51 | 52 | const page: typeof stores.page = { 53 | subscribe(fn) { 54 | return getStores().page.subscribe(fn); 55 | } 56 | }; 57 | const navigating: typeof stores.navigating = { 58 | subscribe(fn) { 59 | return getStores().navigating.subscribe(fn); 60 | } 61 | }; 62 | const updated: typeof stores.updated = { 63 | subscribe(fn) { 64 | return getStores().updated.subscribe(fn); 65 | }, 66 | check: async () => false 67 | }; 68 | 69 | return { 70 | getStores, 71 | navigating, 72 | page, 73 | updated 74 | }; 75 | }); 76 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, 3 | Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 4 | --font-mono: 'Fira Mono', monospace; 5 | --pure-white: #ffffff; 6 | --primary-color: #b9c6d2; 7 | --secondary-color: #d0dde9; 8 | --tertiary-color: #edf0f8; 9 | --accent-color: #ff3e00; 10 | --heading-color: rgba(0, 0, 0, 0.7); 11 | --text-color: #444444; 12 | --background-without-opacity: rgba(255, 255, 255, 0.7); 13 | --column-width: 42rem; 14 | --column-margin-top: 4rem; 15 | } 16 | 17 | body { 18 | min-height: 100vh; 19 | margin: 0; 20 | background-color: var(--primary-color); 21 | background: linear-gradient( 22 | 180deg, 23 | var(--primary-color) 0%, 24 | var(--secondary-color) 10.45%, 25 | var(--tertiary-color) 41.35% 26 | ); 27 | } 28 | 29 | body::before { 30 | content: ''; 31 | width: 80vw; 32 | height: 100vh; 33 | position: absolute; 34 | top: 0; 35 | left: 10vw; 36 | z-index: -1; 37 | background: radial-gradient( 38 | 50% 50% at 50% 50%, 39 | var(--pure-white) 0%, 40 | rgba(255, 255, 255, 0) 100% 41 | ); 42 | opacity: 0.05; 43 | } 44 | 45 | #svelte { 46 | min-height: 100vh; 47 | display: flex; 48 | flex-direction: column; 49 | } 50 | 51 | h1, 52 | h2, 53 | p { 54 | font-weight: 400; 55 | color: var(--heading-color); 56 | } 57 | 58 | p { 59 | line-height: 1.5; 60 | } 61 | 62 | a { 63 | color: var(--accent-color); 64 | text-decoration: none; 65 | } 66 | 67 | a:hover { 68 | text-decoration: underline; 69 | } 70 | 71 | h1 { 72 | font-size: 2rem; 73 | text-align: center; 74 | } 75 | 76 | h2 { 77 | font-size: 1rem; 78 | } 79 | 80 | pre { 81 | font-size: 16px; 82 | font-family: var(--font-mono); 83 | background-color: rgba(255, 255, 255, 0.45); 84 | border-radius: 3px; 85 | box-shadow: 2px 2px 6px rgb(255 255 255 / 25%); 86 | padding: 0.5em; 87 | overflow-x: auto; 88 | color: var(--text-color); 89 | } 90 | 91 | input, 92 | button { 93 | font-size: inherit; 94 | font-family: inherit; 95 | } 96 | 97 | button:focus:not(:focus-visible) { 98 | outline: none; 99 | } 100 | 101 | @media (min-width: 720px) { 102 | h1 { 103 | font-size: 2.4rem; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | // See https://kit.svelte.dev/docs/types#app 3 | // for information about these interfaces 4 | // and what to do when importing types 5 | declare namespace App { 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | // interface PrivateEnv {} 10 | // interface PublicEnv {} 11 | } 12 | 13 | declare namespace svelte.JSX { 14 | interface HTMLAttributes { 15 | onoutclick?: (event: CustomEvent) => void; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/actions/clickOutside.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import html from 'svelte-htm'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { clickOutside } from './clickOutside'; 5 | 6 | it('Test clickOuside svelte action', async () => { 7 | const user = userEvent.setup(); 8 | const mock = vi.fn(); 9 | 10 | render(html` 11 | 12 | 18 | `); 19 | 20 | const button = screen.getByText('Outside the button'); 21 | await user.click(button); 22 | expect(mock).toHaveBeenCalled(); 23 | }); 24 | -------------------------------------------------------------------------------- /src/lib/actions/clickOutside.ts: -------------------------------------------------------------------------------- 1 | export function clickOutside(node: HTMLElement) { 2 | const handleClick = (event: Event) => { 3 | if (!node.contains(event.target as Node)) { 4 | node.dispatchEvent(new CustomEvent('outclick')); 5 | } 6 | }; 7 | 8 | document.addEventListener('click', handleClick, true); 9 | 10 | return { 11 | destroy() { 12 | document.removeEventListener('click', handleClick, true); 13 | } 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/bindings/Keypad.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 41 | -------------------------------------------------------------------------------- /src/lib/bindings/Keypad.test.ts: -------------------------------------------------------------------------------- 1 | import { get, writable } from 'svelte/store'; 2 | import { render, screen } from '@testing-library/svelte'; 3 | import html from 'svelte-htm'; 4 | import Keypad from './Keypad.svelte'; 5 | import userEvent from '@testing-library/user-event'; 6 | 7 | describe('Test Keypad component', async () => { 8 | const user = userEvent.setup(); 9 | 10 | it('Test two-way binding', async () => { 11 | const pin = writable(''); 12 | const mock = vi.fn(); 13 | 14 | render(html`<${Keypad} bind:value=${pin} on:submit=${mock} />`); 15 | 16 | const button1 = screen.getByText('1'); 17 | await user.click(button1); 18 | await user.click(button1); 19 | expect(get(pin)).toBe('11'); 20 | 21 | const submitButton = screen.getByText('submit'); 22 | await user.click(submitButton); 23 | expect(mock).toHaveBeenCalled(); 24 | 25 | const clearButton = screen.getByText('clear'); 26 | await user.click(clearButton); 27 | expect(get(pin)).toBe(''); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/lib/contexts/ContextComponent.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | User Name: 10 | {userDetails.username} 11 |
12 | 13 |
14 | User Login Status: 15 | {userDetails.islogin} 16 |
17 |
18 | -------------------------------------------------------------------------------- /src/lib/contexts/ContextComponent.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import html from 'svelte-htm'; 3 | import Fragment from 'svelte-fragment-component'; 4 | import ContextComponent from './ContextComponent.svelte'; 5 | 6 | it('Test Context API', () => { 7 | const userDetails = { username: 'abc@example.com', islogin: 'yes' }; 8 | 9 | render(html` 10 | <${Fragment} context=${{ 'user-details': userDetails }}> 11 | <${ContextComponent}/> 12 | 13 | `); 14 | 15 | expect(screen.getByText('abc@example.com')).toBeInTheDocument(); 16 | expect(screen.getByText('yes')).toBeInTheDocument(); 17 | }); 18 | -------------------------------------------------------------------------------- /src/lib/data-fetching/ExternalFetch.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#await getRandomPost()} 23 |

...waiting

24 | {:then posts} 25 | {#each posts as { id, userId, title, body }} 26 |

Post ID: {id}

27 |

User ID: {userId}

28 |

The title is {title}

29 |

{body}

30 | {/each} 31 | {:catch error} 32 |

{error.message}

33 | {/await} 34 | -------------------------------------------------------------------------------- /src/lib/data-fetching/ExternalFetch.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import ExternalFetch from './ExternalFetch.svelte'; 3 | 4 | it('render ExternalFetch', async () => { 5 | render(ExternalFetch); 6 | 7 | expect(screen.getByText('...waiting')).toBeInTheDocument(); 8 | expect(await screen.findByText('first post body')).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/data-fetching/InternalFetch.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | {#await promise} 24 |

...waiting

25 | {:then number} 26 |

The number is {number}

27 | {:catch error} 28 |

{error.message}

29 | {/await} 30 | -------------------------------------------------------------------------------- /src/lib/data-fetching/InternalFetch.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import InternalFetch from './InternalFetch.svelte'; 3 | 4 | it('render InternalFetch', async () => { 5 | render(InternalFetch); 6 | 7 | expect(screen.getByText('...waiting')).toBeInTheDocument(); 8 | expect(await screen.findByText('The number is 0.25')).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/events/ComponentEvent.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/events/ComponentEvent.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import ComponentEvent from './ComponentEvent.svelte'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | it('Test ComponentEvent component', async () => { 6 | const user = userEvent.setup(); 7 | 8 | const { component } = render(ComponentEvent); 9 | 10 | // Mock function 11 | let text = ''; 12 | const mock = vi.fn((event) => (text = event.detail.text)); 13 | component.$on('message', mock); 14 | 15 | const button = screen.getByRole('button'); 16 | await user.click(button); 17 | 18 | expect(mock).toHaveBeenCalled(); 19 | expect(text).toBe('Hello!'); 20 | }); 21 | -------------------------------------------------------------------------------- /src/lib/header/Header.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 | 9 | SvelteKit 10 | 11 |
12 | 13 | 56 | 57 |
58 | 59 |
60 |
61 | 62 | 151 | -------------------------------------------------------------------------------- /src/lib/header/Header.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import Header from './Header.svelte'; 3 | 4 | it('Render About page', () => { 5 | render(Header); 6 | 7 | const home = screen.getByText('Home'); 8 | expect(home).toBeInTheDocument(); 9 | 10 | const props = screen.getByText('Props'); 11 | expect(props).toBeInTheDocument(); 12 | 13 | const events = screen.getByText('Events'); 14 | expect(events).toBeInTheDocument(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/lib/header/svelte-logo.svg: -------------------------------------------------------------------------------- 1 | svelte-logo -------------------------------------------------------------------------------- /src/lib/named-slots/ContactCard.svelte: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | Unknown name 5 | 6 |

7 | 8 |
9 | 10 | Unknown address 11 | 12 |
13 | 14 | 19 |
20 | 21 | 55 | -------------------------------------------------------------------------------- /src/lib/named-slots/ContactCard.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import html from 'svelte-htm'; 3 | import ContactCard from './ContactCard.svelte'; 4 | 5 | describe('Test name slots', () => { 6 | it('Only put slot "name"', () => { 7 | render(html` 8 | <${ContactCard}> 9 | P. Sherman 10 | 11 | `); 12 | 13 | // Fallbacks 14 | expect(screen.getByText('Unknown address')).toBeInTheDocument(); 15 | expect(screen.getByText('Unknown email')).toBeInTheDocument(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/lib/optional-slots/Comment.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |
13 | 14 |
15 |

{name}

16 | 17 |
18 |
19 |
20 | 21 |
22 |
23 | 24 | 60 | -------------------------------------------------------------------------------- /src/lib/optional-slots/Project.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 |

{title}

10 |

{tasksCompleted}/{totalTasks} tasks completed

11 |
12 | {#if $$slots.comments} 13 |
14 |

Comments

15 | 16 |
17 | {/if} 18 |
19 | 20 | 65 | -------------------------------------------------------------------------------- /src/lib/optional-slots/Project.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import html from 'svelte-htm'; 3 | import Project from './Project.svelte'; 4 | import Comment from './Comment.svelte'; 5 | 6 | describe('Test optional slots', () => { 7 | it('Put Comment component in Project', () => { 8 | render(html` 9 | <${Project} title="Add TypeScript support" tasksCompleted="{25}" totalTasks="{57}"> 10 |
11 | <${Comment} name="Ecma Script" postedAt=${new Date('2020-08-17T14:12:23')}> 12 |

Those interface tests are now passing.

13 | 14 |
15 | 16 | `); 17 | 18 | const article = screen.getAllByRole('article')[0]; 19 | 20 | expect(article).toHaveClass('has-discussion'); 21 | }); 22 | 23 | it('No slot in Project component', () => { 24 | render(html` 25 | <${Project} title="Update documentation" tasksCompleted="{18}" totalTasks="{21}" /> 26 | `); 27 | 28 | const article = screen.getAllByRole('article')[0]; 29 | 30 | expect(article).not.toHaveClass('has-discussion'); 31 | expect(screen.queryByText('Comments')).not.toBeInTheDocument(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/lib/props/DefaultProps.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

The answer is {answer}

6 | -------------------------------------------------------------------------------- /src/lib/props/DefaultProps.test.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'svelte'; 2 | import { render, screen } from '@testing-library/svelte'; 3 | import DefaultProps from './DefaultProps.svelte'; 4 | 5 | describe('Test DefaultProps component', async () => { 6 | it("doesn't pass prop", () => { 7 | render(DefaultProps); 8 | expect(screen.queryByText('The answer is a mystery')).toBeInTheDocument(); 9 | }); 10 | 11 | it('set and update prop', async () => { 12 | const { component } = render(DefaultProps, { answer: 'I dunno' }); 13 | 14 | expect(screen.queryByText('The answer is I dunno')).toBeInTheDocument(); 15 | 16 | // Update prop 17 | await component.$set({ answer: 'another mystery' }); 18 | expect(screen.queryByText('The answer is another mystery')).toBeInTheDocument(); 19 | }); 20 | 21 | it('Pass predefined prop to the component', () => { 22 | const prop: ComponentProps = { answer: 'TypeScript!' }; 23 | 24 | render(DefaultProps, prop); 25 | 26 | expect(screen.getByText('The answer is TypeScript!')); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/lib/slot-fallbacks/Box.svelte: -------------------------------------------------------------------------------- 1 |
2 | 3 | no content was provided 4 | 5 |
6 | 7 | 17 | -------------------------------------------------------------------------------- /src/lib/slot-fallbacks/Box.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/svelte'; 2 | import html from 'svelte-htm'; 3 | import Box from './Box.svelte'; 4 | 5 | describe('Test slot fallbacks', () => { 6 | it('Put some elements', () => { 7 | render(html`<${Box}>

Hello!

`); 8 | expect(screen.getByText('Hello!')).toBeInTheDocument(); 9 | }); 10 | 11 | it('Test slot fallback', () => { 12 | render(html`<${Box} />`); 13 | expect(screen.getByText('no content was provided')).toBeInTheDocument(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/lib/slot-props/Hoverable.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /src/lib/slot-props/Hoverable.test.ts: -------------------------------------------------------------------------------- 1 | import { get, writable } from 'svelte/store'; 2 | import { render, screen } from '@testing-library/svelte'; 3 | import html from 'svelte-htm'; 4 | import Hoverable from './Hoverable.svelte'; 5 | import userEvent from '@testing-library/user-event'; 6 | 7 | it('Test slot props', async () => { 8 | const user = userEvent.setup(); 9 | const hovering = writable(false); 10 | 11 | render(html` 12 | <${Hoverable} let:hovering=${hovering}> 13 |
14 |

Hover over me!

15 |
16 | 17 | 23 | `); 24 | 25 | const element = screen.getByText('Hover over me!'); 26 | await user.hover(element); 27 | 28 | expect(get(hovering)).toBeTruthy(); 29 | expect(screen.getByTestId('hover')).toHaveClass('active'); 30 | }); 31 | -------------------------------------------------------------------------------- /src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | // Mock Data 4 | const posts = [ 5 | { 6 | userId: 1, 7 | id: 1, 8 | title: 'first post title', 9 | body: 'first post body' 10 | }, 11 | { 12 | userId: 2, 13 | id: 5, 14 | title: 'second post title', 15 | body: 'second post body' 16 | }, 17 | { 18 | userId: 3, 19 | id: 6, 20 | title: 'third post title', 21 | body: 'third post body' 22 | } 23 | ]; 24 | 25 | // Define handlers that catch the corresponding requests and returns the mock data. 26 | export const handlers = [ 27 | rest.get('https://jsonplaceholder.typicode.com/posts', (_, res, ctx) => 28 | res(ctx.status(200), ctx.json(posts)) 29 | ), 30 | rest.get('/random-number', (_, res, ctx) => res(ctx.status(200), ctx.json('0.25'))) 31 | ]; 32 | -------------------------------------------------------------------------------- /src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handlers } from './handlers'; 3 | // This configures a Service Worker with the given request handlers. 4 | export const server = setupServer(...handlers); 5 | -------------------------------------------------------------------------------- /src/mocks/setup.ts: -------------------------------------------------------------------------------- 1 | import { server } from './server'; 2 | 3 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 4 | afterAll(() => server.close()); 5 | afterEach(() => server.resetHandlers()); 6 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 |
9 | 10 |
11 | 12 | 24 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 |

Welcome to SvelteKit

2 |

Visit kit.svelte.dev to read the documentation

3 | -------------------------------------------------------------------------------- /src/routes/actions/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | {#if showModal} 9 |
(showModal = false)}>Click outside me!
10 | {/if} 11 | 12 | 31 | -------------------------------------------------------------------------------- /src/routes/bindings/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |

{view}

13 | 14 | 15 | -------------------------------------------------------------------------------- /src/routes/contexts/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 |

Context Example.

12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 | 34 | -------------------------------------------------------------------------------- /src/routes/data-fetching/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/routes/events/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/routes/props/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/routes/random-number/+server.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | 4 | export const GET: RequestHandler = ({ url }) => { 5 | const min = Number(url.searchParams.get('min') ?? '0'); 6 | const max = Number(url.searchParams.get('max') ?? '1'); 7 | 8 | const d = max - min; 9 | 10 | if (isNaN(d) || d < 0) { 11 | throw error(400, 'min and max must be numbers, and min must be less than max'); 12 | } 13 | 14 | const random = min + Math.random() * d; 15 | 16 | return new Response(String(random)); 17 | }; 18 | -------------------------------------------------------------------------------- /src/routes/slots/named-slots/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | P. Sherman 7 | 8 | 9 | 42 Wallaby Way
10 | Sydney 11 |
12 |
13 | -------------------------------------------------------------------------------- /src/routes/slots/optional-slots/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Projects

7 | 8 |
    9 |
  • 10 | 11 |
    12 | 13 |

    Those interface tests are now passing.

    14 |
    15 |
    16 |
    17 |
  • 18 |
  • 19 | 20 |
  • 21 |
22 | 23 | 48 | -------------------------------------------------------------------------------- /src/routes/slots/slot-fallbacks/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 |

Hello!

7 |

This is a box. It can contain anything.

8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/routes/slots/slot-props/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 |
7 | {#if active} 8 |

I am being hovered upon.

9 | {:else} 10 |

Hover over me!

11 | {/if} 12 |
13 |
14 | 15 | 16 |
17 | {#if active} 18 |

I am being hovered upon.

19 | {:else} 20 |

Hover over me!

21 | {/if} 22 |
23 |
24 | 25 | 26 |
27 | {#if active} 28 |

I am being hovered upon.

29 | {:else} 30 |

Hover over me!

31 | {/if} 32 |
33 |
34 | 35 | 47 | -------------------------------------------------------------------------------- /static/demo-props-test.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wd-David/svelte-component-test-recipes/bbca6f40f3d43f881c88174f9d0832a75f4b14d3/static/demo-props-test.gif -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wd-David/svelte-component-test-recipes/bbca6f40f3d43f881c88174f9d0832a75f4b14d3/static/favicon.png -------------------------------------------------------------------------------- /static/runtime-module-error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wd-David/svelte-component-test-recipes/bbca6f40f3d43f881c88174f9d0832a75f4b14d3/static/runtime-module-error.jpg -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import preprocess from 'svelte-preprocess'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://github.com/sveltejs/svelte-preprocess 7 | // for more information about preprocessors 8 | preprocess: preprocess(), 9 | 10 | kit: { 11 | adapter: adapter() 12 | } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "types": ["vitest/globals", "vitest/importMeta", "@testing-library/jest-dom"] 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // 16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 17 | // from the referenced tsconfig.json - TypeScript does not merge them in 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import type { UserConfig } from 'vite'; 3 | import { configDefaults, type UserConfig as VitestConfig } from 'vitest/config'; 4 | 5 | const config: UserConfig & { test: VitestConfig['test'] } = { 6 | plugins: [sveltekit()], 7 | define: { 8 | // Eliminate in-source test code 9 | 'import.meta.vitest': 'undefined' 10 | }, 11 | test: { 12 | // jest like globals 13 | globals: true, 14 | environment: 'jsdom', 15 | // in-source testing 16 | includeSource: ['src/**/*.{js,ts,svelte}'], 17 | // Add @testing-library/jest-dom matchers & mocks of SvelteKit modules 18 | setupFiles: ['setupTest.ts', 'src/mocks/setup.ts'], 19 | // Exclude files in c8 20 | coverage: { 21 | exclude: ['setupTest.ts', 'src/mocks'] 22 | }, 23 | // Exclude playwright tests folder 24 | exclude: [...configDefaults.exclude, 'tests'] 25 | } 26 | }; 27 | 28 | export default config; 29 | --------------------------------------------------------------------------------