├── .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 |
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 |
12 |
13 |
--------------------------------------------------------------------------------
/src/StaticComponent.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/TagList.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
19 |
--------------------------------------------------------------------------------
/src/api.js:
--------------------------------------------------------------------------------
1 | export const savePost = ({ tags, content }) => {
2 | console.log(`Tags: ${tags}`);
3 | console.log(`Content: ${content}`);
4 | };
5 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import HelloComponent from "./HelloComponent.svelte";
2 |
3 | new HelloComponent({ target: document.body });
4 |
--------------------------------------------------------------------------------
/src/stores/price.js:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 |
3 | const initialValue = "";
4 |
5 | export const reset = () => price.set(initialValue);
6 |
7 | export const price = writable(initialValue);
8 |
9 | export const fetch = async () => {
10 | const response = await window.fetch("/price", { method: "GET" });
11 | if (response.ok) {
12 | const data = await response.json();
13 | price.set(data.price);
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/src/stores/user.js:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 |
3 | const notLoaded = {};
4 | const loggedOut = { "username": null };
5 |
6 | const localStorageKey = "user";
7 |
8 | export let user = writable(notLoaded);
9 |
10 | export const loadUser = () => {
11 | const value = window.localStorage.getItem(localStorageKey);
12 | if (value !== null ) {
13 | user.set(JSON.parse(value));
14 | } else {
15 | user.set(loggedOut);
16 | }
17 | };
18 |
19 | export const login = username => {
20 | // do something to login here
21 | // ...
22 | user.set({ username });
23 | // if log in wasn't successful then you would want to do
24 | // user.set(loggedOut);
25 | };
26 |
27 | user.subscribe(newValue => {
28 | // have to check for notLoaded here as subscribe is always called once,
29 | // as soon as it is defined,
30 | // and this would overwrite localStorage before we had a chance to read it
31 | if (global.window && newValue !== notLoaded) {
32 | const json = JSON.stringify(newValue);
33 | window.localStorage.setItem(localStorageKey, json);
34 | }
35 | });
36 |
--------------------------------------------------------------------------------