├── .editorconfig ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── README.md ├── jsconfig.json ├── package-lock.json ├── package.json ├── src ├── app.d.ts ├── app.html ├── lib │ ├── SplitTest.svelte │ ├── index.js │ └── splitTesting.js ├── routes │ ├── +layout.js │ ├── +layout.server.js │ ├── +layout.svelte │ └── +page.svelte ├── static │ └── images │ │ └── movies-and-shows.png └── test │ ├── SplitTest.test.js │ ├── SplitTestTest.svelte │ └── splitTesting.test.js ├── static └── favicon.png ├── svelte.config.js ├── vite.config.js └── vitest.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = tab 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "htmlWhitespaceSensitivity": "css", 7 | "insertPragma": false, 8 | "plugins": ["prettier-plugin-svelte"], 9 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], 10 | "printWidth": 100, 11 | "proseWrap": "preserve", 12 | "quoteProps": "as-needed", 13 | "requirePragma": false, 14 | "semi": false, 15 | "singleQuote": true, 16 | "svelteAllowShorthand": true, 17 | "svelteIndentScriptAndStyle": true, 18 | "svelteSortOrder": "options-scripts-markup-styles", 19 | "svelteStrictMode": false, 20 | "tabWidth": 2, 21 | "trailingComma": "all", 22 | "useTabs": false 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Split Testing 2 | 3 | [![npm version](https://badgen.net/npm/v/svelte-split-testing)](https://www.npmjs.com/package/svelte-split-testing) 4 | [![npm downloads](https://badgen.net/npm/dt/svelte-split-testing)](https://www.npmjs.com/package/svelte-split-testing) 5 | [![bundle size](https://img.shields.io/bundlephobia/minzip/svelte-split-testing)](https://bundlephobia.com/package/svelte-split-testing) 6 | 7 | Run Split Tests (or A/B tests) with the power of Svelte(Kit). Split tests (or A/B tests) allow you to display different features or variants to test their effectiveness. Unfortunately popular options are very pricey, bloated, and don't work SSR. This package attempts to remedy all of that. 8 | 9 | This package works with Svelte and SvelteKit. 10 | 11 | - Works SSR 12 | - Works with or without Kit 13 | - Works with GTM and GA4, or any other analytics solution 14 | - Lightweight 15 | - Free, of course 16 | 17 | **Demo and Docs**: https://svelte-split-testing.playpilot.com/ 18 | 19 | ### Installation 20 | 21 | Install using Yarn or NPM. 22 | ```js 23 | yarn add svelte-split-testing --dev 24 | ``` 25 | ```js 26 | npm install svelte-split-testing --save-dev 27 | ``` 28 | 29 | Include the component in your app. 30 | ```js 31 | import { SplitTest } from "svelte-split-testing" 32 | ``` 33 | ```svelte 34 | ... 35 | ``` 36 | 37 | ### Configuration 38 | 39 | For more configuration refer to the documentation: https://svelte-split-testing.playpilot.com/ 40 | 41 | #### Properties 42 | 43 | This is a list of all configurable properties for each component and function. 44 | 45 | ##### SplitTest 46 | 47 | | Property | Default | Description | 48 | | --- | --- | --- | 49 | | key | 'Some Key' | Key used to identify the current Split Test. This is primary used during analytics tracking. | 50 | | variants | ['Variant A', 'Variant B'] | An array of variant names. Can be as many variants as you like. What the names are is not important, but they show up during analytics tracking. | 51 | | onView | null | Optional function to be passed to track views of the current variant. Replaces the default GTM method. | 52 | 53 | ##### serverGetSplitTestIdentifier 54 | 55 | | Property | Default | Description | 56 | | --- | --- | --- | 57 | | serverCookies | null | Cookies object as served from +layout.server.js during SSR. | 58 | | options | { userIdentifier, cookieName } | Object of configurable options | 59 | | options.userIdentifier | null | An optional user identifier to use as the identifier. This is used to show a user the same split test across different devices, as long as they have the same identifier. If an identifier was already set before the user identifier |was given the original cookie will be used instead. Be aware that this value will be saved in the cookie as a plain string. Do not use any data that you might not want to be public. | 60 | | options.cookieName | 'splitTestIdentifier' | The name of the cookie used to store the split testing identifier. | 61 | 62 | ##### clientGetSplitTestIdentifier 63 | 64 | | Property | Default | Description | 65 | | --- | --- | --- | 66 | | options | { userIdentifier, cookieName } | Object of configurable options | 67 | | options.userIdentifier | null | An optional user identifier to use as the identifier. This is used to show a user the same split test across different devices, as long as they have the same identifier. If an identifier was already set before the user identifier |was given the original cookie will be used instead. Be aware that this value will be saved in the cookie as a plain string. Do not use any data that you might not want to be public. | 68 | | options.cookieName | 'splitTestIdentifier' | The name of the cookie used to store the split testing identifier. | 69 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "resolveJsonModule": true, 7 | "skipLibCheck": true, 8 | "sourceMap": true, 9 | "moduleResolution": "NodeNext" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-split-testing", 3 | "version": "2.0.0", 4 | "engines": { 5 | "node": "20 || >=22" 6 | }, 7 | "scripts": { 8 | "dev": "vite dev", 9 | "build": "vite build && npm run package", 10 | "preview": "vite preview", 11 | "package": "svelte-kit sync && svelte-package && publint", 12 | "prepublishOnly": "npm run package", 13 | "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", 14 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", 15 | "test": "vitest" 16 | }, 17 | "exports": { 18 | ".": { 19 | "types": "./dist/index.d.ts", 20 | "svelte": "./dist/index.js" 21 | } 22 | }, 23 | "files": [ 24 | "dist", 25 | "!dist/**/*.test.*", 26 | "!dist/**/*.spec.*" 27 | ], 28 | "devDependencies": { 29 | "@sveltejs/adapter-auto": "^3.0.0", 30 | "@sveltejs/adapter-cloudflare": "^4.8.0", 31 | "@sveltejs/kit": "^2.5.27", 32 | "@sveltejs/vite-plugin-svelte": "^4.0.0", 33 | "@types/eslint": "^9.6.0", 34 | "@testing-library/svelte": "^5.2.6", 35 | "esm-seedrandom": "^3.0.5", 36 | "eslint": "^9.7.0", 37 | "eslint-config-prettier": "^9.1.0", 38 | "eslint-plugin-svelte": "^2.45.1", 39 | "jsdom": "^22.1.0", 40 | "globals": "^15.0.0", 41 | "prettier": "^3.3.2", 42 | "prettier-plugin-svelte": "^3.2.6", 43 | "publint": "^0.1.9", 44 | "svelte": "^5.1.16", 45 | "svelte-check": "^4.0.0", 46 | "typescript": "^5.5.0", 47 | "uuid": "^9.0.0", 48 | "vite": "^5.4.4", 49 | "vitest": "^2.1.0" 50 | }, 51 | "description": "Run Split Tests (or A/B tests) with the power of Svelte(Kit)", 52 | "keywords": [ 53 | "Split Test", 54 | "Testing", 55 | "A/B Test", 56 | "AB Test" 57 | ], 58 | "svelte": "./dist/index.js", 59 | "types": "./dist/index.d.ts", 60 | "type": "module", 61 | "main": "./dist/index.js", 62 | "license": "MIT", 63 | "repository": { 64 | "type": "git", 65 | "url": "https://github.com/Playpilot/svelte-split-testing" 66 | }, 67 | "homepage": "https://svelte-split-testing.playpilot.com/" 68 | } 69 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 45 | 46 | 47 | 50 | 51 | %sveltekit.head% 52 | 53 | 54 | 55 |
%sveltekit.body%
56 | 57 | 58 | -------------------------------------------------------------------------------- /src/lib/SplitTest.svelte: -------------------------------------------------------------------------------- 1 | 57 | 58 | {@render children?.({ variant, performAction })} 59 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | import SplitTest from "./SplitTest.svelte" 2 | import { serverGetSplitTestIdentifier, clientGetSplitTestIdentifier, getVariant, performSplitTestAction } from "./splitTesting.js" 3 | 4 | export { SplitTest, serverGetSplitTestIdentifier, clientGetSplitTestIdentifier, getVariant, performSplitTestAction } 5 | -------------------------------------------------------------------------------- /src/lib/splitTesting.js: -------------------------------------------------------------------------------- 1 | import { BROWSER } from 'esm-env' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { prng_alea as seedrandom } from 'esm-seedrandom' 4 | 5 | const oneYear = 365 * 864e5 6 | 7 | /** 8 | * Retrieves or generates a split testing identifier for a user, using cookies or an optional user identifier. 9 | * This Identifier will be used for all split tests. 10 | * This is to be used server side only, in a layout.server file. 11 | * 12 | * @param {object} serverCookies - An object representing the cookies as served from the server. 13 | * @param {object} options - Optional parameters. 14 | * @param {string|null} options.userIdentifier - An optional user identifier to use as the identifier. This is used to show a user the same split test across different devices, as long as they have the same identifier. If an identifier was already set before the user identifier was given the original cookie will be used instead. Be aware that this value will be saved in the cookie as a plain string. Do not use any data that you might not want to be public. 15 | * @param {string} options.cookieName - The name of the cookie used to store the split testing identifier. 16 | * @returns {string} The split testing identifier for the user. 17 | */ 18 | export function serverGetSplitTestIdentifier(serverCookies, { userIdentifier = null, cookieName = 'splitTestIdentifier' } = {}) { 19 | let identifier = serverCookies.get(cookieName) 20 | 21 | if (identifier) return identifier 22 | 23 | identifier = userIdentifier || uuidv4() 24 | serverCookies.set(cookieName, identifier, { 25 | expires: new Date(Date.now() + oneYear), 26 | hostOnly: true, 27 | httpOnly: false, 28 | path: '/', 29 | sameSite: false, 30 | secure: false, 31 | }) 32 | 33 | return identifier 34 | } 35 | 36 | /** 37 | * Retrieves or generates a split testing identifier for a user, using cookies or an optional user identifier. 38 | * This Identifier will be used for all split tests. 39 | * This is to be used client side only. 40 | * @param {object} options - Optional parameters. 41 | * @param {string|null} options.userIdentifier - An optional user identifier to use as the identifier. This is used to show a user the same split test across different devices, as long as they have the same identifier. If an identifier was already set before the user identifier was given the original cookie will be used instead. Be aware that this value will be saved in the cookie as a plain string. Do not use any data that you might not want to be public. 42 | * @param {string} options.cookieName - The name of the cookie used to store the split testing identifier. 43 | * @returns {string} The split testing identifier for the user. 44 | */ 45 | export function clientGetSplitTestIdentifier({ userIdentifier = null, cookieName = 'splitTestIdentifier' } = {}) { 46 | if (!BROWSER) return 47 | 48 | let identifier = clientGetSplitTestCookie(cookieName) 49 | 50 | if (identifier) return identifier 51 | 52 | identifier = userIdentifier || uuidv4() 53 | clientSetSplitTestCookie(cookieName, identifier) 54 | 55 | return identifier 56 | } 57 | 58 | function clientSetSplitTestCookie(cookieName, identifier) { 59 | if (!BROWSER) return 60 | 61 | const expires = new Date(Date.now() + oneYear).toUTCString() 62 | 63 | document.cookie = `${cookieName}=${identifier}; expires=${expires}; path=/`; 64 | } 65 | 66 | function clientGetSplitTestCookie(cookieName) { 67 | if (!BROWSER) return 68 | 69 | return document.cookie?.split(';').reduce((r, v) => { 70 | const [name, value] = v?.split('=').map(item => item.trim()) 71 | return name === cookieName ? decodeURIComponent(value) : r.trim() 72 | }, '') 73 | } 74 | 75 | /** 76 | * Generate a random number based on a seed, meaning it will always be 77 | * the same outcome as long as the identifier is the same. 78 | * The key is also included to prevent one user from always seeing test A 79 | * for every test case. 80 | * @param {object} options - Optional parameters. 81 | * @param {string} options.key - Key used to identify the current test 82 | * @param {string[]} options.variants - Array of strings with all possible variants 83 | * @param {string} options.identifier - Identifier used to find the current variants. Either from cookie or from user identifier. 84 | * @param {string} options.force - Force a particular split test by string. 85 | * @returns {string} The variant to be used 86 | */ 87 | export function getVariant({ key, variants = [], identifier, force }) { 88 | const randomized = seedrandom(identifier + key).quick() 89 | const index = Math.floor(randomized * variants.length) 90 | return force && variants.includes(force) ? force : variants[index] 91 | } 92 | 93 | /** 94 | * Perform a split test outside of a component. The variant is generated and returned from this function. It will perform a view action when called. 95 | * @param {object} options - Optional parameters. 96 | * @param {string} options.key - Key used to identify the current test 97 | * @param {string} options.action - Action send to analytics tracking 98 | * @param {string[]} options.variants - Array of strings with all possible variants 99 | * @param {string|null} options.userIdentifier - Optional user identifier to override the cookie identifier 100 | * @param {string} options.force - Force a particular split test by string. 101 | * @param {Function} options.trackingFunction - Function to override the default GTM data layer tracking. `{ action, key, variant }` is passed as the first and only parameter. 102 | * @returns {string} The variant that was used 103 | * @example ` 104 | * const variant = performSplitTestAction({ ... }) 105 | * if (variant === "A") doThingA() 106 | * else if (variant === "B") doThingB() 107 | */ 108 | export function performSplitTestAction({ key = '', action = 'view', variants = [], userIdentifier = null, force = null, trackingFunction = null } = {}) { 109 | const identifier = clientGetSplitTestIdentifier({ userIdentifier }) 110 | const variant = getVariant({ key, variants, identifier, force }) 111 | 112 | typeof trackingFunction === "function" ? trackingFunction({ action, key, variant }) : sendToDataLayer({ action, key, variant }) 113 | 114 | return variant 115 | } 116 | 117 | /** 118 | * Send an event to GTM data layer 119 | * @param {object} options - Parameters. 120 | * @param {string} options.action - Action send to analytics tracking 121 | * @param {string} options.key - Key used to identify the current test 122 | * @param {string} options.variant - The current variant being used 123 | * @example ` 124 | * const variant = performSplitTestAction({ ... }) 125 | * if (variant === "A") doThingA() 126 | * else if (variant === "B") doThingB() 127 | */ 128 | export function sendToDataLayer({ action, key, variant } = {}) { 129 | window.dataLayer = window.dataLayer || [] 130 | window.dataLayer.push({ event: 'Split Test', action, label: key, value: variant }) 131 | } 132 | -------------------------------------------------------------------------------- /src/routes/+layout.js: -------------------------------------------------------------------------------- 1 | export async function load({ data }) { 2 | const { splitTestIdentifier } = data || {} 3 | 4 | return { 5 | splitTestIdentifier, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/+layout.server.js: -------------------------------------------------------------------------------- 1 | import { serverGetSplitTestIdentifier } from '$lib/splitTesting.js' 2 | 3 | /** @type {import('./$types').LayoutServerLoad} */ 4 | export async function load({ cookies }) { 5 | const splitTestIdentifier = serverGetSplitTestIdentifier(cookies) 6 | 7 | return { 8 | splitTestIdentifier, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {@render children?.()} 10 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | Svelte Split Testing — By PlayPilot 27 | 28 |
29 |
30 |

Svelte Split Testing

31 |
32 | 33 |
34 |

35 | Split tests (or A/B tests) allow you to display different features or variants to test their 36 | effectiveness. Unfortunately popular options are very pricey, bloated, and don't work SSR. 37 | This package attempts to remedy all of that. 38 |

39 |

This package works with Svelte and SvelteKit.

40 |

41 | Uses Google Tag Manager by default to keep track of your analytics, but this can be 43 | replaced with other methods. 44 |

45 | 46 | 53 | 54 |
55 | GitHub 57 | NPM 59 |
60 |
61 | 62 |
63 |

Demo

64 | 65 |

66 | What you are doing with your split tests is completely up to you of course. These examples 67 | show some basics of how it all works, but the sky is the limit. 68 |

69 | 70 |

The important part here is that the results are randomized, but consistent for the same user. SSR and CSR show the same result and revisiting the page will show the same result as the visit before. You can even show the same result for the same user across different devices.

71 | 72 | {#key $page.url.searchParams.get('force-split-test')} 73 | 74 | {#snippet children({ variant })} 75 |

You are currently being shown split test {variant}

76 | {/snippet} 77 |
78 | {/key} 79 | 80 |

81 | Split tests are randomized based on a cookie or a given user identifier. You can override this 82 | by adding ?force-split-test=[Variant name] to the url. This is only meant for debugging. 83 |

84 | 85 |
86 | 93 | 94 | {#key $page.url.searchParams.get('force-split-test')} 95 | 96 | {#snippet children({ variant })} 97 | {variant} is showing 98 | {/snippet} 99 | 100 | {/key} 101 |
102 | 103 |

104 | Note: Forcing the variant doesn't work SSR, if you're viewing this page without Javascript 106 | you might not see a difference. Regular split tests do work during SSR. 107 |

108 |

You can include as many variants as you like.

109 | 110 |
111 |
112 | Don't force any test 113 | {#each Object.entries(colors) as [name, value]} 114 | {name} 119 | {/each} 120 |
121 | 122 | {#key $page.url.searchParams.get('force-split-test')} 123 | 124 | {#snippet children({ variant })} 125 | {variant} is showing 126 | {/snippet} 127 | 128 | {/key} 129 |
130 |
131 | 132 |
133 |

Set up

134 | 135 |

Install using Yarn or NPM.

136 | yarn add svelte-split-testing --dev 137 | npm install svelte-split-testing --save-dev 138 | 139 |

The component isn't quite plug-and-play, we will need to set up some details.

140 |
141 | 142 |
143 |

SvelteKit

144 | 145 |

146 | When using SvelteKit we need to make sure the same identifier is set on the server as is set 147 | on the client. 148 |

149 |

150 | In your (main) +layout.server.js load function import and set the identifier and pass 151 | it along. 152 |

153 | 154 |
156 | import { serverGetSplitTestIdentifier } from 'svelte-split-testing/splitTesting'
158 | 
159 | /** @type {import('./$types').LayoutServerLoad} */
160 | export async function load({ cookies }) {
161 |   const splitTestIdentifier = serverGetSplitTestIdentifier(cookies)
164 | 
165 |   return {
166 |     splitTestIdentifier,
167 |   }
168 | }
169 | 
170 | 171 |

Important here is that you pass along the SSR cookies object.

172 |

173 | Next, in your (main) +layout.js load function pass the identifier along again. We 174 | don't actually need to do anything with it just yet. 175 |

176 | 177 |
179 | export async function load({ data }) {
180 |   const { splitTestIdentifier } = data || {}
181 | 
182 |   return {
183 |     splitTestIdentifier,
184 |   }
185 | }
186 | 
187 | 188 |

189 | This is a bit verbose, but this is assuming you have other stuff going on in the file as well. 190 | If you don't, you could simply pass it as return { ...data }. 191 |

192 |

193 | Next, in your (main) +layout.svelte set the identifier using the Context API. Make 194 | sure to use the correct key. 195 |

196 | 197 |
199 | <script>
200 |   import { clientGetSplitTestIdentifier } from 'svelte-split-testing/splitTesting'
202 |   import { setContext } from 'svelte'
203 | 
204 |   export let data
205 | 
206 |   setContext('splitTestIdentifier', data?.splitTestIdentifier)
209 | </script>
210 | 
211 | <slot />
212 | 
213 | 214 |

And that's the basic set up for SvelteKit. Next up will we go in to usage.

215 |
216 | 217 |
218 |

Svelte (without Kit)

219 | 220 |

221 | For Svelte (without Kit) we do not yet need any set up. There might be some set up depending 222 | on your needs, but we will get to that in the Usage section. 223 |

224 |
225 | 226 |
227 |

Usage

228 | 229 | 230 | import { SplitTest } from 231 | 'svelte-split-testing'
232 |
233 | <SplitTest>...</SplitTest> 234 |
235 | 236 |

237 | At it's most basic SplitTest is a wrapper component that takes care of some 238 | business, but for you as the user it's really just a slot. This components has a slot prop 239 | called variant, this will be used by you to determine what is shown in what 240 | variant. 241 |

242 |

243 | When using the component there's 2 important props: key and 244 | variants. 245 |

246 |

247 | key Is the name of the split test. It isn't very important for functionally what this 248 | key is, is doesn't even need to be unique. It's only used to identify what split test is for your 249 | analytics tracking. 250 |

251 |

252 | variants Is an array of strings with each variant you want to include. What the 253 | names are is not important, but this name is what will be used in your analytics tracking. You 254 | could go for something simple like ['A', 'B'], or give them more explicit names 255 | like ['Large Sign Up', 'Small Sign Up'], it's up to you. 256 |

257 |
258 | 259 |
260 |

Variants

261 | 262 |

263 | In the following example we have two variants, each having 50% chance to show. We use the slot 264 | prop variant to determine what to show for each variant. 265 |

266 | 267 |
269 | <SplitTest
270 |   key="Some test"
271 |   variants={['Variant A', 'Variant B']}
274 |   let:variant>
275 | 
276 |   {#if variant === 'Variant A'}
277 |     <button>Do the thing</button>
278 |   {/if}
279 | 
280 |   {#if variant === 'Variant B'}
281 |     <button>Do the thing, but different</button>
282 |   {/if}
283 | </SplitTest>
284 | 
285 | 286 |

287 | You don't have to use if statements, you could use it like any other variable. 288 |

289 | 290 |
292 | <SplitTest
293 |   key="Some test"
294 |   variants={['Variant A', 'Variant B']}
297 |   let:variant>
298 | 
299 |   Current showing variant: {variant}
300 | </SplitTest>
301 | 
302 | 303 |

Using this you could quickly set up the use of different styles, for example.

304 | 305 |
307 | <script>
308 |   const variants = {
309 |     Plain: 'button-plain',
310 |     Colorful: 'button-colorful',
311 |   }
312 | </script>
313 | 
314 | <SplitTest
315 |   key="Some test"
316 |   variants={Object.keys(variants)}
317 |   let:variant>
318 | 
319 |   <button class={variants[variant]}>...</button>
320 | </SplitTest>
321 | 
322 |
323 | 324 |
325 |

Tracking

326 | 327 |

328 | By default the Split Test will use Google Tag Manager, using the data layer, to keep track of 329 | your events. This can be changed to any other method, which will we go in to later. 330 |

331 | 332 |

333 | When a split test is shown an event is send to GTM with the given key as the label, and the 334 | current variant as the value. 335 |

336 | 337 |

338 | To fire an event when a button is clicked, or any other action is performed, you can use the performAction slot prop. This will fire an event to GTM. You can optionally pass a value 340 | to change the event type from 'click' to whatever else. 341 |

342 | 343 |

344 | The data sent looks a little like: { event: 'Split Test', action: [given action], label: [given key], value: [current 346 | variant] }. 348 |

349 | 350 |
352 | <SplitTest
353 |   key="Some test"
354 |   let:variant
355 |   let:performAction>
356 | 
357 |   <button on:click={performAction}>...</button>
358 | </SplitTest>
359 | 
360 | 361 |

362 | Of course you might want to track more than just clicks, for this you can pass any string to 363 | the performAction function. Once again, this is just for tracking purposes, what the 364 | string is doesn't matter. 365 |

366 | 367 |
369 | <SplitTest
370 |   let:variant
371 |   let:performAction>
372 | 
373 |   <form on:submit={() => performAction('form submit')}>
376 |     ...
377 |   </form>
378 | </SplitTest>
379 | 
380 | 381 |

If your action takes place outside of the component you can bind the property instead.

382 | 383 |
385 | <script>
386 |   let performAction
387 | 
388 |   function doTheThing() {
389 |     performAction()
390 |   }
391 | </script>
392 | 
393 | <SplitTest
394 |   let:variant
395 |   bind:performAction>
396 | 
397 |   <form on:submit={doTheThing}>
398 |     ...
399 |   </form>
400 | </SplitTest>
401 | 
402 |
403 | 404 |
405 |

Using different analytics methods

406 | 407 |

408 | If you do not wish to use GTM you can bring your own solution. Simply pass a function to the onView property, and this function will be used instead. This function will be fired 410 | every time the component is mounted in CSR. It will not fire during SSR. 411 |

412 | 413 |
415 | <SplitTest onView={(data) => console.log(data)}>...</SplitTest>
417 | 
418 |
420 | <script>
421 |   function customTracking(data) {
422 |     ...
423 |   }
424 | </script>
425 | 
426 | <SplitTest onView={customTracking}>...</SplitTest>
429 | 
430 | 431 |

432 | The data returns an object { key, variant, action: 'view' }. What you 433 | do with this data is up to you. 434 |

435 | 436 |

For example, you could use GA4 using gtag.

437 | 438 |
440 | <script>
441 |   function customTracking(data) {
442 |     gtag('event', 'Split Test', data)
443 |   }
444 | </script>
445 | 
446 | <SplitTest onView={customTracking}>...</SplitTest>
449 | 
450 | 451 |

452 | To track custom events other than views, you can use whatever method you like and use the variant slot prop to determine the current variant. 454 |

455 | 456 |
458 | <script>
459 |   const key = 'Some test'
460 | 
461 |   function trackClick(variant) {
462 |     ...
463 |   }
464 | </script>
465 | 
466 | <SplitTest
467 |   {key}
468 |   variants={["A", "B"]}
469 |   let:variant>
470 | 
471 |   <button on:click={() => trackClick(variant)}>...</button>
472 | </SplitTest>
473 | 
474 |
475 | 476 |
477 |

Other config options

478 | 479 |

serverGetSplitTestIdentifier

480 |

481 | This function is used in the set up to set the identifier for SSR. The second parameter in 482 | this function is an object of options. 483 |

484 |

485 | 486 | serverGetSplitTestIdentifier(servercookies, { userIdentifier, cookieName 487 | }) 488 | 489 |

490 |

491 | userIdentifier is used to pass an identifier to the function that will be used instead 492 | of a random identifier. This way you can make sure a user sees the same page across different devices, 493 | as long as they use the same identifier. Be aware that will identifier will be saved as a plain 494 | string in a cookie. 495 |

496 |

497 | cookieName can be used to change the name of the cookie. Defaults to 498 | splitTestIdentifier 499 |

500 |

501 | If you are setting this as done in the set up, you will need to make sure to pass it along to 502 | the client side as well, using clientGetSplitTestIdentifier with the same options, 503 | as a fallback value. This needs to be done when setting the context (refer back to the Set Up section). 504 | This is a safety net in case the value was not set as expected. 505 |

506 | setContext('splitTestIdentifier', data?.splitTestIdentifier || clientGetSplitTestIdentifier({ userIdentifier, cookieName })) 510 | 511 |

clientGetSplitTestIdentifier

512 |

513 | This used to set the identifier client side, this is optional if you are also using SSR. If 514 | you are using Svelte without Kit, this will be the only function you use. The first and only 515 | parameter is an object of options. 516 |

517 |

518 | 519 | clientGetSplitTestIdentifier({ userIdentifier, cookieName }) 520 | 521 |

522 |

523 | userIdentifier is used to pass an identifier to the function that will be used instead 524 | of a random identifier. This way you can make sure a user sees the same page across different devices, 525 | as long as they use the same identifier. Be aware that will identifier will be saved as a plain 526 | string in a cookie. 527 |

528 |

529 | cookieName can be used to change the name of the cookie. Defaults to 530 | splitTestIdentifier 531 |

532 |
533 | 534 |
535 |

Outside of components

536 | 537 |

In some cases you might want to perform split tests outside of a component, perhaps right inside a javascript file. In that case you can use the performSplitTestAction function.

538 | 539 |

This function will return the current variant. It will perform an action when called, sending it to GTM by default.

540 | 541 |
543 | const variant = performSplitTestAction({
544 |   key: 'Some test key',
545 |   action: 'click',
546 |   variants: ['A', 'B'],
547 |   force,
548 |   trackingFunction: ({ variant }) =>
549 |     alert(`Performed action for variant "${variant}"`)
550 | })
551 | 
552 | if (variant === "A") doThingA()
553 | else if (variant === "B") doThingB()
554 | 
555 | 556 |

557 | 558 |

559 | 560 |

Additionally the slot prop variant can be bound to a variable, allowing it to be re-used for things outside of the component.

561 | 562 |
564 | <script>
565 |   let variant
566 | </script>
567 | 
568 | <SplitTest bind:variant />
569 | 
570 |
571 | 572 |
573 |

Properties

574 | 575 |

This is a list of all configurable properties for each component and function.

576 | 577 |

SplitTest

578 | 579 |
580 | Property Default Description 581 | 582 | key 'Some Key' 583 |
584 | Key used to identify the current Split Test. This is primary used during analytics tracking. 585 |
586 | variants ['Variant A', 'Variant B'] 587 |
588 | An array of variant names. Can be as many variants as you like. What the names are is not 589 | important, but they show up during analytics tracking. 590 |
591 | onView null 592 |
593 | Optional function to be passed to track views of the current variant. Replaces the default 594 | GTM method. 595 |
596 |
597 | 598 |

serverGetSplitTestIdentifier

599 | 600 |
601 | Property Default Description 602 | 603 | serverCookies null 604 |
Cookies object as served from +layout.server.js during SSR.
605 | options { userIdentifier, cookieName } 606 |
Object of configurable options
607 | options.userIdentifier null 608 |
609 | An optional user identifier to use as the identifier. This is used to show a user the same 610 | split test across different devices, as long as they have the same identifier. If an 611 | identifier was already set before the user identifier was given the original cookie will be 612 | used instead. Be aware that this value will be saved in the cookie as a plain string. Do not 613 | use any data that you might not want to be public. 614 |
615 | options.cookieName 'splitTestIdentifier' 616 |
The name of the cookie used to store the split testing identifier.
617 |
618 | 619 |

clientGetSplitTestIdentifier

620 | 621 |
622 | Property Default Description 623 | 624 | options { userIdentifier, cookieName } 625 |
Object of configurable options
626 | options.userIdentifier null 627 |
628 | An optional user identifier to use as the identifier. This is used to show a user the same 629 | split test across different devices, as long as they have the same identifier. If an 630 | identifier was already set before the user identifier was given the original cookie will be 631 | used instead. Be aware that this value will be saved in the cookie as a plain string. Do not 632 | use any data that you might not want to be public. 633 |
634 | options.cookieName 'splitTestIdentifier' 635 |
The name of the cookie used to store the split testing identifier.
636 |
637 | 638 |

performSplitTestAction

639 | 640 |
641 | Property Default Description 642 | 643 | options null
Optional parameters
644 | options.key ''
Key used to identify the current test
645 | options.action 'view'
Action send to analytics tracking
646 | options.variants []
Array of strings with all possible variants
647 | options.userIdentifier null
Optional user identifier to override the cookie identifier
648 | options.force null
Force a particular split test by string.
649 | options.trackingFunction null
Function to override the default GTM data layer tracking. { action, key, variant } is passed as the first and only parameter.
650 |
651 |
652 | 653 |
654 | Developed by PlayPilot 655 |
656 |
657 | 658 | 801 | -------------------------------------------------------------------------------- /src/static/images/movies-and-shows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Playpilot/svelte-split-testing/3a01c0a3969d3962845de1d82f81c89b2501f76a/src/static/images/movies-and-shows.png -------------------------------------------------------------------------------- /src/test/SplitTest.test.js: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/svelte/svelte5' 2 | import { describe, expect, it } from 'vitest' 3 | 4 | import SplitTestTest from './SplitTestTest.svelte' 5 | 6 | describe('SplitTest.svelte', () => { 7 | it('Renders with minimal given props', () => { 8 | const { container } = render(SplitTestTest) 9 | 10 | expect(container).toBeTruthy() 11 | }) 12 | 13 | it('Should show slot for test A with given identifier that would result test A being shown', () => { 14 | // It's coincidental that the identifier "a" gives results for test "a", but I'll take it 15 | const { queryByText } = render(SplitTestTest, { identifier: 'a' }) 16 | 17 | expect(queryByText('Test A')).toBeTruthy() 18 | expect(queryByText('Test B')).not.toBeTruthy() 19 | }) 20 | 21 | it('Should show slot for test B with given identifier that would result test B being shown', () => { 22 | const { queryByText } = render(SplitTestTest, { identifier: '123' }) 23 | 24 | expect(queryByText('Test A')).not.toBeTruthy() 25 | expect(queryByText('Test B')).toBeTruthy() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/test/SplitTestTest.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {#snippet children({ variant })} 13 | {#if variant === 'Variant A'}

Test A

{/if} 14 | {#if variant === 'Variant B'}

Test B

{/if} 15 | {/snippet} 16 |
17 | -------------------------------------------------------------------------------- /src/test/splitTesting.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | 3 | import { performSplitTestAction } from '../lib/splitTesting.js' 4 | 5 | describe('splitTesting.js', () => { 6 | describe('performSplitTestAction', () => { 7 | it('Should return the expected variant', () => { 8 | expect(performSplitTestAction({ key: 'a', variants: ['Variant A', 'Variant B'] })).toBe('Variant A') 9 | }) 10 | 11 | it('Should return the forced variant regardless of key', () => { 12 | expect(performSplitTestAction({ key: 'a', variants: ['Variant A', 'Variant B'], force: 'Variant B' })).toBe('Variant B') 13 | }) 14 | 15 | it('Should call the given function', () => { 16 | const mock = vi.fn() 17 | performSplitTestAction({ trackingFunction: mock }) 18 | expect(mock).toHaveBeenCalled() 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Playpilot/svelte-split-testing/3a01c0a3969d3962845de1d82f81c89b2501f76a/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import cloudflare from '@sveltejs/adapter-cloudflare'; 2 | import adapter from '@sveltejs/adapter-auto'; 3 | 4 | const isProduction = process.env.NODE_ENV === 'production'; 5 | 6 | /** @type {import('@sveltejs/kit').Config} */ 7 | const config = { 8 | kit: { 9 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 10 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 11 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 12 | adapter: isProduction ? cloudflare({ 13 | routes: { 14 | include: ['/*'], 15 | exclude: [''] 16 | } 17 | }) : adapter() 18 | } 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig(({ mode }) => ({ 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}'] 8 | }, 9 | resolve: { 10 | conditions: mode === 'test' ? ['browser'] : [], 11 | } 12 | })); 13 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import { svelte } from '@sveltejs/vite-plugin-svelte' 3 | import { svelteTesting } from '@testing-library/svelte/vite' 4 | import path from 'path' 5 | 6 | export default defineConfig({ 7 | plugins: [svelte({hot: !process.env.VITEST}), svelteTesting()], 8 | resolve: { 9 | alias: { 10 | // these are the aliases and paths available throughout the app 11 | // they are also needed here for scripts outside SvelteKit (e.g. tests) 12 | $lib: path.resolve('./src/lib'), 13 | }, 14 | }, 15 | test: { 16 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 17 | globals: true, 18 | environment: 'jsdom', 19 | }, 20 | }) 21 | --------------------------------------------------------------------------------