├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public └── index.html ├── rollup.config.js ├── rollup.test.config.js ├── scripts └── test ├── spec ├── CallbackComponent.spec.js ├── HelloComponent.spec.js ├── Menu.spec.js ├── Post.spec.js ├── StaticComponent.spec.js ├── components │ └── IsolatedMenuItem.svelte ├── stores │ ├── price.spec.js │ └── user.spec.js └── support │ ├── jasmine.json │ └── svelte.js └── src ├── CallbackComponent.svelte ├── HelloComponent.svelte ├── Menu.svelte ├── MenuItem.svelte ├── Post.svelte ├── StaticComponent.svelte ├── TagList.svelte ├── api.js ├── main.js └── stores ├── price.js └── user.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Daniel Irvine 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 | # svele-testing-demo 2 | 3 | Demonstrations some techniques for unit testing with Svelte. 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-testing-demo", 3 | "version": "1.0.0", 4 | "description": "A demo of Svelte 3 testing techniques", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "rollup -c", 8 | "start": "serve public", 9 | "dev": "rollup -c -w", 10 | "test": "scripty" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/dirv/svelte-testing-demo.git" 15 | }, 16 | "keywords": [ 17 | "svelte", 18 | "testing", 19 | "test", 20 | "unit", 21 | "testing", 22 | "tdd" 23 | ], 24 | "author": "Daniel Irvine", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/dirv/svelte-testing-demo/issues" 28 | }, 29 | "homepage": "https://github.com/dirv/svelte-testing-demo#readme", 30 | "devDependencies": { 31 | "@babel/core": "^7.11.6", 32 | "@rollup/plugin-babel": "^5.2.1", 33 | "@rollup/plugin-commonjs": "^15.1.0", 34 | "@rollup/plugin-multi-entry": "^4.0.0", 35 | "@rollup/plugin-node-resolve": "^9.0.0", 36 | "babel-plugin-rewire-exports": "^2.2.0", 37 | "jasmine": "^3.6.1", 38 | "jsdom": "^16.4.0", 39 | "rollup": "^2.28.2", 40 | "rollup-plugin-livereload": "^2.0.0", 41 | "rollup-plugin-svelte": "^7.1.0", 42 | "rollup-plugin-terser": "^7.0.2", 43 | "scripty": "^2.0.0", 44 | "serve": "^11.3.2", 45 | "source-map-support": "^0.5.19", 46 | "svelte": "^3.31.2", 47 | "svelte-component-double": "^0.8.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Svelte Testing Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import svelte from "rollup-plugin-svelte"; 4 | import livereload from "rollup-plugin-livereload"; 5 | import { terser } from "rollup-plugin-terser"; 6 | 7 | const production = !process.env.ROLLUP_WATCH; 8 | 9 | const plugins = [ 10 | svelte({ 11 | dev: !production, 12 | css: css => { 13 | css.write("public/build/bundle.css"); 14 | } 15 | }), 16 | resolve({ 17 | browser: true, 18 | dedupe: importee => importee === "svelte" || importee.startsWith("svelte/") 19 | }), 20 | commonjs(), 21 | !production && serve(), 22 | !production && livereload("build"), 23 | production && terser() 24 | ] 25 | 26 | export default { 27 | input: "src/main.js", 28 | output: { 29 | sourcemap: true, 30 | format: "iife", 31 | name: "app", 32 | file: "public/build/bundle-index.js" 33 | }, 34 | plugins, 35 | watch: { 36 | clearScreen: false 37 | } 38 | }; 39 | 40 | function serve() { 41 | let started = false; 42 | 43 | return { 44 | writeBundle() { 45 | if (!started) { 46 | started = true; 47 | 48 | require("child_process").spawn("npm", ["run", "start"], { 49 | stdio: ["ignore", "inherit", "inherit"], 50 | shell: true 51 | }); 52 | } 53 | } 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /rollup.test.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import multi from "@rollup/plugin-multi-entry"; 3 | import svelte from "rollup-plugin-svelte"; 4 | import babel from "@rollup/plugin-babel"; 5 | 6 | export default { 7 | input: "spec/**/*.spec.js", 8 | output: { 9 | sourcemap: true, 10 | format: "cjs", 11 | name: "tests", 12 | file: "build/bundle-tests.js" 13 | }, 14 | plugins: [ 15 | multi(), 16 | svelte({ css: false }), 17 | commonjs(), 18 | babel({ 19 | babelHelpers: "bundled", 20 | extensions: [".js", ".svelte"], 21 | plugins: ["rewire-exports"] 22 | }) 23 | ], 24 | onwarn (warning, warn) { 25 | if (warning.code === "UNRESOLVED_IMPORT") return; 26 | warn(warning); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ -z $1 ]; then 4 | npx rollup -c rollup.test.config.js 5 | else 6 | npx rollup -c rollup.test.config.js -i $1 7 | fi 8 | 9 | if [ $? == 0 ]; then 10 | npx jasmine 11 | fi 12 | -------------------------------------------------------------------------------- /spec/CallbackComponent.spec.js: -------------------------------------------------------------------------------- 1 | import { mount, asSvelteComponent } from "./support/svelte.js"; 2 | import { tick } from "svelte"; 3 | import { 4 | price, 5 | fetch as fetchPrice, 6 | rewire$fetch, 7 | restore 8 | } from "../src/stores/price.js"; 9 | import CallbackComponent from "../src/CallbackComponent.svelte"; 10 | 11 | describe(CallbackComponent.name, () => { 12 | asSvelteComponent(); 13 | 14 | beforeEach(() => { 15 | rewire$fetch(jasmine.createSpy()); 16 | }); 17 | 18 | afterEach(() => { 19 | restore(); 20 | }); 21 | 22 | it("displays the initial price", () => { 23 | price.set(99.99); 24 | mount(CallbackComponent); 25 | expect(container.textContent).toContain("The price is: $99.99"); 26 | }); 27 | 28 | it("updates when the price changes", async () => { 29 | mount(CallbackComponent); 30 | price.set(123.45); 31 | await tick(); 32 | expect(container.textContent).toContain("The price is: $123.45"); 33 | }); 34 | 35 | it("fetches prices on mount", () => { 36 | mount(CallbackComponent); 37 | expect(fetchPrice).toHaveBeenCalled(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /spec/HelloComponent.spec.js: -------------------------------------------------------------------------------- 1 | import HelloComponent from "../src/HelloComponent.svelte"; 2 | 3 | describe(HelloComponent.name, () => { 4 | it("can be instantiated", () => { 5 | new HelloComponent({}); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /spec/Menu.spec.js: -------------------------------------------------------------------------------- 1 | import { tick } from "svelte"; 2 | import { mount, asSvelteComponent } from "./support/svelte.js"; 3 | import Menu from "../src/Menu.svelte"; 4 | import IsolatedMenuItem from "./components/IsolatedMenuItem.svelte"; 5 | 6 | const menuIcon = () => container.querySelector(".icon"); 7 | const menuBox = () => container.querySelector("div[class*=overlay]"); 8 | 9 | const click = async formElement => { 10 | const evt = document.createEvent("MouseEvents"); 11 | evt.initEvent("click", true, true); 12 | formElement.dispatchEvent(evt); 13 | await tick(); 14 | return evt; 15 | }; 16 | 17 | describe(Menu.name, () => { 18 | asSvelteComponent(); 19 | 20 | it("closes the menu when a menu item is selected", async () => { 21 | mount(IsolatedMenuItem); 22 | await click(menuIcon()); 23 | await click(menuBox().querySelector("button")); 24 | expect(menuBox()).toBe(null); 25 | }); 26 | 27 | it("performs action when menu item chosen", async () => { 28 | const action = jasmine.createSpy(); 29 | mount(IsolatedMenuItem, { spy: action }); 30 | await click(menuIcon()); 31 | await click(menuBox().querySelector("button")); 32 | expect(action).toHaveBeenCalled(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /spec/Post.spec.js: -------------------------------------------------------------------------------- 1 | import Post from "../src/Post.svelte"; 2 | import { mount, asSvelteComponent } from "./support/svelte.js"; 3 | import 4 | TagList, { 5 | rewire as rewire$TagList, 6 | restore as restore$TagList } from "../src/TagList.svelte"; 7 | import { 8 | savePost, 9 | rewire$savePost, 10 | restore as restore$api } from "../src/api.js"; 11 | import { componentDouble } from "svelte-component-double"; 12 | import { registerDoubleMatchers } from "svelte-component-double/matchers/jasmine.js"; 13 | import { tick } from "svelte"; 14 | 15 | describe(Post.name, () => { 16 | asSvelteComponent(); 17 | beforeEach(registerDoubleMatchers); 18 | 19 | beforeEach(() => { 20 | rewire$TagList(componentDouble(TagList)); 21 | rewire$savePost(jasmine.createSpy()); 22 | }); 23 | 24 | afterEach(() => { 25 | restore$TagList(); 26 | restore$api(); 27 | }); 28 | 29 | it("renders a TagList with tags prop", () => { 30 | mount(Post, { tags: ["a", "b", "c" ] }); 31 | 32 | expect(TagList).toBeRenderedWithProps({ tags: [ "a", "b", "c" ] }); 33 | }); 34 | 35 | it("saves post when TagList updates tags", async () => { 36 | const component = mount(Post, { tags: [] }); 37 | 38 | TagList.firstInstance().updateBoundValue( 39 | component, "tags", ["a", "b", "c" ]); 40 | await tick(); 41 | expect(savePost).toHaveBeenCalledWith({ tags: ["a", "b", "c"], content: "" }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /spec/StaticComponent.spec.js: -------------------------------------------------------------------------------- 1 | import { mount, asSvelteComponent } from "./support/svelte.js"; 2 | import StaticComponent from "../src/StaticComponent.svelte"; 3 | 4 | describe(StaticComponent.name, () => { 5 | asSvelteComponent(); 6 | 7 | const element = selector => container.querySelector(selector); 8 | const elements = selector => 9 | Array.from(container.querySelectorAll(selector)); 10 | 11 | it("renders a button", () => { 12 | mount(StaticComponent); 13 | expect(container).toMatchSelector("button"); 14 | }); 15 | 16 | it("renders a default name of human if no 'who' prop passed", () => { 17 | mount(StaticComponent); 18 | expect(element("button").textContent).toEqual("Click me, human!"); 19 | }); 20 | 21 | it("renders the passed 'who' prop in the button caption", () => { 22 | mount(StaticComponent, { who: "Daniel" }); 23 | expect(element("button").textContent).toEqual("Click me, Daniel!"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /spec/components/IsolatedMenuItem.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | Spy 10 | 11 | -------------------------------------------------------------------------------- /spec/stores/price.spec.js: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | import { tick } from "svelte"; 3 | import { fetch, price, reset as resetPrice } from "../../src/stores/price.js"; 4 | 5 | const fetchOkResponse = data => 6 | Promise.resolve({ ok: true, json: () => Promise.resolve(data) }); 7 | 8 | describe(fetch.name, () => { 9 | beforeEach(() => { 10 | global.window = {}; 11 | global.window.fetch = () => ({}); 12 | spyOn(window, "fetch") 13 | .and.returnValue(fetchOkResponse({ price: 99.99 })); 14 | resetPrice(); 15 | }); 16 | 17 | it("makes a GET request to /price", () => { 18 | fetch(); 19 | expect(window.fetch).toHaveBeenCalledWith("/price", { method: "GET" }); 20 | }); 21 | 22 | it("sets the price when API returned", async () => { 23 | fetch(); 24 | await tick(); 25 | await tick(); 26 | expect(get(price)).toEqual(99.99); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /spec/stores/user.spec.js: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | import { user, loadUser, login } from "../../src/stores/user.js"; 3 | import { setupGlobalJsdom } from "../support/svelte.js"; 4 | 5 | describe("user store", () => { 6 | beforeEach(() => setupGlobalJsdom()); 7 | 8 | let getItemSpy, setItemSpy; 9 | 10 | beforeEach(() => { 11 | getItemSpy = jasmine 12 | .createSpy() 13 | .and.returnValue('{ "username": "dan" }'); 14 | 15 | setItemSpy = jasmine.createSpy(); 16 | 17 | Object.defineProperty(window, "localStorage", { 18 | writable: true, 19 | value: { 20 | getItem: getItemSpy, 21 | setItem: setItemSpy 22 | } 23 | }); 24 | }); 25 | 26 | describe(loadUser.name, () => { 27 | 28 | it("retrieves the value from localStorage", () => { 29 | loadUser(); 30 | expect(window.localStorage.getItem).toHaveBeenCalledWith("user"); 31 | }); 32 | 33 | it("sets the user", () => { 34 | loadUser(); 35 | expect(get(user)).toEqual({ "username": "dan" }); 36 | }); 37 | }); 38 | 39 | describe(login.name, () => { 40 | it("saves the user information to localStorage", () => { 41 | login("dan"); 42 | expect(setItemSpy).toHaveBeenCalledWith( 43 | "user", 44 | JSON.stringify({ username:"dan" })); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": ".", 3 | "spec_files": [ 4 | "build/bundle-tests.js" 5 | ], 6 | "helpers": [ 7 | "node_modules/source-map-support/register.js" 8 | ], 9 | "random": false 10 | } 11 | -------------------------------------------------------------------------------- /spec/support/svelte.js: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom"; 2 | import { bind, binding_callbacks } from "svelte/internal"; 3 | 4 | let mountedComponents; 5 | 6 | export const setupGlobalJsdom = (url = "https://localhost") => { 7 | const dom = new JSDOM("", { url, pretendToBeVisual: true }); 8 | global.document = dom.window.document; 9 | global.window = { ...global.window, ...dom.window }; 10 | global.navigator = dom.window.navigator; 11 | }; 12 | 13 | const createContainer = () => { 14 | global.container = document.createElement("div"); 15 | document.body.appendChild(container); 16 | }; 17 | 18 | export const setDomDocument = url => { 19 | setupGlobalJsdom(url); 20 | createContainer(); 21 | mountedComponents = []; 22 | }; 23 | 24 | const setBindingCallbacks = (bindings, component) => 25 | Object.keys(bindings).forEach(binding => { 26 | binding_callbacks.push(() => { 27 | bind(mounted, binding, value => { 28 | bindings[binding] = value 29 | }); 30 | }); 31 | }); 32 | 33 | export const mount = (component, props = {}, { bindings = {} } = {}) => { 34 | const mounted = new component({ 35 | target: global.container, 36 | props 37 | }); 38 | setBindingCallbacks(bindings, mounted); 39 | mountedComponents = [ mounted, ...mountedComponents ]; 40 | return mounted; 41 | }; 42 | 43 | export const unmountAll = () => { 44 | mountedComponents.forEach(component => { 45 | component.$destroy() 46 | }); 47 | mountedComponents = []; 48 | }; 49 | 50 | const toMatchSelector = (util, customEqualityTesters) => ({ 51 | compare: (container, selector) => { 52 | if (container.querySelector(selector) === null) { 53 | return { 54 | pass: false, 55 | message: `Expected container to match CSS selector "${selector}" but it did not.` 56 | } 57 | } else { 58 | return { 59 | pass: true, 60 | message: `Expected container not to match CSS selector "${selector}" but it did.` 61 | } 62 | } 63 | } 64 | }); 65 | 66 | export const asSvelteComponent = () => { 67 | beforeEach(() => setDomDocument()); 68 | beforeAll(() => { 69 | jasmine.addMatchers({ toMatchSelector }); 70 | }); 71 | afterEach(unmountAll); 72 | }; 73 | 74 | -------------------------------------------------------------------------------- /src/CallbackComponent.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |

The price is: ${$price}

10 | -------------------------------------------------------------------------------- /src/HelloComponent.svelte: -------------------------------------------------------------------------------- 1 |

Hello, world!

2 | -------------------------------------------------------------------------------- /src/Menu.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | 17 | 18 | {#if open} 19 |
20 | 21 |
22 | {/if} 23 | -------------------------------------------------------------------------------- /src/MenuItem.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /src/Post.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |