├── .babelrc
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── examples
└── simple
│ ├── README.md
│ ├── package.json
│ ├── public
│ └── index.html
│ ├── src
│ ├── App.js
│ ├── index.css
│ └── index.js
│ └── yarn.lock
├── package.json
├── rollup.config.js
├── src
├── Experiment.jsx
├── Variant.jsx
├── index.js
├── tests
│ └── Experiment.test.jsx
└── utils
│ ├── getUserIdentifier.js
│ ├── getWeight.js
│ └── weightedRandom.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "esmodules": false
8 | }
9 | }
10 | ],
11 | "@babel/preset-react"
12 | ],
13 | "plugins": [
14 | [
15 | "@babel/plugin-transform-runtime",
16 | {
17 | "regenerator": true
18 | }
19 | ],
20 | "@babel/plugin-syntax-dynamic-import",
21 | "@babel/plugin-syntax-import-meta",
22 | [
23 | "@babel/plugin-proposal-decorators",
24 | {
25 | "legacy": true
26 | }
27 | ],
28 | "@babel/plugin-proposal-class-properties",
29 | "@babel/plugin-proposal-json-strings",
30 | "@babel/plugin-proposal-function-sent",
31 | "@babel/plugin-proposal-export-namespace-from",
32 | "@babel/plugin-proposal-numeric-separator",
33 | "@babel/plugin-proposal-throw-expressions",
34 | "@babel/plugin-proposal-export-default-from",
35 | "@babel/plugin-proposal-logical-assignment-operators",
36 | "@babel/plugin-proposal-optional-chaining",
37 | [
38 | "@babel/plugin-proposal-pipeline-operator",
39 | {
40 | "proposal": "minimal"
41 | }
42 | ],
43 | "@babel/plugin-proposal-nullish-coalescing-operator",
44 | "@babel/plugin-proposal-do-expressions",
45 | "@babel/plugin-proposal-function-bind"
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": [
4 | "eslint-config-standard",
5 | "plugin:react/recommended"
6 | ],
7 | "env": {
8 | "es6": true
9 | },
10 | "plugins": [
11 | "react"
12 | ],
13 | "parserOptions": {
14 | "sourceType": "module"
15 | },
16 | "rules": {
17 | "space-before-function-paren": 0,
18 | "react/jsx-boolean-value": 0,
19 | "no-multiple-empty-lines": [
20 | "error",
21 | {
22 | "max": 2
23 | }
24 | ],
25 | "comma-dangle": [
26 | "error",
27 | "only-multiline"
28 | ],
29 | "jsx-quotes": [
30 | "error",
31 | "prefer-double"
32 | ],
33 | "react/no-unescaped-entities": [
34 | "error",
35 | {
36 | "forbid": [
37 | ">",
38 | "}"
39 | ]
40 | }
41 | ]
42 | },
43 | "settings": {
44 | "react": {
45 | "version": "16.0"
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 |
4 | # builds
5 | build
6 | dist
7 |
8 | # misc
9 | .DS_Store
10 | .env
11 | .env.local
12 | .env.development.local
13 | .env.test.local
14 | .env.production.local
15 |
16 | npm-debug.log*
17 | yarn-debug.log*
18 | yarn-error.log*
19 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 9
4 | - 8
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Michael Sulyak
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 | # react-split-testing
2 |
3 | [](https://www.npmjs.com/package/react-split-testing) [](https://scrutinizer-ci.com/g/expert-m/react-split-testing/?branch=master) [](https://scrutinizer-ci.com/g/expert-m/react-split-testing/build-status/master) [](https://github.com/expert-m/react-split-testing/issues) [](https://gitter.im/expert_m/react-split-testing) [](https://opensource.org/licenses/MIT)
4 |
5 | > A/B testing, also called split testing, is an experiment where two (or more) variants of a webpage are shown to users at random, and is used as a means of determining which variant leads to a better performance. Once a variant wins, it is then shown to all users.
6 |
7 | Wrap components in [``](#variant-) and nest in [``](#experiment-). A variant is chosen randomly and saved to local storage.
8 |
9 | ```js
10 |
11 |
12 | Version A
13 |
14 |
15 | Version B
16 |
17 |
18 | ```
19 |
20 | #### [Example](https://github.com/expert-m/react-split-testing/tree/master/examples/simple) ([Demo](https://expert-m.github.io/react-split-testing/))
21 |
22 | ## Table Of Contents
23 | - [Installation](#installation)
24 | - [npm](#npm)
25 | - [yarn](#yarn)
26 | - [Usage](#usage)
27 | - [Server Rendering](#server-rendering)
28 | - [API Reference](#api-reference)
29 | - [``](#experiment-)
30 | - [``](#variant-)
31 | - [License](#license)
32 |
33 | ## Installation
34 |
35 | #### npm
36 | ```bash
37 | npm install react-split-testing
38 | ```
39 |
40 | #### yarn
41 | ```bash
42 | yarn add react-split-testing
43 | ```
44 |
45 | ## Usage
46 |
47 | ```jsx
48 | import { Experiment, Variant } from 'react-split-testing'
49 |
50 | class App extends Component {
51 | render() {
52 | return (
53 | console.log(experimentName, variantName)}
57 | >
58 |
59 | Section A
60 |
61 |
62 | Section B
63 |
64 |
65 | )
66 | }
67 | }
68 | ```
69 |
70 | [back to top](#table-of-contents)
71 |
72 | ---
73 |
74 | ### Server Rendering
75 | A [``](#experiment-) with a `userIdentifier` property will choose a consistent [``](#variant-) suitable for server side rendering.
76 |
77 | #### Example
78 |
79 | ```jsx
80 | import { Experiment, Variant } from 'react-split-testing'
81 |
82 | class App extends Component {
83 | render() {
84 | return (
85 |
86 |
87 | Section A
88 |
89 |
90 | Section B
91 |
92 |
93 | )
94 | }
95 | }
96 | ```
97 |
98 | [back to top](#table-of-contents)
99 |
100 | ---
101 |
102 | ## API Reference
103 |
104 | ### ``
105 |
106 | Experiment container component. Children must be of type [`Variant`](#variant-).
107 |
108 | * **Properties:**
109 | * `name` - Name of the experiment.
110 | * **Required**
111 | * **Type:** `string`
112 | * **Example:** `"My Example"`
113 | * `userIdentifier` - Distinct user identifier. Useful for [server side rendering](#server-rendering).
114 | * **Optional**
115 | * **Type:** `string`
116 | * **Example:** `"lMMaTqA8ODe7c36hhf2N"`
117 | * `variantName` - Name of the variant. When defined, this value is used to choose a variant. This property may be useful for [server side rendering](#server-rendering).
118 | * **Optional**
119 | * **Type:** `string`
120 | * **Example:** `"A"`
121 | * `onChoice` - Called when a [`Variant`](#variant-) has been chosen for your [`Experiment`](#experiment-).
122 | * **Optional**
123 | * **Type:** `function`
124 | * **Example:** `(experimentName, variantName) => { console.log(experimentName, variantName) }`
125 | * `onRawChoice` - Same as `onChoice`, but the objects passed are React component instances.
126 | * **Optional**
127 | * **Type:** `function`
128 | * **Example:** `(experiment, variant) => { console.log(experimentName.getName(), variant.props.name) }`
129 |
130 | * **Methods:**
131 | * `getName()` - Returns the [`Experiment`](#experiment-) name.
132 | * `getActiveVariant()` - Returns the currently displayed [`Variant`](#variant-).
133 | * `getActiveVariantName()` - Returns the currently displayed [`Variant`](#variant-) name.
134 | * `getVariant(variantName)` - Returns the instance of the specified [`Variant`](#variant-) name.
135 |
136 | [back to top](#table-of-contents)
137 |
138 | ---
139 |
140 | ### ``
141 |
142 | Variant container component.
143 |
144 | * **Properties:**
145 | * `name` - Name of the variant.
146 | * **Required**
147 | * **Type:** `string`
148 | * **Example:** `"A"`
149 | * `weight` - The variants will be chosen according to the ratio of the numbers, for example variants `A`, `B`, `C` with weights `1`, `2`, `2` will be chosen 20%, 40%, and 40% of the time, respectively.
150 | * **Optional**
151 | * **Type:** `number`
152 | * **Default:** `1`
153 | * **Example:** `2`
154 |
155 | * **Methods:**
156 | * `getName()` - Returns the [`Variant`](#variant-) name.
157 | * `getWeight()` - Returns the [`Variant`](#variant-) weight.
158 |
159 | [back to top](#table-of-contents)
160 |
161 | ---
162 |
163 | ## License
164 | MIT
165 |
--------------------------------------------------------------------------------
/examples/simple/README.md:
--------------------------------------------------------------------------------
1 | # Example
2 |
3 | ## Start
4 | ```bash
5 | yarn start
6 | ```
7 |
8 | #### [back to react-split-testing](https://github.com/expert-m/react-split-testing)
9 |
--------------------------------------------------------------------------------
/examples/simple/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-split-testing-example",
3 | "homepage": "https://expert-m.github.io/react-split-testing",
4 | "version": "0.0.0",
5 | "license": "MIT",
6 | "private": true,
7 | "scripts": {
8 | "start": "react-scripts start",
9 | "build": "react-scripts build",
10 | "test": "react-scripts test --env=jsdom",
11 | "eject": "react-scripts eject"
12 | },
13 | "dependencies": {
14 | "prop-types": "^15.6.2",
15 | "react": "^16.6.3",
16 | "react-dom": "^16.6.3",
17 | "react-scripts": "^3.0.1",
18 | "react-split-testing": "link:../.."
19 | },
20 | "browserslist": [
21 | ">0.2%",
22 | "not dead",
23 | "not ie <= 11",
24 | "not op_mini all"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/simple/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | react-split-testing-example
6 |
7 |
8 |
9 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/examples/simple/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Experiment, Variant } from 'react-split-testing'
3 |
4 |
5 | export default class App extends Component {
6 | constructor(props) {
7 | super(props)
8 |
9 | this.experiment = React.createRef()
10 |
11 | this.onClick = this.onClick.bind(this)
12 | this.onChoice = this.onChoice.bind(this)
13 | this.onRawChoice = this.onRawChoice.bind(this)
14 | }
15 |
16 | onClick() {
17 | const experiment = this.experiment.current
18 |
19 | alert(
20 | `Experiment name: "${experiment.getName()}"\n` +
21 | `Variant name: "${experiment.getActiveVariantName()}"`
22 | )
23 | }
24 |
25 | onChoice(experimentName, variantName) {
26 | console.log(
27 | `Experiment name: "${experimentName}"\n` +
28 | `Variant name: "${variantName}"`
29 | )
30 | }
31 |
32 | onRawChoice(experiment, variant) {
33 | console.log(
34 | `Experiment name: "${experiment.getName()}"\n` +
35 | `Variant name: "${variant.props.name}"\n` +
36 | `Variant weight: ${variant.props.weight}`
37 | )
38 | }
39 |
40 | render() {
41 | return (
42 |
43 |
44 |
Experiment #1
45 |
50 |
51 | Section A
52 |
53 |
54 |
55 | Section B
56 |
57 |
58 |
59 | Section C
60 |
61 |
62 |
63 |
64 |
65 |
66 |
Experiment #2
67 |
(userIdentifier="something")
68 |
73 |
74 | Section A
75 |
76 |
77 | Section B
78 |
79 |
80 |
81 |
82 |
83 |
Experiment #3
84 |
(variantName="B")
85 |
86 |
87 | Section A
88 |
89 |
90 | Section B
91 |
92 |
93 |
94 |
95 |
GitHub
101 |
102 | )
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/examples/simple/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | }
5 |
6 | body {
7 | font-family: Arial, sans-serif;
8 | background: white;
9 | }
10 |
11 | .App {
12 | text-align: center;
13 | }
14 |
15 | h1 {
16 | font-size: 1.5em;
17 | }
18 |
19 | .block {
20 | background: rgba(0, 0, 0, 0.1);
21 | width: 300px;
22 | border-radius: 10px;
23 | margin: 0 auto;
24 | padding: 10px;
25 | margin-top: 20px;
26 | }
27 |
28 | span {
29 | color: grey;
30 | margin-bottom: 10px;
31 | display: block;
32 | }
33 |
34 | button {
35 | outline: none;
36 | border: none;
37 | padding: 5px 10px;
38 | background: mediumblue;
39 | color: white;
40 | border-radius: 5px;
41 | margin-top: 10px;
42 | cursor: pointer;
43 | }
44 |
45 | .link {
46 | margin-top: 20px;
47 | color: black;
48 | display: block;
49 | }
50 |
--------------------------------------------------------------------------------
/examples/simple/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './App'
4 | import './index.css'
5 |
6 |
7 | ReactDOM.render(, document.getElementById('root'))
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-split-testing",
3 | "version": "1.2.3",
4 | "description": "Simple A/B testing component for React.",
5 | "author": "expert-m",
6 | "license": "MIT",
7 | "repository": "expert-m/react-split-testing",
8 | "main": "dist/index.js",
9 | "module": "dist/index.es.js",
10 | "jsnext:main": "dist/index.es.js",
11 | "scripts": {
12 | "test": "jest --env=jsdom --setupTestFrameworkScriptFile=raf/polyfill",
13 | "test:watch": "jest --watchAll --env=jsdom --setupTestFrameworkScriptFile=raf/polyfill",
14 | "build": "rollup -c",
15 | "start": "rollup -c -w",
16 | "prepare": "yarn run build",
17 | "predeploy": "cd examples/simple && yarn install && yarn run build",
18 | "deploy": "gh-pages -d examples/simple/build"
19 | },
20 | "keywords": [
21 | "react",
22 | "ab",
23 | "a/b",
24 | "experiments",
25 | "testing",
26 | "ssr"
27 | ],
28 | "peerDependencies": {
29 | "prop-types": "^15.5.4",
30 | "react": "^15.0.0 || ^16.0.0"
31 | },
32 | "devDependencies": {
33 | "@babel/cli": "^7.1.0",
34 | "@babel/core": "^7.1.6",
35 | "@babel/node": "^7.0.0",
36 | "@babel/plugin-proposal-class-properties": "^7.0.0",
37 | "@babel/plugin-proposal-decorators": "^7.1.6",
38 | "@babel/plugin-proposal-do-expressions": "^7.0.0",
39 | "@babel/plugin-proposal-export-default-from": "^7.0.0",
40 | "@babel/plugin-proposal-export-namespace-from": "^7.0.0",
41 | "@babel/plugin-proposal-function-bind": "^7.0.0",
42 | "@babel/plugin-proposal-function-sent": "^7.0.0",
43 | "@babel/plugin-proposal-json-strings": "^7.0.0",
44 | "@babel/plugin-proposal-logical-assignment-operators": "^7.0.0",
45 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0",
46 | "@babel/plugin-proposal-numeric-separator": "^7.0.0",
47 | "@babel/plugin-proposal-optional-chaining": "^7.0.0",
48 | "@babel/plugin-proposal-pipeline-operator": "^7.0.0",
49 | "@babel/plugin-proposal-throw-expressions": "^7.0.0",
50 | "@babel/plugin-syntax-dynamic-import": "^7.0.0",
51 | "@babel/plugin-syntax-import-meta": "^7.0.0",
52 | "@babel/plugin-transform-runtime": "^7.1.0",
53 | "@babel/preset-env": "^7.1.6",
54 | "@babel/preset-react": "^7.0.0",
55 | "@babel/register": "^7.0.0",
56 | "@babel/runtime": "^7.0.0",
57 | "babel-eslint": "^10.0.2",
58 | "cross-env": "^5.2.0",
59 | "enzyme": "^3.7.0",
60 | "enzyme-adapter-react-16": "^1.7.0",
61 | "eslint": "^6.1.0",
62 | "eslint-config-standard": "^13.0.1",
63 | "eslint-config-standard-react": "^8.0.0",
64 | "eslint-plugin-import": "^2.14.0",
65 | "eslint-plugin-node": "^9.1.0",
66 | "eslint-plugin-promise": "^4.0.0",
67 | "eslint-plugin-react": "^7.14.3",
68 | "eslint-plugin-standard": "^4.0.0",
69 | "flux-standard-action": "^2.0.3",
70 | "gh-pages": "^2.0.0",
71 | "prop-types": "^15.6.2",
72 | "react": "^16.6.3",
73 | "react-dom": "^16.6.3",
74 | "react-scripts": "^3.0.1",
75 | "rollup": "^1.1.2",
76 | "rollup-plugin-babel": "^4.0.3",
77 | "rollup-plugin-commonjs": "^10.0.0",
78 | "rollup-plugin-node-resolve": "^5.0.1",
79 | "rollup-plugin-peer-deps-external": "^2.2.0",
80 | "rollup-plugin-postcss": "^2.0.0",
81 | "rollup-plugin-url": "^2.0.1"
82 | },
83 | "files": [
84 | "dist"
85 | ]
86 | }
87 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babel from 'rollup-plugin-babel'
2 | import commonjs from 'rollup-plugin-commonjs'
3 | import external from 'rollup-plugin-peer-deps-external'
4 | import postcss from 'rollup-plugin-postcss'
5 | import resolve from 'rollup-plugin-node-resolve'
6 | import url from 'rollup-plugin-url'
7 |
8 | import pkg from './package.json'
9 |
10 |
11 | export default {
12 | input: 'src/index.js',
13 | output: [
14 | {
15 | file: pkg.main,
16 | format: 'cjs',
17 | sourcemap: true,
18 | },
19 | {
20 | file: pkg.module,
21 | format: 'es',
22 | sourcemap: true,
23 | },
24 | ],
25 | plugins: [
26 | external(),
27 | postcss({
28 | modules: true,
29 | }),
30 | url(),
31 | babel({
32 | exclude: 'node_modules/**',
33 | runtimeHelpers: true,
34 | }),
35 | resolve(),
36 | commonjs({
37 | include: 'node_modules/**',
38 | namedExports: {
39 | 'node_modules/react/index.js': ['Component', 'PureComponent', 'Fragment', 'Children', 'createElement'],
40 | },
41 | }),
42 | ],
43 | }
44 |
--------------------------------------------------------------------------------
/src/Experiment.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import weightedRandom from './utils/weightedRandom'
4 | import getUserIdentifier from './utils/getUserIdentifier'
5 | import getWeight from './utils/getWeight'
6 |
7 |
8 | export default class Experiment extends Component {
9 | static propTypes = {
10 | name: PropTypes.string.isRequired,
11 | onChoice: PropTypes.func,
12 | onRawChoice: PropTypes.func,
13 | userIdentifier: PropTypes.oneOfType([
14 | PropTypes.string,
15 | PropTypes.number,
16 | ]),
17 | variantName: PropTypes.string,
18 | }
19 |
20 | constructor(props) {
21 | super(props)
22 |
23 | this.state = {}
24 | this.state.activeVariant = this._chooseVariant(true)
25 | }
26 |
27 | componentDidUpdate(prevProps) {
28 | const { name, children, userIdentifier, variantName } = this.props
29 | const isNewExperiment = (
30 | prevProps.name !== name
31 | || prevProps.userIdentifier !== userIdentifier
32 | || prevProps.variantName !== variantName
33 | )
34 |
35 | if (!isNewExperiment && prevProps.children === children) {
36 | return
37 | }
38 |
39 | this.setState({
40 | activeVariant: this._chooseVariant(prevProps, isNewExperiment),
41 | })
42 | }
43 |
44 | getActiveVariant() {
45 | return this.state.activeVariant
46 | }
47 |
48 | getActiveVariantName() {
49 | return this.state.activeVariant && this.state.activeVariant.props.name
50 | }
51 |
52 | getName() {
53 | return this.props.name
54 | }
55 |
56 | getVariant(variantName) {
57 | const children = React.Children.toArray(this.props.children)
58 |
59 | return children.find(element => (
60 | this._isVariant(element) && element.props.name === variantName
61 | ))
62 | }
63 |
64 | _chooseVariant(isNewExperiment) {
65 | const { variantName, userIdentifier } = this.props
66 |
67 | if (variantName) {
68 | const variant = this.getVariant(variantName)
69 |
70 | if (isNewExperiment && variant) {
71 | this._onChoice(variant)
72 | }
73 |
74 | return variant
75 | }
76 |
77 | const children = React.Children.toArray(this.props.children)
78 | const activeVariantName = this.state.activeVariant && this.state.activeVariant.props.name
79 | const variants = [], weights = []
80 |
81 | for (const element of children) {
82 | if (!this._isVariant(element)) {
83 | continue
84 | }
85 |
86 | if (!isNewExperiment && activeVariantName === element.props.name) {
87 | return element
88 | }
89 |
90 | variants.push(element)
91 | weights.push(getWeight(element.props.weight))
92 | }
93 |
94 | const randomSeed = getUserIdentifier(userIdentifier)
95 | const index = weightedRandom(weights, randomSeed)
96 |
97 | if (index === -1) {
98 | return null
99 | }
100 |
101 | const variant = variants[index]
102 | this._onChoice(variant)
103 |
104 | return variant
105 | }
106 |
107 | _isVariant(element) {
108 | if (!React.isValidElement(element)) {
109 | return false
110 | }
111 |
112 | if (!element.type.isVariant) {
113 | throw new Error('Experiment children must be Variant components.')
114 | }
115 |
116 | return true
117 | }
118 |
119 | _onChoice(variant) {
120 | const { onChoice, onRawChoice, name } = this.props
121 |
122 | if (onChoice instanceof Function) {
123 | onChoice(name, variant.props.name)
124 | }
125 |
126 | if (onRawChoice instanceof Function) {
127 | onRawChoice(this, variant)
128 | }
129 | }
130 |
131 | render() {
132 | return this.state.activeVariant
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/Variant.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import getWeight from './utils/getWeight'
4 |
5 |
6 | export default class Variant extends Component {
7 | static propTypes = {
8 | name: PropTypes.string.isRequired,
9 | weight: PropTypes.oneOfType([
10 | PropTypes.string,
11 | PropTypes.number,
12 | ]),
13 | }
14 |
15 | static isVariant = true
16 |
17 | getName() {
18 | return this.props.name
19 | }
20 |
21 | getWeight() {
22 | return getWeight(this.props.weight)
23 | }
24 |
25 | render() {
26 | return this.props.children
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Experiment from './Experiment.jsx'
2 | import Variant from './Variant.jsx'
3 |
4 |
5 | export {
6 | Experiment,
7 | Variant,
8 | }
9 |
--------------------------------------------------------------------------------
/src/tests/Experiment.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { configure, shallow } from 'enzyme'
3 | import Experiment from '../Experiment'
4 | import Variant from '../Variant'
5 | import Adapter from 'enzyme-adapter-react-16'
6 |
7 |
8 | configure({ adapter: new Adapter() })
9 |
10 |
11 | it('test variantName', () => {
12 | let name = ''
13 |
14 | const wrapper = shallow(
15 | name = `${a} ${b}`}
19 | >
20 |
21 | test1
22 |
23 | test2
24 | ,
25 | )
26 |
27 | expect(name).toEqual('Test B')
28 | expect(wrapper.contains(test1
)).toEqual(false)
29 | expect(wrapper.contains(test2)).toEqual(true)
30 | })
31 |
32 | it('test onChoice', () => {
33 | let name = ''
34 |
35 | const wrapper = shallow(
36 | name = `${a} ${b}`}
39 | userIdentifier={0}
40 | >
41 |
42 | Test A
43 |
44 |
45 | Test B
46 |
47 | ,
48 | )
49 |
50 | expect(wrapper.contains({name}
)).toEqual(true)
51 | })
52 |
53 | it('test onRawChoice', () => {
54 | let name = ''
55 |
56 | const wrapper = shallow(
57 | name = `${a.props.name} ${b.props.name}`}
60 | userIdentifier={0}
61 | >
62 |
63 | Test A
64 |
65 |
66 | Test B
67 |
68 | ,
69 | )
70 |
71 | expect(wrapper.contains({name}
)).toEqual(true)
72 | })
73 |
74 | it('test componentDidUpdate', () => {
75 | let name = ''
76 | const onChoice = (a, b) => name = `${a} ${b}`
77 |
78 | const wrapper = shallow(
79 |
80 |
81 | A
82 |
83 |
84 | B
85 |
86 | ,
87 | )
88 |
89 | // First render
90 | expect(name).toEqual('Test A')
91 | expect(wrapper.contains(A
)).toEqual(true)
92 |
93 | wrapper.setProps({
94 | name: 'Test',
95 | onChoice,
96 | userIdentifier: 25,
97 | })
98 |
99 | // With changed userIdentifier
100 | expect(name).toEqual('Test B')
101 | expect(wrapper.contains(B
)).toEqual(true)
102 |
103 | wrapper.setProps({
104 | name: 'Test2',
105 | onChoice,
106 | userIdentifier: 25,
107 | })
108 |
109 | // With changed name
110 | expect(name).toEqual('Test2 B')
111 | expect(wrapper.contains(B
)).toEqual(true)
112 |
113 | let flag = false
114 | wrapper.setProps({
115 | name: 'Test2',
116 | onChoice: () => flag = true,
117 | userIdentifier: 25,
118 | })
119 |
120 | // Not be called
121 | expect(flag).toEqual(false)
122 | expect(wrapper.contains(B
)).toEqual(true)
123 | })
124 |
125 | it('test weights', () => {
126 | let name = ''
127 |
128 | const wrapper = shallow(
129 | name = `${a} ${b}`}
132 | userIdentifier={24}
133 | >
134 |
135 | A
136 |
137 |
138 | B
139 |
140 | ,
141 | )
142 |
143 | expect(wrapper.contains(B
)).toEqual(true)
144 | })
145 |
146 |
147 | it('test random', () => {
148 | const stats = { 'A': 0, 'B': 0, 'C': 0 }
149 | const onChoice = (a, b) => ++stats[b]
150 |
151 | for (let i = 0; i < 100; i++) {
152 | shallow(
153 |
154 |
155 | A
156 |
157 |
158 | B
159 |
160 |
161 | C
162 |
163 | ,
164 | )
165 | }
166 |
167 | expect(stats).toEqual({ A: 28, B: 55, C: 17 })
168 | })
169 |
170 | it('test empty experiment', () => {
171 | shallow()
172 | })
173 |
174 | it('test empty variant', () => {
175 | shallow()
176 | })
177 |
178 | it('test wrong variantName', () => {
179 | const wrapper = shallow(
180 |
181 | test1
182 | test2
183 | ,
184 | )
185 |
186 | expect(wrapper.contains('test1')).toEqual(false)
187 | expect(wrapper.contains('test2')).toEqual(false)
188 | })
189 |
--------------------------------------------------------------------------------
/src/utils/getUserIdentifier.js:
--------------------------------------------------------------------------------
1 | function generateIdentifier() {
2 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
3 | let text = ''
4 |
5 | for (let i = 0; i < 20; i++) {
6 | text += possible.charAt(Math.floor(Math.random() * possible.length))
7 | }
8 |
9 | return text
10 | }
11 |
12 | export default function getIdentifier(userIdentifier) {
13 | if (userIdentifier || userIdentifier === 0) {
14 | return String(userIdentifier)
15 | }
16 |
17 | if (typeof window === 'undefined' || !('localStorage' in window)) {
18 | return null
19 | }
20 |
21 | const key = '__ab_experiment_identifier__'
22 |
23 | try {
24 | userIdentifier = localStorage.getItem(key)
25 | userIdentifier = userIdentifier && String(userIdentifier)
26 |
27 | if (!userIdentifier) {
28 | userIdentifier = generateIdentifier()
29 | localStorage.setItem(key, userIdentifier)
30 | }
31 | } catch (exception) {
32 | return null
33 | }
34 |
35 | return userIdentifier
36 | }
37 |
--------------------------------------------------------------------------------
/src/utils/getWeight.js:
--------------------------------------------------------------------------------
1 | export default function getWeight(weight) {
2 | return (weight || weight === 0) ? parseFloat(weight) : 1
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/weightedRandom.js:
--------------------------------------------------------------------------------
1 | function getRandom(seed) {
2 | let newSeed = 0
3 |
4 | for (let i = 0; i < seed.length; i++) {
5 | newSeed += seed.charCodeAt(i) * Math.pow(10, i)
6 | }
7 |
8 | const x = Math.sin(newSeed) * 10000
9 | return x - Math.floor(x)
10 | }
11 |
12 |
13 | export default function weightedRandom(weights, randomSeed) { // randomSeed must be a string
14 | if (!randomSeed) {
15 | return -1
16 | }
17 |
18 | let totalWeight = 0
19 |
20 | for (let i = 0; i < weights.length; i++) {
21 | totalWeight += weights[i]
22 | }
23 |
24 | let random = getRandom(randomSeed) * totalWeight
25 |
26 | for (let i = 0; i < weights.length; i++) {
27 | if (random < weights[i]) {
28 | return i
29 | }
30 |
31 | random -= weights[i]
32 | }
33 |
34 | return -1
35 | }
36 |
--------------------------------------------------------------------------------