├── .node-version ├── .npmrc ├── .prettierrc ├── .gitignore ├── .eslintrc ├── .github └── workflows │ └── node.js.yml ├── package.json ├── LICENSE ├── README.md └── src ├── rosie.js └── __tests__ └── rosie.test.js /.node-version: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = "https://registry.npmjs.com/" 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | .idea 3 | *.iml 4 | *.log 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier"], 3 | "plugins": ["jest"], 4 | "parserOptions": { 5 | "ecmaVersion": 2018 6 | }, 7 | "overrides": [ 8 | { 9 | "files": ["**/*.test.js"], 10 | "extends": ["plugin:jest/recommended"], 11 | "env": { 12 | "jest": true 13 | }, 14 | "rules": { 15 | "jest/consistent-test-it": "error" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rosie", 3 | "version": "2.1.1", 4 | "description": "factory for building JavaScript objects, mostly useful for setting up test data. Inspired by factory_girl", 5 | "keywords": [ 6 | "factory", 7 | "rosie", 8 | "test" 9 | ], 10 | "author": "Brandon Keepers ", 11 | "contributors": [ 12 | "Brian Donovan" 13 | ], 14 | "dependencies": {}, 15 | "devDependencies": { 16 | "eslint": "^8.29.0", 17 | "eslint-config-prettier": "^8.5.0", 18 | "eslint-plugin-jest": "^27.1.6", 19 | "jest": "^29.3.1", 20 | "prettier": "^2.8.0", 21 | "prettier-check": "^2.0.0" 22 | }, 23 | "engines": { 24 | "node": ">=10" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git://github.com/rosiejs/rosie.git" 29 | }, 30 | "main": "src/rosie.js", 31 | "scripts": { 32 | "pretest": "eslint src && prettier-check src/rosie.js src/__tests__/rosie.test.js README.md", 33 | "test": "jest", 34 | "watch": "jest --watch" 35 | }, 36 | "files": [ 37 | "src/rosie.js" 38 | ], 39 | "license": "MIT" 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | rosie is a factory for building JavaScript objects 2 | 3 | Copyright © 2012 Brandon Keepers 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rosie 2 | 3 | ![Rosie the Riveter](https://upload.wikimedia.org/wikipedia/commons/thumb/1/12/We_Can_Do_It%21.jpg/220px-We_Can_Do_It%21.jpg) 4 | 5 | Rosie is a factory for building JavaScript objects, mostly useful for setting up test data. It is inspired by [factory_bot](https://github.com/thoughtbot/factory_bot). 6 | 7 | To use Rosie you first define a _factory_. The _factory_ is defined in terms of _attributes_, _sequences_, _options_, _callbacks_, and can inherit from other factories. Once the factory is defined you use it to build _objects_. 8 | 9 | ## Usage 10 | 11 | There are two phases of use: 12 | 13 | 1. Factory definition 14 | 2. Object building 15 | 16 | **Factory Definition:** Define your factory, giving it a name and optionally a constructor function (`game` in this example): 17 | 18 | ```js 19 | Factory.define('game') 20 | .sequence('id') 21 | .attr('is_over', false) 22 | .attr('created_at', () => new Date()) 23 | .attr('random_seed', () => Math.random()) 24 | 25 | // Default to two players. If players were given, fill in 26 | // whatever attributes might be missing. 27 | .attr('players', ['players'], (players) => { 28 | if (!players) { 29 | players = [{}, {}]; 30 | } 31 | return players.map((data) => Factory.attributes('player', data)); 32 | }); 33 | 34 | Factory.define('player') 35 | .sequence('id') 36 | .sequence('name', (i) => { 37 | return 'player' + i; 38 | }) 39 | 40 | // Define `position` to depend on `id`. 41 | .attr('position', ['id'], (id) => { 42 | const positions = ['pitcher', '1st base', '2nd base', '3rd base']; 43 | return positions[id % positions.length]; 44 | }); 45 | 46 | Factory.define('disabled-player').extend('player').attr('state', 'disabled'); 47 | ``` 48 | 49 | **Object Building:** Build an object, passing in attributes that you want to override: 50 | 51 | ```js 52 | const game = Factory.build('game', { is_over: true }); 53 | // Built object (note scores are random): 54 | //{ 55 | // id: 1, 56 | // is_over: true, // overriden when building 57 | // created_at: Fri Apr 15 2011 12:02:25 GMT-0400 (EDT), 58 | // random_seed: 0.8999513240996748, 59 | // players: [ 60 | // {id: 1, name:'Player 1'}, 61 | // {id: 2, name:'Player 2'} 62 | // ] 63 | //} 64 | ``` 65 | 66 | For a factory with a constructor, if you want just the attributes: 67 | 68 | ```js 69 | Factory.attributes('game'); // return just the attributes 70 | ``` 71 | 72 | ### Programmatic Generation of Attributes 73 | 74 | You can specify options that are used to programmatically generate the attributes: 75 | 76 | ```js 77 | const moment = require('moment'); 78 | 79 | Factory.define('matches') 80 | .attr('seasonStart', '2016-01-01') 81 | .option('numMatches', 2) 82 | .attr('matches', ['numMatches', 'seasonStart'], (numMatches, seasonStart) => { 83 | const matches = []; 84 | for (const i = 1; i <= numMatches; i++) { 85 | matches.push({ 86 | matchDate: moment(seasonStart).add(i, 'week').format('YYYY-MM-DD'), 87 | homeScore: Math.floor(Math.random() * 5), 88 | awayScore: Math.floor(Math.random() * 5), 89 | }); 90 | } 91 | return matches; 92 | }); 93 | 94 | Factory.build('matches', { seasonStart: '2016-03-12' }, { numMatches: 3 }); 95 | // Built object (note scores are random): 96 | //{ 97 | // seasonStart: '2016-03-12', 98 | // matches: [ 99 | // { matchDate: '2016-03-19', homeScore: 3, awayScore: 1 }, 100 | // { matchDate: '2016-03-26', homeScore: 0, awayScore: 4 }, 101 | // { matchDate: '2016-04-02', homeScore: 1, awayScore: 0 } 102 | // ] 103 | //} 104 | ``` 105 | 106 | In the example `numMatches` is defined as an `option`, not as an `attribute`. Therefore `numMatches` is not part of the output, it is only used to generate the `matches` array. 107 | 108 | In the same example `seasonStart` is defined as an `attribute`, therefore it appears in the output, and can also be used in the generator function that creates the `matches` array. 109 | 110 | ### Batch Specification of Attributes 111 | 112 | The convenience function `attrs` simplifies the common case of specifying multiple attributes in a batch. Rewriting the `game` example from above: 113 | 114 | ```js 115 | Factory.define('game') 116 | .sequence('id') 117 | .attrs({ 118 | is_over: false, 119 | created_at: () => new Date(), 120 | random_seed: () => Math.random(), 121 | }) 122 | .attr('players', ['players'], (players) => { 123 | /* etc. */ 124 | }); 125 | ``` 126 | 127 | ### Post Build Callback 128 | 129 | You can also define a callback function to be run after building an object: 130 | 131 | ```js 132 | Factory.define('coach') 133 | .option('buildPlayer', false) 134 | .sequence('id') 135 | .attr('players', ['id', 'buildPlayer'], (id, buildPlayer) => { 136 | if (buildPlayer) { 137 | return [Factory.build('player', { coach_id: id })]; 138 | } 139 | }) 140 | .after((coach, options) => { 141 | if (options.buildPlayer) { 142 | console.log('built player:', coach.players[0]); 143 | } 144 | }); 145 | 146 | Factory.build('coach', {}, { buildPlayer: true }); 147 | ``` 148 | 149 | Multiple callbacks can be registered, and they will be executed in the order they are registered. The callbacks can manipulate the built object before it is returned to the callee. 150 | 151 | If the callback doesn't return anything, rosie will return build object as final result. If the callback returns a value, rosie will use that as final result instead. 152 | 153 | ### Associate a Factory with an existing Class 154 | 155 | This is an advanced use case that you can probably happily ignore, but store this away in case you need it. 156 | 157 | When you define a factory you can optionally provide a class definition, and anything built by the factory will be passed through the constructor of the provided class. 158 | 159 | Specifically, the output of `.build` is used as the input to the constructor function, so the returned object is an instance of the specified class: 160 | 161 | ```js 162 | class SimpleClass { 163 | constructor(args) { 164 | this.moops = 'correct'; 165 | this.args = args; 166 | } 167 | 168 | isMoopsCorrect() { 169 | return this.moops; 170 | } 171 | } 172 | 173 | testFactory = Factory.define('test', SimpleClass).attr('some_var', 4); 174 | 175 | testInstance = testFactory.build({ stuff: 2 }); 176 | console.log(JSON.stringify(testInstance, {}, 2)); 177 | // Output: 178 | // { 179 | // "moops": "correct", 180 | // "args": { 181 | // "stuff": 2, 182 | // "some_var": 4 183 | // } 184 | // } 185 | 186 | console.log(testInstance.isMoopsCorrect()); 187 | // Output: 188 | // correct 189 | ``` 190 | 191 | Mind. Blown. 192 | 193 | ## Usage in Node.js 194 | 195 | To use Rosie in node, you'll need to import it first: 196 | 197 | ```js 198 | import { Factory } from 'rosie'; 199 | // or with `require` 200 | const Factory = require('rosie').Factory; 201 | ``` 202 | 203 | You might also choose to use unregistered factories, as it fits better with node's module pattern: 204 | 205 | ```js 206 | // factories/game.js 207 | import { Factory } from 'rosie'; 208 | 209 | export default new Factory().sequence('id').attr('is_over', false); 210 | // etc 211 | ``` 212 | 213 | To use the unregistered `Game` factory defined above: 214 | 215 | ```js 216 | import Game from './factories/game'; 217 | 218 | const game = Game.build({ is_over: true }); 219 | ``` 220 | 221 | A tool like [babel](https://babeljs.io) is currently required to use this syntax. 222 | 223 | You can also extend an existing unregistered factory: 224 | 225 | ```js 226 | // factories/scored-game.js 227 | import { Factory } from 'rosie'; 228 | import Game from './game'; 229 | 230 | export default new Factory().extend(Game).attrs({ 231 | score: 10, 232 | }); 233 | ``` 234 | 235 | ## Rosie API 236 | 237 | As stated above the rosie factory signatures can be broken into factory definition and object creation. 238 | 239 | Additionally factories can be defined and accessed via the Factory singleton, or they can be created and maintained by the callee. 240 | 241 | ### Factory declaration functions 242 | 243 | Once you have an instance returned from a `Factory.define` or a `new Factory()` call, you do the actual of work of defining the objects. This is done using the methods below (note these are typically chained together as in the examples above): 244 | 245 | #### Factory.define 246 | 247 | - **Factory.define(`factory_name`)** - Defines a factory by name. Return an instance of a Factory that you call `.attr`, `.option`, `.sequence`, and `.after` on the result to define the properties of this factory. 248 | - **Factory.define(`factory_name`, `constructor`)** - Optionally pass a constuctor function, and the objects produced by `.build` will be passed through the `constructor` function. 249 | 250 | #### instance.attr: 251 | 252 | Use this to define attributes of your objects 253 | 254 | - **instance.attr(`attribute_name`, `default_value`)** - `attribute_name` is required and is a string, `default_value` is the value to use by default for the attribute 255 | - **instance.attr(`attribute_name`, `generator_function`)** - `generator_function` is called to generate the value of the attribute 256 | - **instance.attr(`attribute_name`, `dependencies`, `generator_function`)** - `dependencies` is an array of strings, each string is the name of an attribute or option that is required by the `generator_function` to generate the value of the attribute. This list of `dependencies` will match the parameters that are passed to the `generator_function` 257 | 258 | #### instance.attrs: 259 | 260 | Use this as a convenience function instead of calling `instance.attr` multiple times 261 | 262 | - **instance.attrs(`{attribute_1: value_1, attribute_2: value_2, ...}`)** - `attribute_i` is a string, `value_i` is either an object or generator function. 263 | 264 | See `instance.attr` above for details. Note: there is no way to specify dependencies using this method, so if you need that, you should use `instance.attr` instead. 265 | 266 | #### instance.option: 267 | 268 | Use this to define options. Options do not appear in the generated object, but they can be used in a `generator_function` that is used to configure an attribute or sequence that appears in the generated object. See the [Programmatic Generation Of Attributes](#programmatic-generation-of-attributes) section for examples. 269 | 270 | - **instance.option(`option_name`, `default_value`)** - `option_name` is required and is a string, `default_value` is the value to use by default for the option 271 | - **instance.option(`option_name`, `generator_function`)** - `generator_function` is called to generate the value of the option 272 | - **instance.option(`option_name`, `dependencies`, `generator_function`)** - `dependencies` is an array of strings, each string is the name of an option that is required by the `generator_function` to generate the value of the option. This list of `dependencies` will match the parameters that are passed to the `generator_function` 273 | 274 | #### instance.sequence: 275 | 276 | Use this to define an auto incrementing sequence field in your object 277 | 278 | - **instance.sequence(`sequence_name`)** - define a sequence called `sequence_name`, set the start value to 1 279 | - **instance.sequence(`sequence_name`, `generator_function`)** - `generator_function` is called to generate the value of the sequence. When the `generator_function` is called the pre-incremented sequence number will be passed as the first parameter, followed by any dependencies that have been specified. 280 | - **instance.sequence(`sequence_name`, `dependencies`, `generator_function`)** - `dependencies` is an array of strings, each string is the name of an attribute or option that is required by the `generator_function` to generate the value of the option. The value of each specified dependency will be passed as parameters 2..N to the `generator_function`, noting again that the pre-incremented sequence number is passed as the first parameter. 281 | 282 | #### instance.after: 283 | 284 | - **instance.after(`callback`)** - register a `callback` function that will be called at the end of the object build process. The `callback` is invoked with two params: (`build_object`, `object_options`). See the [Post Build Callback](#post-build-callback) section for examples. 285 | 286 | ### Object building functions 287 | 288 | #### build 289 | 290 | Returns an object that is generated by the named factory. `attributes` and `options` are optional parameters. The `factory_name` is required when calling against the rosie Factory singleton. 291 | 292 | - **Factory.build(`factory_name`, `attributes`, `options`)** - when build is called against the rosie Factory singleton, the first param is the name of the factory to use to build the object. The second is an object containing attribute override key value pairs, and the third is a object containing option key value pairs 293 | - **instance.build(`attributes`, `options`)** - when build is called on a factory instance only the `attributes` and `options` objects are necessary 294 | 295 | #### buildList 296 | 297 | Identical to `.build` except it returns an array of built objects. `size` is required, `attributes` and `options` are optional 298 | 299 | - **Factory.buildList(`factory_name`, size, `attributes`, `options`)** - when buildList is called against the rosie Factory singleton, the first param is the name of the factory to use to build the object. The `attributes` and `options` behave the same as the call to `.build`. 300 | - **instance.buildList(size, `attributes`, `options`)** - when buildList is called on a factory instance only the size, `attributes` and `options` objects are necessary (strictly speaking only the size is necessary) 301 | 302 | ### Testing 303 | 304 | You may find `resetAll` useful when working with testing frameworks such as Jest. It resets any build state, such as sequences, to their original values: 305 | 306 | ```js 307 | import Factory from 'rosie'; 308 | 309 | beforeEach(() => { 310 | Factory.resetAll(); 311 | }); 312 | ``` 313 | 314 | Or call `reset` on a specific factory: 315 | 316 | ```js 317 | import Game from './game'; 318 | 319 | beforeEach(() => { 320 | Game.reset(); 321 | }); 322 | ``` 323 | 324 | ## Contributing 325 | 326 | 1. Fork it 327 | 1. Create your feature branch (`git checkout -b my-new-feature`) 328 | 1. Install the test dependencies (`npm install` - requires NodeJS) 329 | 1. Make your changes and make sure the tests pass (`npm test`) 330 | 1. Commit your changes (`git commit -am 'Added some feature'`) 331 | 1. Push to the branch (`git push origin my-new-feature`) 332 | 1. Create new Pull Request 333 | 334 | ## Credits 335 | 336 | Thanks to [Daniel Morrison](http://twitter.com/danielmorrison/status/58883772040486912) for the name and [Jon Hoyt](http://twitter.com/jonmagic) for inspiration and brainstorming the idea. 337 | -------------------------------------------------------------------------------- /src/rosie.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a new factory with attributes, options, etc. to be used to build 3 | * objects. 4 | * 5 | * @param {Function=} constructor 6 | * @class 7 | */ 8 | class Factory { 9 | constructor(constructor) { 10 | this.construct = constructor; 11 | this._attrs = {}; 12 | this.opts = {}; 13 | this.sequences = {}; 14 | this.callbacks = []; 15 | 16 | Factory._allFactories.push(this); 17 | } 18 | 19 | /** 20 | * Define an attribute on this factory. Attributes can optionally define a 21 | * default value, either as a value (e.g. a string or number) or as a builder 22 | * function. For example: 23 | * 24 | * // no default value for age 25 | * Factory.define('Person').attr('age') 26 | * 27 | * // static default value for age 28 | * Factory.define('Person').attr('age', 18) 29 | * 30 | * // dynamic default value for age 31 | * Factory.define('Person').attr('age', function() { 32 | * return Math.random() * 100; 33 | * }) 34 | * 35 | * Attributes with dynamic default values can depend on options or other 36 | * attributes: 37 | * 38 | * Factory.define('Person').attr('age', ['name'], function(name) { 39 | * return name === 'Brian' ? 30 : 18; 40 | * }); 41 | * 42 | * By default if the consumer of your factory provides a value for an 43 | * attribute your builder function will not be called. You can override this 44 | * behavior by declaring that your attribute depends on itself: 45 | * 46 | * Factory.define('Person').attr('spouse', ['spouse'], function(spouse) { 47 | * return Factory.build('Person', spouse); 48 | * }); 49 | * 50 | * As in the example above, this can be a useful way to fill in 51 | * partially-specified child objects. 52 | * 53 | * @param {string} attr 54 | * @param {Array.=} dependencies 55 | * @param {*=} value 56 | * @return {Factory} 57 | */ 58 | attr(attr, dependencies, value) { 59 | let builder; 60 | if (arguments.length === 2) { 61 | value = dependencies; 62 | dependencies = null; 63 | } 64 | 65 | builder = typeof value === 'function' ? value : () => value; 66 | this._attrs[attr] = { dependencies: dependencies || [], builder: builder }; 67 | return this; 68 | } 69 | 70 | /** 71 | * Convenience function for defining a set of attributes on this object as 72 | * builder functions or static values. If you need to specify dependencies, 73 | * use #attr instead. 74 | * 75 | * For example: 76 | * 77 | * Factory.define('Person').attrs({ 78 | * name: 'Michael', 79 | * age: function() { return Math.random() * 100; } 80 | * }); 81 | * 82 | * @param {object} attributes 83 | * @return {Factory} 84 | */ 85 | attrs(attributes) { 86 | for (let attr in attributes) { 87 | if (Object.prototype.hasOwnProperty.call(attributes, attr)) { 88 | this.attr(attr, attributes[attr]); 89 | } 90 | } 91 | return this; 92 | } 93 | 94 | /** 95 | * Define an option for this factory. Options are values that may inform 96 | * dynamic attribute behavior but are not included in objects built by the 97 | * factory. Like attributes, options may have dependencies. Unlike 98 | * attributes, options may only depend on other options. 99 | * 100 | * Factory.define('Person') 101 | * .option('includeRelationships', false) 102 | * .attr( 103 | * 'spouse', 104 | * ['spouse', 'includeRelationships'], 105 | * function(spouse, includeRelationships) { 106 | * return includeRelationships ? 107 | * Factory.build('Person', spouse) : 108 | * null; 109 | * }); 110 | * 111 | * Factory.build('Person', null, { includeRelationships: true }); 112 | * 113 | * Options may have either static or dynamic default values, just like 114 | * attributes. Options without default values must have a value specified 115 | * when building. 116 | * 117 | * @param {string} opt 118 | * @param {Array.=} dependencies 119 | * @param {*=} value 120 | * @return {Factory} 121 | */ 122 | option(opt, dependencies, value) { 123 | let builder; 124 | if (arguments.length === 2) { 125 | value = dependencies; 126 | dependencies = null; 127 | } 128 | if (arguments.length > 1) { 129 | builder = typeof value === 'function' ? value : () => value; 130 | } 131 | this.opts[opt] = { dependencies: dependencies || [], builder }; 132 | return this; 133 | } 134 | 135 | /** 136 | * Defines an attribute that, by default, simply has an auto-incrementing 137 | * numeric value starting at 1. You can provide your own builder function 138 | * that accepts the number of the sequence and returns whatever value you'd 139 | * like it to be. 140 | * 141 | * Sequence values are inherited such that a factory derived from another 142 | * with a sequence will share the state of that sequence and they will never 143 | * conflict. 144 | * 145 | * Factory.define('Person').sequence('id'); 146 | * 147 | * @param {string} attr 148 | * @param {Array.=} dependencies 149 | * @param {function(number): *=} builder 150 | * @return {Factory} 151 | */ 152 | sequence(attr, dependencies, builder) { 153 | if (arguments.length === 2) { 154 | builder = /** @type function(number): * */ dependencies; 155 | dependencies = null; 156 | } 157 | builder = builder || ((i) => i); 158 | return this.attr(attr, dependencies, (...args) => { 159 | this.sequences[attr] = this.sequences[attr] || 0; 160 | args.unshift(++this.sequences[attr]); 161 | return builder(...args); 162 | }); 163 | } 164 | 165 | /** 166 | * Sets a post-processor callback that will receive built objects and the 167 | * options for the build just before they are returned from the #build 168 | * function. 169 | * 170 | * @param {function(object, object=)} callback 171 | * @return {Factory} 172 | */ 173 | after(callback) { 174 | this.callbacks.push(callback); 175 | return this; 176 | } 177 | 178 | /** 179 | * Builds a plain object containing values for each of the declared 180 | * attributes. The result of this is the same as the result when using #build 181 | * when there is no constructor registered. 182 | * 183 | * @param {object=} attributes 184 | * @param {object=} options 185 | * @return {object} 186 | */ 187 | attributes(attributes, options) { 188 | attributes = { ...attributes }; 189 | options = this.options(options); 190 | for (let attr in this._attrs) { 191 | this._attrValue(attr, attributes, options, [attr]); 192 | } 193 | return attributes; 194 | } 195 | 196 | /** 197 | * Generates a value for the given named attribute and adds the result to the 198 | * given attributes list. 199 | * 200 | * @private 201 | * @param {string} attr 202 | * @param {object} attributes 203 | * @param {object} options 204 | * @param {Array.} stack 205 | * @return {*} 206 | */ 207 | _attrValue(attr, attributes, options, stack) { 208 | if ( 209 | !this._alwaysCallBuilder(attr) && 210 | Object.prototype.hasOwnProperty.call(attributes, attr) 211 | ) { 212 | return attributes[attr]; 213 | } 214 | 215 | const value = this._buildWithDependencies(this._attrs[attr], (dep) => { 216 | if (Object.prototype.hasOwnProperty.call(options, dep)) { 217 | return options[dep]; 218 | } else if (dep === attr) { 219 | return attributes[dep]; 220 | } else if (stack.indexOf(dep) >= 0) { 221 | throw new Error( 222 | 'detected a dependency cycle: ' + stack.concat([dep]).join(' -> ') 223 | ); 224 | } else { 225 | return this._attrValue(dep, attributes, options, stack.concat([dep])); 226 | } 227 | }); 228 | attributes[attr] = value; 229 | return value; 230 | } 231 | 232 | /** 233 | * Determines whether the given named attribute has listed itself as a 234 | * dependency. 235 | * 236 | * @private 237 | * @param {string} attr 238 | * @return {boolean} 239 | */ 240 | _alwaysCallBuilder(attr) { 241 | const attrMeta = this._attrs[attr]; 242 | return attrMeta.dependencies.indexOf(attr) >= 0; 243 | } 244 | 245 | /** 246 | * Generates values for all the registered options using the values given. 247 | * 248 | * @private 249 | * @param {?object} options 250 | * @return {object} 251 | */ 252 | options(options = {}) { 253 | options = { ...options }; 254 | for (let opt in this.opts) { 255 | options[opt] = this._optionValue(opt, options); 256 | } 257 | return options; 258 | } 259 | 260 | /** 261 | * Generates a value for the given named option and adds the result to the 262 | * given options list. 263 | * 264 | * @private 265 | * @param {string} opt 266 | * @param {object} options 267 | * @return {*} 268 | */ 269 | _optionValue(opt, options) { 270 | if (Object.prototype.hasOwnProperty.call(options, opt)) { 271 | return options[opt]; 272 | } 273 | 274 | const optMeta = this.opts[opt]; 275 | if (!optMeta.builder) { 276 | throw new Error( 277 | 'option `' + opt + '` has no default value and none was provided' 278 | ); 279 | } 280 | 281 | return this._buildWithDependencies(optMeta, (dep) => 282 | this._optionValue(dep, options) 283 | ); 284 | } 285 | 286 | /** 287 | * Calls the builder function with its dependencies as determined by the 288 | * given dependency resolver. 289 | * 290 | * @private 291 | * @param {{builder: function(...[*]): *, dependencies: Array.}} meta 292 | * @param {function(string): *} getDep 293 | * @return {*} 294 | */ 295 | _buildWithDependencies(meta, getDep) { 296 | const deps = meta.dependencies; 297 | const args = deps.map((...args) => getDep.apply(this, args)); 298 | return meta.builder.apply(this, args); 299 | } 300 | 301 | /** 302 | * Builds objects by getting values for all attributes and optionally passing 303 | * the result to a constructor function. 304 | * 305 | * @param {object=} attributes 306 | * @param {object=} options 307 | * @return {*} 308 | */ 309 | build(attributes, options) { 310 | // Precalculate options. 311 | // Because options cannot depend on themselves or on attributes, subsequent calls to 312 | // `this.options` will be idempotent and we can avoid re-running builders 313 | options = this.options(options); 314 | const result = this.attributes(attributes, options); 315 | let retval = null; 316 | 317 | if (this.construct) { 318 | const Constructor = this.construct; 319 | retval = new Constructor(result); 320 | } else { 321 | retval = result; 322 | } 323 | 324 | for (let i = 0; i < this.callbacks.length; i++) { 325 | const callbackResult = this.callbacks[i](retval, options); 326 | retval = callbackResult || retval; 327 | } 328 | return retval; 329 | } 330 | 331 | buildList(size, attributes, options) { 332 | const objs = []; 333 | for (let i = 0; i < size; i++) { 334 | objs.push(this.build(attributes, options)); 335 | } 336 | return objs; 337 | } 338 | 339 | /** 340 | * Extends a given factory by copying over its attributes, options, 341 | * callbacks, and constructor. This can be useful when you want to make 342 | * different types which all share certain attributes. 343 | * 344 | * @param {string|Factory} name The factory to extend. 345 | * @return {Factory} 346 | */ 347 | extend(name) { 348 | const factory = typeof name === 'string' ? Factory.factories[name] : name; 349 | // Copy the parent's constructor 350 | if (this.construct === undefined) { 351 | this.construct = factory.construct; 352 | } 353 | Object.assign(this._attrs, factory._attrs); 354 | Object.assign(this.opts, factory.opts); 355 | // Copy the parent's callbacks 356 | this.callbacks = factory.callbacks.slice(); 357 | return this; 358 | } 359 | 360 | /** 361 | * Resets any state changed by building objects back to the original values. 362 | * Preserves attributes and options as-is. 363 | */ 364 | reset() { 365 | this.sequences = {}; 366 | } 367 | } 368 | 369 | Factory.factories = {}; 370 | Object.defineProperty(Factory, '_allFactories', { 371 | value: [], 372 | enumerable: false, 373 | }); 374 | 375 | /** 376 | * Defines a factory by name and constructor function. Call #attr and #option 377 | * on the result to define the properties of this factory. 378 | * 379 | * @param {!string} name 380 | * @param {function(object): *=} constructor 381 | * @return {Factory} 382 | */ 383 | Factory.define = function (name, constructor) { 384 | const factory = new Factory(constructor); 385 | this.factories[name] = factory; 386 | return factory; 387 | }; 388 | 389 | /** 390 | * Locates a factory by name and calls #build on it. 391 | * 392 | * @param {string} name 393 | * @param {object=} attributes 394 | * @param {object=} options 395 | * @return {*} 396 | */ 397 | Factory.build = function (name, attributes, options) { 398 | if (!this.factories[name]) { 399 | throw new Error(`The "${name}" factory is not defined.`); 400 | } 401 | return this.factories[name].build(attributes, options); 402 | }; 403 | 404 | /** 405 | * Builds a collection of objects using the named factory. 406 | * 407 | * @param {string} name 408 | * @param {number} size 409 | * @param {object=} attributes 410 | * @param {object=} options 411 | * @return {Array.<*>} 412 | */ 413 | Factory.buildList = function (name, size, attributes, options) { 414 | const objs = []; 415 | for (let i = 0; i < size; i++) { 416 | objs.push(Factory.build(name, attributes, options)); 417 | } 418 | return objs; 419 | }; 420 | 421 | /** 422 | * Locates a factory by name and calls #attributes on it. 423 | * 424 | * @param {string} name 425 | * @param {object} attributes 426 | * @param {object} options 427 | * @return {object} 428 | */ 429 | Factory.attributes = function (name, attributes, options) { 430 | return this.factories[name].attributes(attributes, options); 431 | }; 432 | 433 | /** 434 | * Resets a factory by name. Preserves attributes and options as-is. 435 | * 436 | * @param {string} name 437 | */ 438 | Factory.reset = function (name) { 439 | Factory.factories[name].reset(); 440 | }; 441 | 442 | /** 443 | * Resets all factory build state. Preserves attributes and options as-is. 444 | */ 445 | Factory.resetAll = function () { 446 | Factory._allFactories.forEach((factory) => factory.reset()); 447 | }; 448 | 449 | /** 450 | * Unregister and forget all existing factories. 451 | */ 452 | Factory.implode = function () { 453 | Factory.factories = {}; 454 | Factory._allFactories.length = 0; 455 | }; 456 | 457 | /* istanbul ignore next */ 458 | if (typeof exports === 'object' && typeof module !== 'undefined') { 459 | /* eslint-env commonjs */ 460 | exports.Factory = Factory; 461 | /* eslint-env commonjs:false */ 462 | } else if (typeof define === 'function' && define.amd) { 463 | /* eslint-env amd */ 464 | define([], () => ({ 465 | Factory: Factory, 466 | })); 467 | /* eslint-env amd:false */ 468 | } else if (this) { 469 | this.Factory = Factory; 470 | } 471 | -------------------------------------------------------------------------------- /src/__tests__/rosie.test.js: -------------------------------------------------------------------------------- 1 | const { Factory } = require('../rosie'); 2 | 3 | describe('Factory', () => { 4 | afterEach(() => { 5 | Factory.implode(); 6 | }); 7 | 8 | describe('build', () => { 9 | describe('with a normal constructor', () => { 10 | class Thing { 11 | constructor(attrs) { 12 | for (let attr in attrs) { 13 | this[attr] = attrs[attr]; 14 | } 15 | } 16 | } 17 | 18 | beforeEach(() => { 19 | Factory.define('thing', Thing) 20 | .attr('name', 'Thing 1') 21 | .after((obj) => { 22 | obj.afterCalled = true; 23 | }); 24 | }); 25 | 26 | it('should return a new instance of that constructor', () => { 27 | expect(Factory.build('thing') instanceof Thing).toBe(true); 28 | expect(Factory.build('thing').constructor).toBe(Thing); 29 | }); 30 | 31 | it('should set attributes', () => { 32 | expect(Factory.build('thing')).toEqual( 33 | expect.objectContaining({ name: 'Thing 1', afterCalled: true }) 34 | ); 35 | }); 36 | 37 | describe('running callbacks', () => { 38 | describe('callbacks do not return value', () => { 39 | beforeEach(() => { 40 | Factory.define('thing', Thing) 41 | .option('isAwesome', true) 42 | .after((obj, options) => { 43 | obj.afterCalled = true; 44 | obj.isAwesomeOption = options.isAwesome; 45 | }); 46 | }); 47 | 48 | it('should run callbacks', () => { 49 | expect(Factory.build('thing').afterCalled).toBe(true); 50 | }); 51 | 52 | it('should pass options to the after callback', () => { 53 | expect(Factory.build('thing').isAwesomeOption).toBe(true); 54 | }); 55 | }); 56 | 57 | describe('callbacks return new object', () => { 58 | beforeEach(() => { 59 | Factory.define('thing', Thing) 60 | .option('isAwesome', true) 61 | .attr('name', 'Thing 1') 62 | .after((obj) => ({ 63 | wrapped: obj, 64 | })) 65 | .after((obj, options) => ({ 66 | afterCalled: true, 67 | isAwesomeOption: options.isAwesome, 68 | wrapped: obj, 69 | })); 70 | }); 71 | 72 | it('should run callbacks', () => { 73 | expect(Factory.build('thing').afterCalled).toBe(true); 74 | }); 75 | 76 | it('should pass options to the after callback', () => { 77 | expect(Factory.build('thing').isAwesomeOption).toBe(true); 78 | }); 79 | 80 | it('should return object from callback as the final result', () => { 81 | expect(Factory.build('thing')).toEqual({ 82 | afterCalled: true, 83 | isAwesomeOption: true, 84 | wrapped: { 85 | wrapped: new Thing({ 86 | name: 'Thing 1', 87 | }), 88 | }, 89 | }); 90 | }); 91 | }); 92 | 93 | describe('when passed options', () => { 94 | it('passes all options', () => { 95 | Factory.define('thing') 96 | .option('option1', 'option1') 97 | .option('option2', 'option2') 98 | .after((_, options) => options); 99 | 100 | // Default 101 | expect(Factory.build('thing')).toEqual({ 102 | option1: 'option1', 103 | option2: 'option2', 104 | }); 105 | 106 | // Override 107 | expect( 108 | Factory.build( 109 | 'thing', 110 | {}, 111 | { 112 | option1: 'foo', 113 | option2: 'bar', 114 | } 115 | ) 116 | ).toEqual({ 117 | option1: 'foo', 118 | option2: 'bar', 119 | }); 120 | 121 | // Extra (!) 122 | expect( 123 | Factory.build( 124 | 'thing', 125 | {}, 126 | { 127 | option1: 'foo', 128 | option2: 'bar', 129 | option3: 'baz', 130 | } 131 | ) 132 | ).toEqual({ 133 | option1: 'foo', 134 | option2: 'bar', 135 | option3: 'baz', 136 | }); 137 | }); 138 | 139 | it('calls default option functions', () => { 140 | const fn = jest.fn().mockReturnValue('default value'); 141 | Factory.define('thing') 142 | .option('option', fn) 143 | .attr('value', ['option'], (option) => option) 144 | .after((obj, options) => ({ ...obj, ...options })); 145 | 146 | expect(Factory.build('thing')).toEqual({ 147 | option: 'default value', 148 | value: 'default value', 149 | }); 150 | expect(fn).toHaveBeenCalledTimes(1); 151 | }); 152 | }); 153 | }); 154 | 155 | describe('using attrs convenience function', () => { 156 | beforeEach(() => { 157 | Factory.define('thing', Thing).attrs({ 158 | name: 'Thing 1', 159 | attr1: 'value1', 160 | attr2: 'value2', 161 | }); 162 | }); 163 | 164 | it('should set attributes', () => { 165 | const thing = Factory.build('thing'); 166 | expect(thing).toEqual( 167 | expect.objectContaining({ 168 | name: 'Thing 1', 169 | attr1: 'value1', 170 | attr2: 'value2', 171 | }) 172 | ); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('without a constructor', () => { 178 | beforeEach(() => { 179 | Factory.define('thing').attr('name', 'Thing 1'); 180 | }); 181 | 182 | it('should return object with attributes set', () => { 183 | expect(Factory.build('thing')).toEqual({ name: 'Thing 1' }); 184 | }); 185 | 186 | it('should allow overriding attributes', () => { 187 | expect(Factory.build('thing', { name: 'changed' })).toEqual({ 188 | name: 'changed', 189 | }); 190 | }); 191 | 192 | it('throws error if the factory is not defined', () => { 193 | expect(() => { 194 | Factory.build('nothing'); 195 | }).toThrow(Error, 'The "nothing" factory is not defined.'); 196 | }); 197 | }); 198 | }); 199 | 200 | describe('buildList', () => { 201 | beforeEach(() => { 202 | Factory.define('thing').attr('name', 'Thing 1'); 203 | }); 204 | 205 | it('should return array of objects', () => { 206 | expect(Factory.buildList('thing', 10).length).toEqual(10); 207 | }); 208 | 209 | it('should return array of objects with default attributes', () => { 210 | const things = Factory.buildList('thing', 10); 211 | for (let i = 0; i < 10; i++) { 212 | expect(things[i]).toEqual({ name: 'Thing 1' }); 213 | } 214 | }); 215 | 216 | it('should return array of objects with specified attributes', () => { 217 | const things = Factory.buildList('thing', 10, { name: 'changed' }); 218 | for (let i = 0; i < 10; i++) { 219 | expect(things[i]).toEqual({ name: 'changed' }); 220 | } 221 | }); 222 | 223 | it('should return an array of objects with a sequence', () => { 224 | Factory.define('thing').sequence('id'); 225 | const things = Factory.buildList('thing', 4); 226 | for (let i = 0; i < 4; i++) { 227 | expect(things[i]).toEqual({ id: i + 1 }); 228 | } 229 | }); 230 | 231 | it('should return an array of objects with a sequence and with specified attributes', () => { 232 | Factory.define('thing').sequence('id').attr('name', 'Thing 1'); 233 | const things = Factory.buildList('thing', 4, { name: 'changed' }); 234 | for (let i = 0; i < 4; i++) { 235 | expect(things[i]).toEqual({ id: i + 1, name: 'changed' }); 236 | } 237 | }); 238 | 239 | it('should evaluate a option for every member of the list', () => { 240 | Factory.define('thing') 241 | .option('random', () => { 242 | return Math.random(); 243 | }) 244 | .attr('number', ['random'], (random) => random); 245 | const things = Factory.buildList('thing', 2, {}, {}); 246 | expect(things[0].number).not.toEqual(things[1].number); 247 | }); 248 | 249 | describe('with an unregistered factory', () => { 250 | const Other = new Factory().attr('name', 'Other 1'); 251 | 252 | it('should return array of objects', () => { 253 | expect(Other.buildList(10).length).toEqual(10); 254 | }); 255 | 256 | it('should return array of objects with default attributes', () => { 257 | const list = Other.buildList(10); 258 | list.forEach((item) => { 259 | expect(item).toEqual({ name: 'Other 1' }); 260 | }); 261 | }); 262 | 263 | it('should reutrn array of objects with specified attributes', () => { 264 | const list = Other.buildList(10, { name: 'changed' }); 265 | list.forEach((item) => { 266 | expect(item).toEqual({ name: 'changed' }); 267 | }); 268 | }); 269 | 270 | it('should return an array of objects with a sequence', () => { 271 | const Another = new Factory().sequence('id'); 272 | const list = Another.buildList(4); 273 | list.forEach((item, idx) => { 274 | expect(item).toEqual({ id: idx + 1 }); 275 | }); 276 | }); 277 | 278 | it('should return an array of objects with a sequence and with specified attributes', () => { 279 | const Another = new Factory().sequence('id').attr('name', 'Another 1'); 280 | const list = Another.buildList(4, { name: 'changed' }); 281 | list.forEach((item, idx) => { 282 | expect(item).toEqual({ id: idx + 1, name: 'changed' }); 283 | }); 284 | }); 285 | 286 | it('should evaluate an option for every member of the list', () => { 287 | const Another = new Factory() 288 | .option('random', Math.random) 289 | .attr('number', ['random'], (random) => random); 290 | const list = Another.buildList(2, {}, {}); 291 | expect(list[0].number).not.toEqual(list[1].number); 292 | }); 293 | 294 | it('should be reset by resetAll', () => { 295 | const Counter = new Factory().sequence('count'); 296 | expect(Counter.build()).toEqual({ count: 1 }); 297 | Factory.resetAll(); 298 | expect(Counter.build()).toEqual({ count: 1 }); 299 | }); 300 | }); 301 | }); 302 | 303 | describe('extend', () => { 304 | class Thing { 305 | constructor(attrs) { 306 | for (let attr in attrs) { 307 | this[attr] = attrs[attr]; 308 | } 309 | } 310 | } 311 | class Thingy { 312 | constructor(attrs) { 313 | for (let attr in attrs) { 314 | this[attr] = attrs[attr]; 315 | } 316 | } 317 | } 318 | 319 | describe('with registered factories', () => { 320 | beforeEach(() => { 321 | Factory.define('thing', Thing) 322 | .attr('name', 'Thing 1') 323 | .sequence('count') 324 | .after((obj) => { 325 | obj.afterCalled = true; 326 | }); 327 | Factory.define('anotherThing').extend('thing').attr('title', 'Title 1'); 328 | Factory.define('differentThing', Thingy) 329 | .extend('thing') 330 | .attr('name', 'Different Thing'); 331 | }); 332 | 333 | it('should extend the constructor', () => { 334 | expect(Factory.build('anotherThing') instanceof Thing).toBe(true); 335 | expect(Factory.build('differentThing') instanceof Thingy).toBe(true); 336 | }); 337 | 338 | it('should extend attributes', () => { 339 | expect(Factory.build('anotherThing')).toEqual( 340 | expect.objectContaining({ 341 | name: 'Thing 1', 342 | title: 'Title 1', 343 | afterCalled: true, 344 | }) 345 | ); 346 | }); 347 | 348 | it('should extend callbacks', () => { 349 | expect(Factory.build('anotherThing').afterCalled).toBe(true); 350 | }); 351 | 352 | it('should override attributes', () => { 353 | expect(Factory.build('differentThing').name).toBe('Different Thing'); 354 | }); 355 | 356 | it('should reset sequences', () => { 357 | expect(Factory.build('thing')).toEqual( 358 | expect.objectContaining({ count: 1 }) 359 | ); 360 | expect(Factory.build('thing')).toEqual( 361 | expect.objectContaining({ count: 2 }) 362 | ); 363 | Factory.reset('thing'); 364 | expect(Factory.build('thing')).toEqual( 365 | expect.objectContaining({ count: 1 }) 366 | ); 367 | }); 368 | 369 | it('should be reset by resetAll', () => { 370 | expect(Factory.build('thing')).toEqual( 371 | expect.objectContaining({ count: 1 }) 372 | ); 373 | expect(Factory.build('thing')).toEqual( 374 | expect.objectContaining({ count: 2 }) 375 | ); 376 | Factory.resetAll(); 377 | expect(Factory.build('thing')).toEqual( 378 | expect.objectContaining({ count: 1 }) 379 | ); 380 | }); 381 | }); 382 | 383 | describe('with unregistered factories', () => { 384 | let ParentFactory; 385 | let ChildFactory; 386 | let SiblingFactory; 387 | 388 | beforeEach(() => { 389 | ParentFactory = new Factory(Thing) 390 | .attr('name', 'Parent') 391 | .after((obj) => { 392 | obj.afterCalled = true; 393 | }); 394 | ChildFactory = new Factory() 395 | .extend(ParentFactory) 396 | .attr('title', 'Child'); 397 | SiblingFactory = new Factory(Thingy) 398 | .extend(ParentFactory) 399 | .attr('name', 'Sibling'); 400 | }); 401 | 402 | it('should extend the constructor', () => { 403 | expect(ChildFactory.build() instanceof Thing).toBe(true); 404 | expect(SiblingFactory.build() instanceof Thingy).toBe(true); 405 | }); 406 | 407 | it('should extend attributes', () => { 408 | expect(ChildFactory.build()).toEqual( 409 | expect.objectContaining({ 410 | name: 'Parent', 411 | title: 'Child', 412 | afterCalled: true, 413 | }) 414 | ); 415 | }); 416 | 417 | it('should extend callbacks', () => { 418 | expect(SiblingFactory.build().afterCalled).toBe(true); 419 | }); 420 | 421 | it('should override attributes', () => { 422 | expect(SiblingFactory.build().name).toBe('Sibling'); 423 | }); 424 | }); 425 | }); 426 | 427 | describe('attributes', () => { 428 | beforeEach(() => { 429 | Factory.define('thing').attr('name', 'Thing 1'); 430 | }); 431 | 432 | it('should return object with attributes set', () => { 433 | expect(Factory.attributes('thing')).toEqual({ name: 'Thing 1' }); 434 | }); 435 | 436 | it('should allow overriding attributes', () => { 437 | expect(Factory.attributes('thing', { name: 'changed' })).toEqual({ 438 | name: 'changed', 439 | }); 440 | }); 441 | }); 442 | 443 | describe('prototype', () => { 444 | let factory; 445 | 446 | beforeEach(() => { 447 | factory = new Factory(); 448 | }); 449 | 450 | describe('attr', () => { 451 | it('should add given value to attributes', () => { 452 | factory.attr('foo', 'bar'); 453 | expect(factory.attributes().foo).toEqual('bar'); 454 | }); 455 | 456 | it('should invoke function', () => { 457 | let calls = 0; 458 | factory.attr('dynamic', () => { 459 | return ++calls; 460 | }); 461 | expect(factory.attributes().dynamic).toEqual(1); 462 | expect(factory.attributes().dynamic).toEqual(2); 463 | }); 464 | 465 | it('should return the factory', () => { 466 | expect(factory.attr('foo', 1)).toBe(factory); 467 | }); 468 | 469 | it('should allow depending on other attributes', () => { 470 | factory 471 | .attr( 472 | 'fullName', 473 | ['firstName', 'lastName'], 474 | (first, last) => first + ' ' + last 475 | ) 476 | .attr('firstName', 'Default') 477 | .attr('lastName', 'Name'); 478 | 479 | expect(factory.attributes()).toEqual({ 480 | firstName: 'Default', 481 | lastName: 'Name', 482 | fullName: 'Default Name', 483 | }); 484 | 485 | expect( 486 | factory.attributes({ firstName: 'Michael', lastName: 'Bluth' }) 487 | ).toEqual({ 488 | fullName: 'Michael Bluth', 489 | firstName: 'Michael', 490 | lastName: 'Bluth', 491 | }); 492 | 493 | expect(factory.attributes({ fullName: 'Buster Bluth' })).toEqual({ 494 | fullName: 'Buster Bluth', 495 | firstName: 'Default', 496 | lastName: 'Name', 497 | }); 498 | }); 499 | 500 | it('throws when building when a dependency cycle is unbroken', () => { 501 | factory 502 | .option('rate', 0.0275) 503 | .attr('fees', ['total', 'rate'], (total, rate) => total * rate) 504 | .attr('total', ['fees', 'rate'], (fees, rate) => fees / rate); 505 | 506 | expect(() => { 507 | factory.build(); 508 | }).toThrow(Error, 'detected a dependency cycle: fees -> total -> fees'); 509 | }); 510 | 511 | it('always calls dynamic attributes when they depend on themselves', () => { 512 | factory.attr('person', ['person'], (person) => { 513 | if (!person) { 514 | person = {}; 515 | } 516 | if (!person.name) { 517 | person.name = 'Bob'; 518 | } 519 | return person; 520 | }); 521 | 522 | expect(factory.attributes({ person: { age: 55 } })).toEqual({ 523 | person: { name: 'Bob', age: 55 }, 524 | }); 525 | }); 526 | }); 527 | 528 | describe('sequence', () => { 529 | it('should return the factory', () => { 530 | expect(factory.sequence('id')).toBe(factory); 531 | }); 532 | 533 | it('should return an incremented value for each invocation', () => { 534 | factory.sequence('id'); 535 | expect(factory.attributes().id).toEqual(1); 536 | expect(factory.attributes().id).toEqual(2); 537 | expect(factory.attributes().id).toEqual(3); 538 | }); 539 | 540 | it('should increment different sequences independently', () => { 541 | factory.sequence('id'); 542 | factory.sequence('count'); 543 | 544 | expect(factory.attributes()).toEqual({ id: 1, count: 1 }); 545 | expect(factory.attributes()).toEqual({ id: 2, count: 2 }); 546 | }); 547 | 548 | it('should share the sequence when extending a factory', () => { 549 | const User = Factory.define('User').sequence('id'); 550 | const AdminUser = Factory.define('AdminUser').extend('User'); 551 | 552 | const adminUser = AdminUser.build(); 553 | const user = User.build(); 554 | 555 | expect(adminUser.id).toEqual(1); 556 | expect(user.id).toEqual(2); 557 | }); 558 | 559 | it('should use custom function', () => { 560 | factory.sequence('name', (i) => 'user' + i); 561 | expect(factory.attributes().name).toEqual('user1'); 562 | }); 563 | 564 | it('should be able to depend on one option', () => { 565 | const startTime = 5; 566 | 567 | factory 568 | .option('startTime', startTime) 569 | .sequence('time', ['startTime'], (i, startTime) => startTime + i); 570 | 571 | expect(factory.attributes()).toEqual({ time: startTime + 1 }); 572 | expect(factory.attributes()).toEqual({ time: startTime + 2 }); 573 | expect(factory.attributes()).toEqual({ time: startTime + 3 }); 574 | }); 575 | 576 | it('should be able to depend on one attribute', () => { 577 | const startTime = 5; 578 | 579 | factory 580 | .attr('startTime', startTime) 581 | .sequence('time', ['startTime'], (i, startTime) => startTime + i); 582 | 583 | expect(factory.attributes()).toEqual({ 584 | startTime: startTime, 585 | time: startTime + 1, 586 | }); 587 | expect(factory.attributes()).toEqual({ 588 | startTime: startTime, 589 | time: startTime + 2, 590 | }); 591 | expect(factory.attributes()).toEqual({ 592 | startTime: startTime, 593 | time: startTime + 3, 594 | }); 595 | }); 596 | 597 | it('should be able to depend on several attributes and options', () => { 598 | const startTime = 5; 599 | const endTime = 7; 600 | 601 | factory 602 | .attr('startTime', startTime) 603 | .attr('endTime', endTime) 604 | .option('checkEndTime', true) 605 | .sequence( 606 | 'time', 607 | ['startTime', 'endTime', 'checkEndTime'], 608 | (i, startTime, endTime, checkEndTime) => 609 | checkEndTime ? Math.min(startTime + i, endTime) : startTime + i 610 | ); 611 | 612 | expect(factory.attributes()).toEqual({ 613 | startTime: startTime, 614 | endTime: endTime, 615 | time: startTime + 1, 616 | }); 617 | expect(factory.attributes()).toEqual({ 618 | startTime: startTime, 619 | endTime: endTime, 620 | time: startTime + 2, 621 | }); 622 | expect(factory.attributes()).toEqual({ 623 | startTime: startTime, 624 | endTime: endTime, 625 | time: startTime + 2, 626 | }); 627 | }); 628 | 629 | it('should be able to be reset', () => { 630 | factory.sequence('count'); 631 | expect(factory.attributes()).toEqual({ count: 1 }); 632 | factory.reset(); 633 | expect(factory.attributes()).toEqual({ count: 1 }); 634 | }); 635 | }); 636 | 637 | describe('attributes', () => { 638 | beforeEach(() => { 639 | factory.attr('foo', 1).attr('bar', 2); 640 | }); 641 | 642 | it('should allow overriding an attribute', () => { 643 | expect(factory.attributes({ bar: 3 })).toEqual({ foo: 1, bar: 3 }); 644 | }); 645 | 646 | it('should allow overriding an attribute with a falsy value', () => { 647 | expect(factory.attributes({ bar: false })).toEqual({ 648 | foo: 1, 649 | bar: false, 650 | }); 651 | }); 652 | 653 | it('should allow adding new attributes', () => { 654 | expect(factory.attributes({ baz: 3 })).toEqual({ 655 | foo: 1, 656 | bar: 2, 657 | baz: 3, 658 | }); 659 | }); 660 | }); 661 | 662 | describe('option', () => { 663 | beforeEach(() => { 664 | factory.option('useCapsLock', false); 665 | }); 666 | 667 | it('should return the factory', () => { 668 | expect(factory.option('rate')).toBe(factory); 669 | }); 670 | 671 | it('should not create attributes in the build result', () => { 672 | expect(factory.attributes().useCapsLock).toBeUndefined(); 673 | }); 674 | 675 | it('throws when no default or value is given', () => { 676 | factory.option('someOptionWithoutAValue'); 677 | expect(() => { 678 | factory.attributes(); 679 | }).toThrow( 680 | Error, 681 | 'option `someOptionWithoutAValue` has no default value and none was provided' 682 | ); 683 | }); 684 | 685 | it('should be usable by attributes', () => { 686 | const useCapsLockValues = []; 687 | factory.attr('name', ['useCapsLock'], (useCapsLock) => { 688 | useCapsLockValues.push(useCapsLock); 689 | const name = 'Madeline'; 690 | if (useCapsLock) { 691 | return name.toUpperCase(); 692 | } else { 693 | return name; 694 | } 695 | }); 696 | // use default values 697 | expect(factory.attributes().name).toEqual('Madeline'); 698 | // override default values 699 | expect(factory.attributes({}, { useCapsLock: true }).name).toEqual( 700 | 'MADELINE' 701 | ); 702 | expect(useCapsLockValues).toEqual([false, true]); 703 | }); 704 | 705 | it('can depend on other options', () => { 706 | factory 707 | .option('option1', 'foo') 708 | .option('option2', ['option1'], (option1) => option1 + 'bar') 709 | .attr('value', ['option2'], (option2) => option2); 710 | 711 | // Default values 712 | expect(factory.attributes()).toHaveProperty('value', 'foobar'); 713 | // Override one 714 | expect(factory.attributes({}, { option1: 'bar' })).toHaveProperty( 715 | 'value', 716 | 'barbar' 717 | ); 718 | // Override two 719 | expect(factory.attributes({}, { option2: 'specific' })).toHaveProperty( 720 | 'value', 721 | 'specific' 722 | ); 723 | }); 724 | 725 | it('cannot depend on itself', () => { 726 | const fn = jest.fn().mockReturnValue('default value'); 727 | factory 728 | .option('option', ['option'], fn) 729 | .attr('value', ['option'], (option) => option); 730 | 731 | // Default values 732 | expect(() => factory.attributes()).toThrow(/Maximum call stack/); 733 | expect(fn).not.toHaveBeenCalled(); 734 | // Option set 735 | expect(factory.attributes({}, { option: 'override' })).toHaveProperty( 736 | 'value', 737 | 'override' 738 | ); 739 | expect(fn).not.toHaveBeenCalled(); 740 | }); 741 | 742 | it('cannot depend on an attribute', () => { 743 | factory 744 | .attr('baseAttr', 'base') 745 | .option('option', ['baseAttr'], (attr) => attr) 746 | .attr('value', ['option'], (option) => option); 747 | 748 | expect(() => factory.attributes()).toThrow( 749 | /Cannot read propert(y 'builder'|ies) of undefined/ 750 | ); 751 | }); 752 | }); 753 | }); 754 | }); 755 | --------------------------------------------------------------------------------