├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── auto-cleanup.tsx │ ├── basic.tsx │ ├── broken-cleanup.tsx │ ├── cleanup.tsx │ ├── debug.tsx │ ├── end-to-end.tsx │ ├── events.tsx │ ├── multi-base.tsx │ ├── no-router.tsx │ ├── routes.tsx │ ├── stopwatch.tsx │ └── tsconfig.json ├── index.ts └── types.ts ├── tsconfig.json └── vitest.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "arrowParens": "avoid", 7 | "printWidth": 100 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Ryan Carniato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Solid Testing Library 3 |

4 |
5 |

Simple and complete Solid DOM testing utilities that encourage good testing 6 | practices.

7 | 8 | > Inspired completely by [preact-testing-library](https://github.com/testing-library/preact-testing-library) 9 | 10 | [![Coverage Status](https://coveralls.io/repos/github/ryansolid/solid-testing-library/badge.svg?branch=main)](https://coveralls.io/github/ryansolid/solid-testing-library?branch=main) 11 | [![NPM Version](https://img.shields.io/npm/v/@solidjs/testing-library.svg?style=flat)](https://www.npmjs.com/package/@solidjs/testing-library) 12 | [![NPM Downloads](https://img.shields.io/npm/dm/solid-testing-library.svg?style=flat)](https://www.npmjs.com/package/solid-testing-library) 13 | [![Discord](https://img.shields.io/discord/722131463138705510)](https://discord.com/invite/solidjs) 14 | 15 |
16 | 17 | --- 18 | 19 | ## Table of Contents 20 | 21 | - [The Problem](#the-problem) 22 | - [The Solution](#the-solution) 23 | - [Installation](#installation) 24 | - [Docs](#docs) 25 | - [Issues](#issues) 26 | - [Acknowledgement](#acknowledgment) 27 | 28 | --- 29 | 30 | 31 | ## The Problem 32 | 33 | You want to write tests for your Solid components so that they avoid including implementation details, and are maintainable in the long run. 34 | 35 | 36 | ## The Solution 37 | 38 | The Solid Testing Library is a very lightweight solution for testing Solid components. Its primary guiding principle is: 39 | 40 | > [The more your tests resemble the way your software is used, the more confidence they can give you.](https://twitter.com/kentcdodds/status/977018512689455106) 41 | 42 | ## Installation 43 | 44 | This module is distributed via npm which is bundled with node and should be installed 45 | as one of your project's `devDependencies`: 46 | 47 | ```sh 48 | npm install --save-dev @solidjs/testing-library 49 | ``` 50 | 51 | If you using Jest we recommend using [solid-jest](https://github.com/solidjs/solid-jest) to properly resolve the browser version of Solid as Jest will default to the server version when run in Node. 52 | 53 | 💡 If you are using Jest or vitest, you may also be interested in installing `@testing-library/jest-dom` so you can use 54 | [the custom jest matchers](https://github.com/testing-library/jest-dom). 55 | 56 | 57 | ## Integration with Vite 58 | 59 | A working Vite template setup with `solid-testing-library` and TypeScript support can be found [for classic solid](https://github.com/solidjs/templates/tree/main/ts-vitest) and [for solid-start](https://github.com/solidjs/solid-start/tree/main/examples/with-vitest). 60 | 61 | 62 | ## Docs 63 | 64 | See the [docs](https://testing-library.com/docs/preact-testing-library/intro) over at the Testing Library website. 65 | 66 | There are several key differences, though: 67 | 68 | ⚠️ The `render` function takes in a function that returns a Solid Component, rather than simply the component itself. 69 | 70 | ```tsx 71 | // With @testing-library/preact 72 | const results = render(, options); 73 | ``` 74 | 75 | ```tsx 76 | // With solid-testing-library 77 | const results = render(() => , options); 78 | ``` 79 | 80 | ⚠️ Solid.js does *not* re-render, it merely executes side effects triggered by reactive state that change the DOM, therefore there is no `rerender` method. You can use global signals to manipulate your test component in a way that causes it to update. 81 | 82 | Solid.js reactive changes are pretty instantaneous, so there is rarely need to use `waitFor(…)`, `await findByRole(…)` and other asynchronous queries to test the rendered result, except for transitions, suspense, resources and router navigation. 83 | 84 | ⚠️ In extension of the original API, the render function of this testing library supports a convenient `location` option that will set up an in-memory router pointing at the specified location. Since this setup is not instantaneous, you need to first use asynchronous queries (`findBy`) after employing it: 85 | 86 | ```tsx 87 | it('uses params', async () => { 88 | const App = () => ( 89 | <> 90 |

Id: {useParams()?.id}

} /> 91 |

Start

} /> 92 | 93 | ); 94 | const { findByText } = render(() => , { location: "ids/1234" }); 95 | expect(await findByText("Id: 1234")).not.toBeFalsy(); 96 | }); 97 | ``` 98 | 99 | It uses `@solidjs/router`, so if you want to use a different router, you should consider the `wrapper` option instead. If you attempt to use this without having the package installed, you will receive an error message. At the moment, there is an issue with using `useNavigate` inside of the tests (since you cannot get into the context of a Route), but `` inside a `` will work fine to switch routes during tests. 100 | 101 | ⚠️ Solid.js external reactive state does not require any DOM elements to run in, so our `renderHook` call to test hooks in the context of a component (if your hook does not require the context of a component, `createRoot` should suffice to test the reactive behavior; for convenience, we also have `testEffect`, which is described later) has no `container`, `baseElement` or queries in its options or return value. Instead, it has an `owner` to be used with [`runWithOwner`](https://www.solidjs.com/docs/latest/api#runwithowner) if required. It also exposes a `cleanup` function, though this is already automatically called after the test is finished. 102 | 103 | ```ts 104 | function renderHook( 105 | hook: (...args: Args) => Result, 106 | options: { 107 | initialProps?: Args, 108 | wrapper?: Component<{ children: JSX.Element }> 109 | } 110 | ) => { 111 | result: Result; 112 | owner: Owner | null; 113 | cleanup: () => void; 114 | } 115 | ``` 116 | 117 | This can be used to easily test a hook / primitive: 118 | 119 | ```ts 120 | const { result } = renderHook(createResult); 121 | expect(result).toBe(true); 122 | ``` 123 | 124 | If you are using a `wrapper` with `renderHook`, make sure it will **always** return `props.children` - especially if you are using a context with asynchronous code together with ``, because this is required to get the value from the hook and it is only obtained synchronously once and you will otherwise only get `undefined` and wonder why this is the case. 125 | 126 | ⚠️ Solid.js supports [custom directives](https://www.solidjs.com/docs/latest/api#use___), which is a convenient pattern to tie custom behavior to elements, so we also have a `renderDirective` call, which augments `renderHook` to take a directive as first argument, accept an `initialValue` for the argument and a `targetElement` (string, HTMLElement or function returning a HTMLElement) in the `options` and also returns `arg` and `setArg` to read and manipulate the argument of the directive. 127 | 128 | ```ts 129 | function renderDirective< 130 | Arg extends any, 131 | Elem extends HTMLElement 132 | >( 133 | directive: (ref: Elem, arg: Accessor) => void, 134 | options?: { 135 | ...renderOptions, 136 | initialValue: Arg, 137 | targetElement: 138 | | Lowercase 139 | | Elem 140 | | (() => Elem) 141 | } 142 | ): Result & { arg: Accessor, setArg: Setter }; 143 | ``` 144 | 145 | This allows for very effective and concise testing of directives: 146 | 147 | ```ts 148 | const { asFragment, setArg } = renderDirective(myDirective); 149 | expect(asFragment()).toBe( 150 | '
' 151 | ); 152 | setArg("perfect"); 153 | expect(asFragment()).toBe( 154 | '
' 155 | ); 156 | ``` 157 | 158 | Solid.js manages side effects with different variants of `createEffect`. While you can use `waitFor` to test asynchronous effects, it uses polling instead of allowing Solid's reactivity to trigger the next step. In order to simplify testing those asynchronous effects, we have a `testEffect` helper that complements the hooks for directives and hooks: 159 | 160 | ```ts 161 | testEffect(fn: (done: (result: T) => void) => void, owner?: Owner): Promise 162 | 163 | // use it like this: 164 | test("testEffect allows testing an effect asynchronously", () => { 165 | const [value, setValue] = createSignal(0); 166 | return testEffect(done => createEffect((run: number = 0) => { 167 | if (run === 0) { 168 | expect(value()).toBe(0); 169 | setValue(1); 170 | } else if (run === 1) { 171 | expect(value()).toBe(1); 172 | done(); 173 | } 174 | return run + 1; 175 | })); 176 | }); 177 | ``` 178 | 179 | It allows running the effect inside a defined owner that is received as an optional second argument. This can be useful in combination with `renderHook`, which gives you an owner field in its result. The return value is a Promise with the value given to the `done()` callback. You can either await the result for further assertions or return it to your test runner. 180 | 181 | 182 | ## Issues 183 | 184 | If you find any issues *with this library*, please [check on the issues page](https://github.com/solidjs/solid-testing-library/issues) if they are already known. If not, opening an issue will be much appreciated, even more so if it contains a 185 | 186 | - short description 187 | - minimal reproduction code 188 | - list of possible workarounds, if there are any 189 | 190 | If you think you can fix an issue yourself, feel free to [open a pull-request](https://github.com/solidjs/solid-testing-library/pulls). If functionality changes, please don't forget to add or adapt tests. 191 | 192 | Please keep in mind that not all issues related to testing Solid.js code are directly related to this library. In some cases, the culprit might be [Solid's vite plugin](https://github.com/solidjs/vite-plugin-solid) or [Vitest](https://github.com/vitest-dev/vitest) instead. Posting the issue to the correct project will speed up fixing it; if in doubt, you can ask [on our discord](https://discord.com/invite/solidjs). 193 | 194 | 195 | ### Known issues 196 | 197 | If you are using [`vitest`](https://vitest.dev/), then tests might fail, because the packages `solid-js`, and `@solidjs/router` (if used) need to be loaded only once, and they could be loaded both through the internal `vite` server and through node. Typical bugs that happen because of this is that dispose is supposedly undefined, or the router could not be loaded. 198 | 199 | Since version 2.8.2, our vite plugin has gained the capability to configure everything for testing, so you should only need extra configuration for globals, coverage, etc. 200 | 201 | ## Acknowledgement 202 | 203 | Thanks goes to [Kent C. Dodds](https://kentcdodds.com/) and his colleagues for creating testing-library and to the creators of [preact-testing-library](https://github.com/testing-library/preact-testing-library). 204 | 205 | This library has been created by Ryan Carniato and is currently maintained by Alex Lohr. 206 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solidjs/testing-library", 3 | "version": "0.8.9", 4 | "description": "Simple and complete Solid testing utilities that encourage good testing practices.", 5 | "type": "module", 6 | "main": "./dist/index.cjs", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs" 12 | }, 13 | "license": "MIT", 14 | "author": "Ryan Carniato", 15 | "maintainers": [ 16 | "Alex Lohr" 17 | ], 18 | "homepage": "https://github.com/solidjs/solid-testing-library#readme", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/solidjs/solid-testing-library" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/solidjs/solid-testing-library/issues" 25 | }, 26 | "engines": { 27 | "node": ">= 14" 28 | }, 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "keywords": [ 33 | "testing", 34 | "solid-js", 35 | "ui", 36 | "dom", 37 | "jsdom", 38 | "unit", 39 | "integration", 40 | "functional", 41 | "end-to-end", 42 | "e2e" 43 | ], 44 | "files": [ 45 | "dist" 46 | ], 47 | "scripts": { 48 | "prepublishOnly": "npm run build", 49 | "build": "tsup src/index.ts --format esm,cjs --dts --clean; node -e 'require(`node:fs/promises`).copyFile(`dist/index.d.ts`, `dist/index.d.cts`)'", 50 | "typecheck": "tsc --noEmit; tsc --noEmit --project src/__tests__/tsconfig.json", 51 | "test": "vitest", 52 | "test:watch": "npm test --watch", 53 | "test:coverage": "npm test -- --coverage", 54 | "setup": "npm install && npm run validate", 55 | "prettier": "prettier -w src/**/* ./*.json ./vitest.config.js", 56 | "validate": "npm run typecheck && npm run test:coverage && npm run build" 57 | }, 58 | "dependencies": { 59 | "@testing-library/dom": "^10.3.2" 60 | }, 61 | "devDependencies": { 62 | "@solidjs/router": "^0.14.1", 63 | "@testing-library/jest-dom": "^6.4.6", 64 | "@testing-library/user-event": "^14.5.2", 65 | "@vitest/coverage-v8": "^2.0.4", 66 | "jsdom": "^24.1.1", 67 | "prettier": "^3.3.3", 68 | "pretty-format": "^29.7.0", 69 | "solid-js": "^1.8.18", 70 | "tsup": "8.2.2", 71 | "typescript": "^5.5.3", 72 | "vite-plugin-solid": "^2.10.2", 73 | "vitest": "^2.0.4" 74 | }, 75 | "peerDependencies": { 76 | "@solidjs/router": ">=0.9.0", 77 | "solid-js": ">=1.0.0" 78 | }, 79 | "peerDependenciesMeta": { 80 | "@solidjs/router": { 81 | "optional": true 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/__tests__/auto-cleanup.tsx: -------------------------------------------------------------------------------- 1 | import { render } from ".."; 2 | 3 | // This just verifies that by importing STL in an 4 | // environment which supports afterEach (like jest) 5 | // we'll get automatic cleanup between tests. 6 | test("first", () => { 7 | render(() =>
hi
); 8 | }); 9 | 10 | test("second", () => { 11 | expect(document.body.innerHTML).toEqual(""); 12 | }); 13 | -------------------------------------------------------------------------------- /src/__tests__/basic.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | import { 3 | createSignal, 4 | createEffect, 5 | createContext, 6 | useContext, 7 | ParentComponent, 8 | Accessor, 9 | getOwner, 10 | createRoot, 11 | } from "solid-js"; 12 | import { For } from "solid-js/web"; 13 | import type { JSX } from "solid-js"; 14 | import { render, renderDirective, renderHook, screen, testEffect } from ".."; 15 | import userEvent from "@testing-library/user-event"; 16 | 17 | declare global { 18 | var _$HY: Record; 19 | } 20 | 21 | globalThis._$HY = {}; 22 | 23 | test("render calls createEffect immediately", () => { 24 | const cb = vi.fn(); 25 | 26 | function Comp() { 27 | createEffect(cb); 28 | return null; 29 | } 30 | 31 | render(() => ); 32 | 33 | expect(cb).toHaveBeenCalledTimes(1); 34 | }); 35 | 36 | test("findByTestId returns the element", async () => { 37 | let ref!: HTMLDivElement; 38 | 39 | render(() =>
); 40 | 41 | expect(await screen.findByTestId("foo")).toBe(ref); 42 | }); 43 | 44 | test("userEvent triggers createEffect calls", async () => { 45 | const cb = vi.fn(); 46 | 47 | function Counter() { 48 | createEffect(() => (count(), cb())); 49 | 50 | const [count, setCount] = createSignal(0); 51 | 52 | return ; 53 | } 54 | 55 | const { 56 | container: { firstChild: buttonNode } 57 | } = render(() => ); 58 | 59 | cb.mockClear(); 60 | await userEvent.click(buttonNode! as Element); 61 | expect(buttonNode).toHaveTextContent("1"); 62 | expect(cb).toHaveBeenCalledTimes(1); 63 | }); 64 | 65 | test("calls to hydrate will run createEffects", () => { 66 | const cb = vi.fn(); 67 | 68 | function Comp() { 69 | createEffect(cb); 70 | return null; 71 | } 72 | 73 | render(() => , { hydrate: true }); 74 | 75 | expect(cb).toHaveBeenCalledTimes(1); 76 | }); 77 | 78 | test("queries should not return elements outside of the container", () => { 79 | const { container, getAllByText } = render(() =>
Some text...
); 80 | const falseContainer = document.createElement("p"); 81 | falseContainer.textContent = "Some text..."; 82 | container.parentNode!.insertBefore(falseContainer, getAllByText("Some text...")[0].parentNode); 83 | expect(getAllByText("Some text...")[0] === container.childNodes[0]).toBe(true); 84 | }); 85 | 86 | test("wrapper option works correctly", () => { 87 | const { asFragment } = render(() =>
Component
, { 88 | wrapper: props =>
Wrapper {props.children}
89 | }); 90 | expect(asFragment()).toBe("
Wrapper
Component
"); 91 | }); 92 | 93 | test("wrapper option includes context", async () => { 94 | const context = createContext("test"); 95 | const Wrapper: ParentComponent = props => ( 96 | {props.children} 97 | ); 98 | const { asFragment } = render(() =>
{useContext(context)}
, { wrapper: Wrapper }); 99 | expect(asFragment()).toBe("
works
"); 100 | }); 101 | 102 | test("For does not need a parent wrapper", () => { 103 | const { getByText } = render(() => {(i) => {i}}); 104 | expect(getByText('b')).toBeInTheDocument(); 105 | }); 106 | 107 | test("renderHook works correctly", () => { 108 | const createDate = () => { 109 | const [date, setDate] = createSignal(new Date()); 110 | return [date, (d: Date) => (d ? setDate(d) : setDate(new Date()))] as const; 111 | }; 112 | const { 113 | result: [date, setDate] 114 | } = renderHook(createDate); 115 | expect(date()).toBeInstanceOf(Date); 116 | const newDate = new Date(); 117 | setDate(newDate); 118 | expect(date()).toBe(newDate); 119 | }); 120 | 121 | test("renderHook accepts hook props as array parameter", () => { 122 | const { result } = renderHook(opts => opts, ["option value"]); 123 | expect(result).toBe("option value"); 124 | }); 125 | 126 | test("renderHook accepts hook props as option value", () => { 127 | const { result } = renderHook(opts => opts, { initialProps: ["option value"] }); 128 | expect(result).toBe("option value"); 129 | }); 130 | 131 | test("wrapper context is available in renderHook", () => { 132 | const context = createContext("initial value"); 133 | const testHook = () => useContext(context); 134 | const Wrapper: ParentComponent = props => ( 135 | {props.children} 136 | ); 137 | const { result } = renderHook(testHook, { wrapper: Wrapper }); 138 | expect(result).toBe("context value"); 139 | }); 140 | 141 | declare module "solid-js" { 142 | namespace JSX { 143 | interface Directives { 144 | noArgDirective: boolean; 145 | argDirective: string; 146 | } 147 | } 148 | } 149 | 150 | type NoArgDirectiveArg = Accessor; 151 | 152 | test("renderDirective works for directives without an argument", () => { 153 | const noArgDirective: (ref: HTMLElement, arg: NoArgDirectiveArg) => void = (ref: HTMLElement) => { 154 | ref.dataset.directive = "works"; 155 | }; 156 | const { asFragment } = renderDirective(noArgDirective); 157 | expect(asFragment()).toBe('
'); 158 | }); 159 | 160 | test("renderDirective accepts different targetElement types", () => { 161 | const noArgDirective: (ref: HTMLElement, arg: NoArgDirectiveArg) => void = (ref: HTMLElement) => { 162 | ref.dataset.directive = "works"; 163 | }; 164 | const { asFragment: getHtml1 } = renderDirective(noArgDirective, { targetElement: "span" }); 165 | expect(getHtml1()).toBe(''); 166 | const button = document.createElement("button"); 167 | const { asFragment: getHtml2 } = renderDirective(noArgDirective, { targetElement: button }); 168 | expect(getHtml2()).toBe(''); 169 | const getH3 = () => document.createElement("h3"); 170 | const { asFragment: getHtml3 } = renderDirective(noArgDirective, { targetElement: getH3 }); 171 | expect(getHtml3()).toBe('

'); 172 | const { asFragment: getHtml4 } = renderDirective(noArgDirective, { targetElement: {} }); 173 | expect(getHtml4()).toBe('
'); 174 | }); 175 | 176 | test("renderDirective works for directives with argument", () => { 177 | const argDirective = (ref: HTMLSpanElement, arg: Accessor) => { 178 | createEffect(() => { 179 | ref.dataset.directive = arg(); 180 | }); 181 | }; 182 | const { asFragment, setArg } = renderDirective(argDirective, { 183 | initialValue: "initial value", 184 | targetElement: "span" 185 | }); 186 | expect(asFragment()).toBe(''); 187 | setArg("updated value"); 188 | expect(asFragment()).toBe(''); 189 | }); 190 | 191 | test("testEffect allows testing an effect asynchronously", () => { 192 | const [value, setValue] = createSignal(0); 193 | return testEffect(done => 194 | createEffect((run: number = 0) => { 195 | if (run === 0) { 196 | expect(value()).toBe(0); 197 | setValue(1); 198 | } else if (run === 1) { 199 | expect(value()).toBe(1); 200 | done(); 201 | } 202 | return run + 1; 203 | }) 204 | ); 205 | }); 206 | 207 | test("testEffect catches errors", () => { 208 | const [value, setValue] = createSignal<{ error: string } | null>({ error: "not yet" }); 209 | return testEffect(done => 210 | createEffect((run: number = 0) => { 211 | value()!.error; 212 | if (run === 0) { 213 | setValue(null); 214 | } 215 | if (run === 1) { 216 | done(); 217 | } 218 | return run + 1; 219 | }) 220 | ) 221 | .then(() => { 222 | throw new Error("Error swallowed by testEffect!"); 223 | }) 224 | .catch((e: Error) => expect(e.name).toBe("TypeError")); 225 | }); 226 | 227 | test("testEffect runs with owner", () => { 228 | const [owner, dispose] = createRoot(dispose => [getOwner(), dispose]); 229 | return testEffect( 230 | done => 231 | createEffect(() => { 232 | expect(getOwner()!.owner).toBe(owner); 233 | dispose(); 234 | done(); 235 | }), 236 | owner! 237 | ); 238 | }); 239 | 240 | -------------------------------------------------------------------------------- /src/__tests__/broken-cleanup.tsx: -------------------------------------------------------------------------------- 1 | vi.mock('solid-js', () => ({ hydrate: () => {}, render: () => {} })); 2 | import { render } from '..'; 3 | 4 | const errors: Event[] = []; 5 | const handler = (ev: Event) => errors.push(ev); 6 | globalThis.addEventListener('error', handler); 7 | 8 | test('trigger auto-cleanup', () => { 9 | render(() =>
Hi
); 10 | }); 11 | 12 | test('check if auto-cleanup threw an error', () => { 13 | expect(errors).toEqual([]); 14 | globalThis.removeEventListener('error', handler); 15 | }); 16 | 17 | -------------------------------------------------------------------------------- /src/__tests__/cleanup.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | import { onCleanup, render as soildRender } from "solid-js"; 3 | import { cleanup, render } from ".."; 4 | 5 | test("cleans up the document", () => { 6 | const spy = vi.fn(); 7 | const divId = "my-div"; 8 | 9 | function Test() { 10 | onCleanup(() => { 11 | expect(document.getElementById(divId)).toBeInTheDocument(); 12 | spy(); 13 | }); 14 | return
; 15 | } 16 | 17 | render(() => ); 18 | cleanup(); 19 | expect(document.body.innerHTML).toBe(""); 20 | expect(spy).toHaveBeenCalledTimes(1); 21 | }); 22 | 23 | test("cleanup does not error when an element is not a child", () => { 24 | render(() =>
, { container: document.createElement("div") }); 25 | cleanup(); 26 | }); 27 | 28 | -------------------------------------------------------------------------------- /src/__tests__/debug.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | import { screen, render } from ".."; 3 | 4 | beforeEach(() => { 5 | vi.spyOn(console, "log").mockImplementation(() => {}); 6 | }); 7 | 8 | afterEach(() => { 9 | // @ts-ignore 10 | console.log.mockRestore(); 11 | }); 12 | 13 | test("debug pretty prints the container", () => { 14 | const HelloWorld = () =>

Hello World

; 15 | 16 | render(() => ); 17 | 18 | screen.debug(); 19 | 20 | expect(console.log).toHaveBeenCalledTimes(1); 21 | expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Hello World")); 22 | }); 23 | 24 | test("debug pretty prints multiple containers", () => { 25 | const HelloWorld = () => ( 26 | <> 27 |

Hello World

28 |

Hello World

29 | 30 | ); 31 | 32 | const { debug, getAllByTestId } = render(() => ); 33 | const multipleElements = getAllByTestId("testId"); 34 | debug(multipleElements); 35 | expect(console.log).toHaveBeenCalledTimes(2); 36 | expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Hello World")); 37 | }); 38 | 39 | test("allows same arguments as prettyDOM", () => { 40 | const HelloWorld = () =>

Hello World

; 41 | const { debug, container } = render(() => ); 42 | debug(container, 6, { highlight: false }); 43 | expect(console.log).toHaveBeenCalledTimes(1); 44 | // @ts-ignore 45 | expect(console.log.mock.calls[0]).toMatchInlineSnapshot(` 46 | [ 47 | "
48 | ...", 49 | ] 50 | `); 51 | }); 52 | -------------------------------------------------------------------------------- /src/__tests__/end-to-end.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | import { createResource, Show } from "solid-js"; 3 | import { screen, render, waitForElementToBeRemoved } from ".."; 4 | 5 | const fetchAMessage = () => 6 | new Promise<{ returnedMessage: string }>(resolve => { 7 | // we are using random timeout here to simulate a real-time example 8 | // of an async operation calling a callback at a non-deterministic time 9 | const randomTimeout = Math.floor(Math.random() * 100); 10 | 11 | setTimeout(() => { 12 | resolve({ returnedMessage: "Hello World" }); 13 | }, randomTimeout); 14 | }); 15 | 16 | function ComponentWithLoader() { 17 | const [data] = createResource("data", fetchAMessage); 18 | return ( 19 | Loading...
}> 20 |
Loaded this message: {data()!.returnedMessage}!
21 | 22 | ); 23 | } 24 | 25 | test("it waits for the data to be loaded", async () => { 26 | render(() => ); 27 | const loading = () => screen.getByText("Loading..."); 28 | await waitForElementToBeRemoved(loading); 29 | expect(screen.getByTestId("message")).toHaveTextContent(/Hello World/); 30 | }); 31 | -------------------------------------------------------------------------------- /src/__tests__/events.tsx: -------------------------------------------------------------------------------- 1 | import { Dynamic } from "solid-js/web"; 2 | import { render, fireEvent } from ".."; 3 | import userEvent from "@testing-library/user-event"; 4 | import type { Mock } from "vitest"; 5 | 6 | const eventTypes = [ 7 | { 8 | type: "Clipboard", 9 | events: ["copy", "paste"], 10 | elementType: "input" 11 | }, 12 | { 13 | type: "Composition", 14 | events: ["compositionEnd", "compositionStart", "compositionUpdate"], 15 | elementType: "input" 16 | }, 17 | { 18 | type: "Keyboard", 19 | events: ["keyDown", "keyPress", "keyUp"], 20 | elementType: "input", 21 | init: { keyCode: 13 } 22 | }, 23 | { 24 | type: "Focus", 25 | events: ["focus", "blur"], 26 | elementType: "input" 27 | }, 28 | { 29 | type: "Form", 30 | events: ["focus", "blur"], 31 | elementType: "input" 32 | }, 33 | { 34 | type: "Focus", 35 | events: ["input", "invalid"], 36 | elementType: "input" 37 | }, 38 | { 39 | type: "Focus", 40 | events: ["submit"], 41 | elementType: "form" 42 | }, 43 | { 44 | type: "Mouse", 45 | events: [ 46 | "click", 47 | "contextMenu", 48 | "dblClick", 49 | "drag", 50 | "dragEnd", 51 | "dragEnter", 52 | "dragExit", 53 | "dragLeave", 54 | "dragOver", 55 | "dragStart", 56 | "drop", 57 | "mouseDown", 58 | "mouseEnter", 59 | "mouseLeave", 60 | "mouseMove", 61 | "mouseOut", 62 | "mouseOver", 63 | "mouseUp" 64 | ], 65 | elementType: "button" 66 | }, 67 | { 68 | type: "Selection", 69 | events: ["select"], 70 | elementType: "input" 71 | }, 72 | { 73 | type: "Touch", 74 | events: ["touchCancel", "touchEnd", "touchMove", "touchStart"], 75 | elementType: "button" 76 | }, 77 | { 78 | type: "UI", 79 | events: ["scroll"], 80 | elementType: "div" 81 | }, 82 | { 83 | type: "Wheel", 84 | events: ["wheel"], 85 | elementType: "div" 86 | }, 87 | { 88 | type: "Media", 89 | events: [ 90 | "abort", 91 | "canPlay", 92 | "canPlayThrough", 93 | "durationChange", 94 | "emptied", 95 | "encrypted", 96 | "ended", 97 | "error", 98 | "loadedData", 99 | "loadedMetadata", 100 | "loadStart", 101 | "pause", 102 | "play", 103 | "playing", 104 | "progress", 105 | "rateChange", 106 | "seeked", 107 | "seeking", 108 | "stalled", 109 | "suspend", 110 | "timeUpdate", 111 | "volumeChange", 112 | "waiting" 113 | ], 114 | elementType: "video" 115 | }, 116 | { 117 | type: "Image", 118 | events: ["load", "error"], 119 | elementType: "img" 120 | }, 121 | { 122 | type: "Animation", 123 | events: ["animationStart", "animationEnd", "animationIteration"], 124 | elementType: "div" 125 | }, 126 | { 127 | type: "Transition", 128 | events: ["transitionEnd"], 129 | elementType: "div" 130 | } 131 | ]; 132 | 133 | function event(el: HTMLElement, name: string, spy: Mock) { 134 | el.addEventListener(name, spy); 135 | } 136 | 137 | eventTypes.forEach(({ type, events, elementType, init }) => { 138 | describe(`${type} Events`, () => { 139 | events.forEach(eventName => { 140 | const eventProp = eventName.toLowerCase(); 141 | 142 | it(`triggers ${eventProp}`, () => { 143 | let ref!: HTMLElement; 144 | const spy = vi.fn(); 145 | 146 | render(() => ); 147 | event(ref, eventProp, spy); 148 | 149 | // @ts-ignore 150 | fireEvent[eventName](ref, init); 151 | 152 | expect(spy).toHaveBeenCalledTimes(1); 153 | }); 154 | }); 155 | }); 156 | }); 157 | 158 | test("onInput works", async () => { 159 | const handler = vi.fn(); 160 | 161 | const { 162 | container: { firstChild: input } 163 | } = render(() => ); 164 | 165 | await userEvent.type(input! as Element, "a"); 166 | 167 | expect(handler).toHaveBeenCalledTimes(1); 168 | }); 169 | 170 | test("calling `fireEvent` directly works too", () => { 171 | const handleEvent = vi.fn(); 172 | 173 | const { 174 | container: { firstChild: button } 175 | } = render(() =>
32 | 33 |
34 | ); 35 | } 36 | 37 | const wait = (time: number) => new Promise(resolve => setTimeout(resolve, time)); 38 | 39 | test("unmounts a component", async () => { 40 | vi.spyOn(console, "error").mockImplementation(() => {}); 41 | 42 | const { unmount, container } = render(() => ); 43 | 44 | userEvent.click(screen.getByText("Start") as Element); 45 | 46 | unmount(); 47 | 48 | // Hey there reader! You don't need to have an assertion like this one 49 | // this is just me making sure that the unmount function works. 50 | // You don't need to do this in your apps. Just rely on the fact that this works. 51 | expect(container.innerHTML).toBe(""); 52 | 53 | // Just wait to see if the interval is cleared or not. 54 | // If it's not, then we'll call setState on an unmounted component and get an error. 55 | await wait((() => expect(console.error).not.toHaveBeenCalled()) as any); 56 | }); 57 | -------------------------------------------------------------------------------- /src/__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals", "@testing-library/jest-dom"] 5 | }, 6 | "include": ["./*.tsx"] 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getQueriesForElement, prettyDOM } from "@testing-library/dom"; 2 | import { 3 | Accessor, 4 | createComponent, 5 | createRoot, 6 | createSignal, 7 | getOwner, 8 | lazy, 9 | catchError, 10 | onMount, 11 | Owner, 12 | runWithOwner 13 | } from "solid-js"; 14 | import { hydrate as solidHydrate, render as solidRender } from "solid-js/web"; 15 | 16 | import type { 17 | Ui, 18 | Result, 19 | Options, 20 | Ref, 21 | RenderHookResult, 22 | RenderHookOptions, 23 | RenderDirectiveOptions, 24 | RenderDirectiveResult 25 | } from "./types"; 26 | 27 | /* istanbul ignore next */ 28 | if (typeof process === 'undefined' || !process.env.STL_SKIP_AUTO_CLEANUP) { 29 | //@ts-ignore 30 | if (typeof afterEach === "function") { afterEach(cleanup); } 31 | } 32 | 33 | const mountedContainers = new Set(); 34 | 35 | /** 36 | * Renders a component to test it 37 | * @param ui {Ui} a function calling the component 38 | * @param options {Options} test options 39 | * @returns {Result} references and tools to test the component 40 | * 41 | * ```ts 42 | * const { getByText } = render(() => , { wrapper: I18nProvider }); 43 | * const button = getByText('Accept'); 44 | * ``` 45 | * ### Options 46 | * - `options.container` - the HTML element which the UI will be rendered into; otherwise a `
` will be created 47 | * - `options.baseElement` - the parent of the container, the default will be `` 48 | * - `options.queries` - custom queries (see https://testing-library.com/docs/queries/about) 49 | * - `options.hydrate` - `true` if you want to test hydration 50 | * - `options.wrapper` - a component that applies a context provider and returns `props.children` 51 | * - `options.location` - wraps the component in a solid-router with memory integration pointing at the given path 52 | * 53 | * ### Result 54 | * - `result.asFragment()` - returns the HTML fragment as string 55 | * - `result.container` - the container in which the component is rendered 56 | * - `result.baseElement` - the parent of the component 57 | * - `result.debug()` - returns helpful debug output on the console 58 | * - `result.unmount()` - unmounts the component, usually automatically called in cleanup 59 | * - `result.`[queries] - testing library queries, see https://testing-library.com/docs/queries/about) 60 | */ 61 | function render(ui: Ui, options: Options = {}): Result { 62 | let { container, baseElement = container, queries, hydrate = false, wrapper, location } = options; 63 | 64 | if (!baseElement) { 65 | // Default to document.body instead of documentElement to avoid output of potentially-large 66 | // head elements (such as JSS style blocks) in debug output. 67 | baseElement = document.body; 68 | } 69 | 70 | if (!container) { 71 | container = baseElement.appendChild(document.createElement("div")); 72 | } 73 | 74 | const wrappedUi: Ui = 75 | typeof wrapper === "function" 76 | ? () => 77 | createComponent(wrapper!, { 78 | get children() { 79 | return createComponent(ui, {}); 80 | } 81 | }) 82 | : ui; 83 | 84 | const routedUi: Ui = 85 | typeof location === "string" 86 | ? lazy(async () => { 87 | try { 88 | const { createMemoryHistory, MemoryRouter } = await import("@solidjs/router"); 89 | const history = createMemoryHistory(); 90 | location && history.set({ value: location, scroll: false, replace: true }); 91 | return { 92 | default: () => 93 | createComponent(MemoryRouter, { 94 | history, 95 | get children() { return createComponent(wrappedUi, {}); } 96 | }) 97 | }; 98 | } catch (e: unknown) { 99 | console.error( 100 | `Error attempting to initialize @solidjs/router:\n"${ 101 | (e instanceof Error && e.message) || e?.toString() || "unknown error" 102 | }"` 103 | ); 104 | return { default: () => createComponent(wrappedUi, {}) }; 105 | } 106 | }) 107 | : wrappedUi; 108 | 109 | const dispose = hydrate 110 | ? (solidHydrate(routedUi, container) as unknown as () => void) 111 | : solidRender(routedUi, container); 112 | 113 | // We'll add it to the mounted containers regardless of whether it's actually 114 | // added to document.body so the cleanup method works regardless of whether 115 | // they're passing us a custom container or not. 116 | mountedContainers.add({ container, dispose }); 117 | 118 | const queryHelpers = getQueriesForElement(container, queries); 119 | 120 | return { 121 | asFragment: () => container?.innerHTML as string, 122 | container, 123 | baseElement, 124 | debug: (el = baseElement, maxLength, options) => 125 | Array.isArray(el) 126 | ? el.forEach(e => console.log(prettyDOM(e, maxLength, options))) 127 | : console.log(prettyDOM(el, maxLength, options)), 128 | unmount: dispose, 129 | ...queryHelpers 130 | } as Result; 131 | } 132 | 133 | /** 134 | * "Renders" a hook to test it 135 | * @param hook {() => unknown)} a hook or primitive 136 | * @param options {RenderHookOptions} test options 137 | * @returns {RenderHookResult} references and tools to test the hook/primitive 138 | * 139 | * ```ts 140 | * const { result } = render(useI18n, { wrapper: I18nProvider }); 141 | * expect(result.t('test')).toBe('works'); 142 | * ``` 143 | * ### Options 144 | * - `options.initialProps` - an array with the props that the hook will be provided with. 145 | * - `options.wrapper` - a component that applies a context provider and **always** returns `props.children` 146 | * 147 | * ### Result 148 | * - `result.result` - the return value of the hook/primitive 149 | * - `result.owner` - the reactive owner in which the hook is run (in order to run other reactive code in the same context with [`runWithOwner`](https://www.solidjs.com/docs/latest/api#runwithowner)) 150 | * - `result.cleanup()` - calls the cleanup function of the hook/primitive 151 | */ 152 | export function renderHook( 153 | hook: (...args: A) => R, 154 | options?: RenderHookOptions 155 | ): RenderHookResult { 156 | const initialProps: A | [] = Array.isArray(options) ? options : options?.initialProps || []; 157 | const [dispose, owner, result] = createRoot(dispose => { 158 | if ( 159 | typeof options === "object" && 160 | "wrapper" in options && 161 | typeof options.wrapper === "function" 162 | ) { 163 | let result: ReturnType; 164 | options.wrapper({ 165 | get children() { 166 | return createComponent(() => { 167 | result = hook(...(initialProps as A)); 168 | return null; 169 | }, {}); 170 | } 171 | }); 172 | return [dispose, getOwner(), result!]; 173 | } 174 | return [dispose, getOwner(), hook(...(initialProps as A))]; 175 | }); 176 | 177 | mountedContainers.add({ dispose }); 178 | 179 | return { result, cleanup: dispose, owner }; 180 | } 181 | 182 | /** 183 | * Applies a directive to a test container 184 | * @param directive {(ref, value: () => unknown)} a reusable custom directive 185 | * @param options {RenderDirectiveOptions} test options 186 | * @returns {RenderDirectiveResult} references and tools to test the directive 187 | * 188 | * ```ts 189 | * const called = vi.fn() 190 | * const { getByText, baseContainer } = render(onClickOutside, { initialValue: called }); 191 | * expect(called).not.toBeCalled(); 192 | * fireEvent.click(baseContainer); 193 | * expect(called).toBeCalled(); 194 | * ``` 195 | * ### Options 196 | * - `options.initialValue` - a value added to the directive 197 | * - `options.targetElement` - the name of a HTML element as a string or a HTMLElement or a function returning a HTMLElement 198 | * - `options.container` - the HTML element which the UI will be rendered into; otherwise a `
` will be created 199 | * - `options.baseElement` - the parent of the container, the default will be `` 200 | * - `options.queries` - custom queries (see https://testing-library.com/docs/queries/about) 201 | * - `options.hydrate` - `true` if you want to test hydration 202 | * - `options.wrapper` - a component that applies a context provider and returns `props.children` 203 | * 204 | * ### Result 205 | * - `result.arg()` - the accessor for the value that the directive receives 206 | * - `result.setArg()` - the setter for the value that the directive receives 207 | * - `result.asFragment()` - returns the HTML fragment as string 208 | * - `result.container` - the container in which the component is rendered 209 | * - `result.baseElement` - the parent of the component 210 | * - `result.debug()` - returns helpful debug output on the console 211 | * - `result.unmount()` - unmounts the component, usually automatically called in cleanup 212 | * - `result.`[queries] - testing library queries, see https://testing-library.com/docs/queries/about) 213 | */ 214 | export function renderDirective( 215 | directive: (ref: E, arg: Accessor) => void, 216 | options?: RenderDirectiveOptions 217 | ): RenderDirectiveResult { 218 | const [arg, setArg] = createSignal(options?.initialValue as U); 219 | return Object.assign( 220 | render(() => { 221 | const targetElement = 222 | (options?.targetElement && 223 | (options.targetElement instanceof HTMLElement 224 | ? options.targetElement 225 | : typeof options.targetElement === "string" 226 | ? document.createElement(options.targetElement) 227 | : typeof options.targetElement === "function" 228 | ? options.targetElement() 229 | : undefined)) || 230 | document.createElement("div"); 231 | onMount(() => directive(targetElement as E, arg as Accessor)); 232 | return targetElement; 233 | }, options), 234 | { arg, setArg } 235 | ); 236 | } 237 | 238 | export function testEffect( 239 | fn: (done: (result: T) => void) => void, 240 | owner?: Owner 241 | ): Promise { 242 | let done: (result: T) => void; 243 | let fail: (error: any) => void; 244 | let promise = new Promise((resolve, reject) => { 245 | done = resolve; 246 | fail = reject; 247 | }); 248 | createRoot(dispose => { 249 | catchError(() => { 250 | fn(result => { 251 | done(result); 252 | dispose(); 253 | }); 254 | }, fail) 255 | }, owner); 256 | return promise 257 | } 258 | 259 | function cleanupAtContainer(ref: Ref) { 260 | const { container, dispose } = ref; 261 | if (typeof dispose === 'function') { 262 | dispose(); 263 | } else { 264 | console.warn('solid-testing-library: dispose is not a function - maybe your tests include multiple solid versions!'); 265 | } 266 | 267 | if (container?.parentNode === document.body) { 268 | document.body.removeChild(container); 269 | } 270 | 271 | mountedContainers.delete(ref); 272 | } 273 | 274 | function cleanup() { 275 | mountedContainers.forEach(cleanupAtContainer); 276 | } 277 | 278 | export * from "@testing-library/dom"; 279 | export { render, cleanup }; 280 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Accessor, Component, JSX, Owner, Setter } from "solid-js"; 2 | import { queries } from "@testing-library/dom"; 3 | import type { Queries, BoundFunctions, prettyFormat } from "@testing-library/dom"; 4 | 5 | export interface Ref { 6 | container?: HTMLElement; 7 | dispose: () => void; 8 | } 9 | 10 | export type Ui = () => JSX.Element; 11 | 12 | export interface Options { 13 | container?: HTMLElement; 14 | baseElement?: HTMLElement; 15 | queries?: Queries & typeof queries; 16 | hydrate?: boolean; 17 | wrapper?: Component<{ children: JSX.Element }>; 18 | readonly location?: string; 19 | } 20 | 21 | export type DebugFn = ( 22 | baseElement?: HTMLElement | HTMLElement[], 23 | maxLength?: number, 24 | options?: prettyFormat.OptionsReceived 25 | ) => void; 26 | 27 | export type Result = BoundFunctions & { 28 | asFragment: () => string; 29 | container: HTMLElement; 30 | baseElement: HTMLElement; 31 | debug: DebugFn; 32 | unmount: () => void; 33 | }; 34 | 35 | export type RenderHookOptions = { 36 | initialProps?: A; 37 | wrapper?: Component<{ children: JSX.Element }>; 38 | } | A; 39 | 40 | export type RenderHookResult = { 41 | result: R; 42 | owner: Owner | null; 43 | cleanup: () => void; 44 | }; 45 | 46 | export type RenderDirectiveOptions = Options & { 47 | initialValue?: A; 48 | targetElement?: Lowercase | E | (() => E); 49 | }; 50 | 51 | export type RenderDirectiveResult = Result & { 52 | arg: Accessor, 53 | setArg: Setter 54 | }; 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "lib": ["es2020", "dom"], 6 | "skipLibCheck": true, 7 | "target": "es2020", 8 | "newLine": "LF", 9 | "moduleResolution": "node", 10 | "strict": true, 11 | "jsx": "preserve", 12 | "jsxImportSource": "solid-js", 13 | "outDir": "./dist", 14 | "module": "commonjs" 15 | }, 16 | "include": ["src/index.ts", "src/types.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import solidPlugin from "vite-plugin-solid"; 3 | 4 | export default defineConfig({ 5 | plugins: [solidPlugin()], 6 | test: { 7 | coverage: { 8 | reporter: ["lcov", "text"], 9 | include: ["src/index.ts"], 10 | exclude: ["src/types.ts"] 11 | }, 12 | watch: false, 13 | globals: true, 14 | clearMocks: true, 15 | include: "src/__tests__/*.tsx" 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------