├── .gitignore ├── .npmignore ├── .travis.yml ├── .vscode ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── docs ├── .nojekyll ├── README.md ├── _coverpage.md ├── _sidebar.md ├── api.md ├── array.md ├── assets │ ├── images │ │ └── favicon.ico │ └── styles.css ├── configure.md ├── define.md ├── derive.md ├── extend.md ├── helpers.md ├── index.html ├── project-setup.md └── quick-start.md ├── jest.config.js ├── package.json ├── src ├── __tests__ │ ├── array.test.ts │ ├── define.test.ts │ ├── derive.test.ts │ ├── extend.test.ts │ └── helpers.test.ts ├── array.ts ├── compute.ts ├── config.ts ├── define.ts ├── derive.ts ├── extend.ts ├── helpers.ts ├── index.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Avoid publishing the source code in the package 2 | src/ 3 | 4 | # Avoid publishing the built tests in the package 5 | dist/__tests__/ 6 | 7 | # Avoid publishing the Travis config 8 | .travis.yml 9 | 10 | # Avoid publishing the Jest config 11 | jest.config.js 12 | 13 | # Avoid publishing code coverage information 14 | coverage/ 15 | 16 | # Avoid publishing the documentation 17 | docs/ 18 | 19 | # Avoid publishing editor config for VSCode 20 | .vscode 21 | 22 | # Avoid publishing commit linting configuration 23 | commitlint.config.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | if: tag IS blank 2 | 3 | language: node_js 4 | node_js: stable 5 | 6 | jobs: 7 | include: 8 | - stage: test 9 | name: "Unit tests, type checking, linting, etc." 10 | script: 11 | - yarn run check-types 12 | - yarn test 13 | - yarn commitlint-travis 14 | - yarn formatting:check 15 | - yarn build 16 | after_success: yarn run coverage 17 | - stage: release 18 | name: "Release on npm" 19 | deploy: 20 | provider: script 21 | skip_cleanup: true 22 | script: 23 | - yarn semantic-release 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "yarn", 8 | "script": "test", 9 | "group": { 10 | "kind": "test", 11 | "isDefault": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | **⚠️ For all versions after `1.4.0` please see the [Releases Tab](https://github.com/skovy/cooky-cutter/releases. `semantic-release` is now used ⚠️** 9 | 10 | ## [1.4.0] - 2019-10-02 11 | 12 | ### Added 13 | 14 | - Add `resetSequence()` to all factories to allow resetting the sequence [#24](https://github.com/skovy/cooky-cutter/pull/24) 15 | 16 | ## [1.3.1] - 2019-06-01 17 | 18 | ### Fixed 19 | 20 | - An incorrect warning about hard-coded values when using an override value with `derive` [#17](https://github.com/skovy/cooky-cutter/pull/17) 21 | - Invoking a function more than once when computing dependencies for a value needed for `derive` [#19](https://github.com/skovy/cooky-cutter/pull/19) 22 | 23 | ## [1.3.0] - 2019-05-18 24 | 25 | ### Security 26 | 27 | - Update dependencies with vulnerabilities reported via `npm audit` via `npm audit fix --force` [#11](https://github.com/skovy/cooky-cutter/pull/11) 28 | 29 | ### Added 30 | 31 | - By default, warnings will now be show for hard-coded objects and arrays [#12](https://github.com/skovy/cooky-cutter/pull/12) 32 | - A `configure` function was added to globally configure factories [#12](https://github.com/skovy/cooky-cutter/pull/12) 33 | - A configuration option `errorOnHardCodedValues` will throw (rather than warn) about hard-coded values [#12](https://github.com/skovy/cooky-cutter/pull/12) 34 | - Properly ignore `.vscode` config when publishing to npm in [#9](https://github.com/skovy/cooky-cutter/pull/9) 35 | 36 | ### Fixed 37 | 38 | - The `array` type definitions now match the `extend` type definitions [#12](https://github.com/skovy/cooky-cutter/pull/12) 39 | 40 | ## [1.2.0] - 2018-11-10 41 | 42 | ### Changed 43 | 44 | - Upgrade to `typescript@3.1.6` and update the internals to use proper function 45 | properties in [#7](https://github.com/skovy/cooky-cutter/pull/7) 46 | 47 | ### Fixed 48 | 49 | - Run `npm audit fix` to resolve `devDependency` vulnerabilities in [#8](https://github.com/skovy/cooky-cutter/pull/8) 50 | 51 | ## [1.1.0] - 2018-08-08 52 | 53 | ### Added 54 | 55 | - Add `array` for creating an array of objects (from a factory) in [#2](https://github.com/skovy/cooky-cutter/pull/2) by [@kijowski](https://github.com/kijowski) 56 | - Add `derive` for deriving values dependent on other attributes in [#5](https://github.com/skovy/cooky-cutter/pull/5) 57 | 58 | ### Changed 59 | 60 | - Update internal version of TypeScript to `3.0.1` in [#3](https://github.com/skovy/cooky-cutter/pull/3) 61 | 62 | ## [1.0.3] - 2018-07-15 63 | 64 | ### Added 65 | 66 | - `CHANGELOG` with current changes and (most) retroactive changes 67 | 68 | ### Fixed 69 | 70 | - Allow overriding factory by passing a "falsy" value (eg: `0` or `false`) 71 | 72 | ## [1.0.2] - 2018-07-14 73 | 74 | ### Fixed 75 | 76 | - Add another badge for types 77 | - Update the homepage URL to point to documentation 78 | 79 | ## [1.0.1] - 2018-07-14 80 | 81 | ### Fixed 82 | 83 | - Removed unintentionally published files that increased the bundle size unnecessarily 84 | 85 | ## [1.0.0] - 2018-07-14 86 | 87 | ### Changed 88 | 89 | - Documentation updates 90 | - `README` updates 91 | 92 | ## [0.0.6] - 2018-07-03 93 | 94 | ### Changed 95 | 96 | - Allow optional params in the `override` config 97 | - Allow overriding base factory attributes when extending 98 | 99 | ## [0.0.5] - 2018-06-25 100 | 101 | ### Added 102 | 103 | - Add `extend` function to extend existing factories 104 | - Export types from the index 105 | 106 | ## [0.0.4] - 2018-06-25 107 | 108 | ### Changed 109 | 110 | - `create` renamed to define 111 | 112 | ## [0.0.3] - 2018-06-24 113 | 114 | ### Added 115 | 116 | - `README` was added with basic information 117 | 118 | ### Changed 119 | 120 | - The range of the `random` helper was changed from `Number.MAX_VALUE` to the 121 | 32-bit max for readability. 122 | 123 | ## [0.0.2] - 2018-06-24 124 | 125 | ### Fixed 126 | 127 | - Correctly export the `create` method 128 | 129 | ## 0.0.1 - 2018-06-24 130 | 131 | ### Added 132 | 133 | - `create` method to define factories 134 | - `random` helper for a random integer 135 | - `sequence` helper for a sequential integer 136 | 137 | [unreleased]: https://github.com/skovy/cooky-cutter/compare/v1.3.1...HEAD 138 | [1.3.1]: https://github.com/skovy/cooky-cutter/compare/v1.3.0...v1.3.1 139 | [1.3.0]: https://github.com/skovy/cooky-cutter/compare/v1.2.0...v1.3.0 140 | [1.2.0]: https://github.com/skovy/cooky-cutter/compare/v1.1.0...v1.2.0 141 | [1.1.0]: https://github.com/skovy/cooky-cutter/compare/v1.0.3...v1.1.0 142 | [1.0.3]: https://github.com/skovy/cooky-cutter/compare/v1.0.2...v1.0.3 143 | [1.0.2]: https://github.com/skovy/cooky-cutter/compare/v1.0.1...v1.0.2 144 | [1.0.1]: https://github.com/skovy/cooky-cutter/compare/v1.0.0...v1.0.1 145 | [1.0.0]: https://github.com/skovy/cooky-cutter/compare/v0.3.0...v1.0.0 146 | [0.0.6]: https://github.com/skovy/cooky-cutter/compare/v0.0.5...v0.0.6 147 | [0.0.5]: https://github.com/skovy/cooky-cutter/compare/v0.0.4...v0.0.5 148 | [0.0.4]: https://github.com/skovy/cooky-cutter/compare/v0.0.3...v0.0.4 149 | [0.0.3]: https://github.com/skovy/cooky-cutter/compare/v0.0.2...v0.0.3 150 | [0.0.2]: https://github.com/skovy/cooky-cutter/compare/v0.0.1...v0.0.2 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Spencer Miskoviak 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 | # 🍪 cooky-cutter 2 | 3 | **Simple, type safe\* object factories for JavaScript tests.** (_\*with TypeScript_) 4 | 5 | [![Travis branch](https://img.shields.io/travis/skovy/cooky-cutter/master.svg)](https://travis-ci.org/skovy/cooky-cutter) 6 | [![Coverage Status](https://coveralls.io/repos/github/skovy/cooky-cutter/badge.svg?branch=master)](https://coveralls.io/github/skovy/cooky-cutter?branch=master) 7 | [![npm](https://img.shields.io/npm/v/cooky-cutter.svg)](https://www.npmjs.com/package/cooky-cutter) 8 | [![npm type definitions](https://img.shields.io/npm/types/cooky-cutter.svg)](https://www.npmjs.com/package/cooky-cutter) 9 | 10 | **[Read the Release Annoucement](https://skovy.dev/object-factories-for-testing-in-typescript/)**. 11 | 12 | ## The problem 13 | 14 | You need to write maintainable tests for JavaScript. The code depends on 15 | specific entity types defined in the data model. These entities might initially 16 | get stubbed out in tests (Mocha, Jest, etc) as plain objects. As complexity 17 | grows, you move to factory functions (or another package that does this) to 18 | avoid the duplication. A new column gets added, an old one gets removed or maybe 19 | an entirely new entity is added. The breaking change isn't noticed until the 20 | entire test suite runs (or maybe never). 21 | 22 | ## The solution 23 | 24 | [`cooky-cutter`](https://www.npmjs.com/package/cooky-cutter) is a light package 25 | that leverages TypeScript to define and create factories. Simply pass the type 26 | as a generic (assuming you already have a type or interface defined for each 27 | entity type). Whenever the entity type changes, the factories become invalid! 28 | 29 | ## Installation 30 | 31 | ```bash 32 | npm install --save-dev cooky-cutter 33 | # or 34 | yarn add --dev cooky-cutter 35 | ``` 36 | 37 | ## Basic Usage 38 | 39 | For more documentation and examples, read the [full documentation](https://skovy.github.io/cooky-cutter/). 40 | 41 | ```typescript 42 | import { define, random, sequence } from "cooky-cutter"; 43 | 44 | // Define an interface (or type) for the entity 45 | interface User { 46 | id: number; 47 | firstName: string; 48 | lastName: string; 49 | age: number; 50 | } 51 | 52 | // Define a factory that represents the defined interface 53 | const user = define({ 54 | id: random, 55 | firstName: i => `Bob #${i}`, 56 | lastName: "Smith", 57 | age: sequence 58 | }); 59 | 60 | // Invoke the factory a few times 61 | console.log(user()); 62 | // => { id: 980711200, firstName: 'Bob #1', lastName: 'Smith', age: 1 } 63 | 64 | console.log(user()); 65 | // => { id: 1345667839, firstName: 'Bob #2', lastName: 'Smith', age: 2 } 66 | 67 | console.log(user()); 68 | // => { id: 796816401, firstName: 'Bob #3', lastName: 'Smith', age: 3 } 69 | ``` 70 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skovy/cooky-cutter/7c942bd056a2794aa222b0e47d8b6022aca9fbf5/docs/.nojekyll -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | ## Why? 4 | 5 | You need to write maintainable tests for JavaScript that depends on a 6 | specific data model. Plain objects work at first, then maybe factory functions, 7 | but after a while the data model and the factories get out of sync. A new column 8 | gets added, an old one gets removed or maybe an entirely new entity is added. 9 | The breaking change isn't noticed until the entire test suite runs (or maybe 10 | never). 11 | 12 | **cooky-cutter** is a light, simple package that leverages TypeScript to define 13 | and extend factories. Simply pass the type the factory represents and configure 14 | your factory. When the types change, the factories become invalid! 15 | 16 | ## Features 17 | 18 | - 🛠 [Configuration](configure) 19 | - 🤖 [Define](define) factories 20 | - ⚙️ [Extend](extend) existing factories 21 | - 📦 [Arrays](array) of factories 22 | - 🚀 [Helpers](helpers) for common patterns 23 | - ⚡️️ Type safety! 24 | 25 | ## Getting started 26 | 27 | Check out the [Quick start](quick-start) documentation to get started. 28 | -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | # 🍪 cooky-cutter 2 | 3 | > Object factories for testing in TypeScript. 4 | 5 | [![Travis branch](https://img.shields.io/travis/skovy/cooky-cutter/master.svg)](https://travis-ci.org/skovy/cooky-cutter) 6 | [![Coverage Status](https://coveralls.io/repos/github/skovy/cooky-cutter/badge.svg?branch=master)](https://coveralls.io/github/skovy/cooky-cutter?branch=master) 7 | [![npm](https://img.shields.io/npm/v/cooky-cutter.svg)](https://www.npmjs.com/package/cooky-cutter) 8 | [![npm type definitions](https://img.shields.io/npm/types/cooky-cutter.svg)](https://www.npmjs.com/package/cooky-cutter) 9 | 10 | [GitHub](https://github.com/skovy/cooky-cutter) 11 | [Get Started](README.md) 12 | 13 | ![color](#FCFCFC) 14 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - Getting Started 2 | 3 | - [Overview](README.md) 4 | - [Quick Start](quick-start.md) 5 | - [Configuration](configure.md) 6 | - [Defining factories](define.md) 7 | - [Extending factories](extend.md) 8 | - [Arrays of factories](array.md) 9 | - [Deriving values](derive.md) 10 | 11 | - Recipes 12 | 13 | - [Helpers](helpers.md) 14 | - [Project Setup](project-setup.md) 15 | 16 | - Reference 17 | - [API](api.md) 18 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## configure 4 | 5 | Globally configure all factories. For examples, see [configuration](configure). 6 | 7 | | Param | Type | Description | 8 | | ------------- | -------- | ------------------------------------------------------- | 9 | | configuration | `Object` | [Configuration object](configure#configuration-options) | 10 | 11 | ## define 12 | 13 | Define a new cooky-cutter factory. For examples, see [defining factories](define). 14 | 15 | | Param | Type | Description | 16 | | ----------- | --------- | ------------------------------------------------ | 17 | | config | `Object` | [Configuration object](api#configuration-object) | 18 | | **Returns** | `Factory` | [Factory function](api#factory-function) | 19 | 20 | ## extend 21 | 22 | Extend an existing cooky-cutter factory. For examples, see [extending factories](extend). 23 | 24 | | Param | Type | Description | 25 | | ----------- | --------- | ------------------------------------------------ | 26 | | base | `Factory` | Existing cooky-cutter factory | 27 | | config | `Object` | [Configuration object](api#configuration-object) | 28 | | **Returns** | `Factory` | [Factory function](api#factory-function) | 29 | 30 | ## array 31 | 32 | Uses an existing cooky-cutter factory to create arrays. For examples, see [creating array of objects](define#creating-array-of-objects). 33 | 34 | | Param | Type | Description | 35 | | ----------- | -------------- | ---------------------------------------------------- | 36 | | factory | `Factory` | Existing cooky-cutter factory | 37 | | size | `number` | Size of the array that will be generated | 38 | | **Returns** | `ArrayFactory` | [Array factory function](api#array-factory-function) | 39 | 40 | ## derive 41 | 42 | Derive an attribute's value from other fields in the factory. For examples, see 43 | [deriving values](derive). 44 | 45 | | Param | Type | Description | 46 | | ------------- | -------------- | -------------------------------------------------- | 47 | | fn | `Function` | Receives an object with the dependent keys defined | 48 | | dependentKeys | `String` | Attributes the derived field is dependent on | 49 | | **Returns** | Attribute Type | Matches the attribute type | 50 | 51 | ## Factory function 52 | 53 | The return value of `define` and `extend`. It can be invoked any number of times 54 | to create a new object representing a given type following the configuration 55 | object specifications. The function also accepts an optional `override` 56 | parameter to override the original configuration. 57 | 58 | | Param | Type | Description | 59 | | ----------- | -------- | ------------------------------------------------ | 60 | | override | `Object` | [Configuration object](api#configuration-object) | 61 | | **Returns** | `Object` | Matches the configuration specifications | 62 | 63 | The internal sequence can be reset by calling `resetSequence()` on the factory 64 | returned from `define` or `extend`. 65 | 66 | ## Array factory function 67 | 68 | The return value of `array`. It can be invoked any number of times 69 | to create a new array with objects representing a given type following the configuration 70 | object specifications. The function also accepts an optional `override` 71 | parameter to override the original configuration. 72 | 73 | | Param | Type | Description | 74 | | ----------- | ---------- | ---------------------------------------------------- | 75 | | override | `Object` | [Configuration object](api#configuration-object) | 76 | | **Returns** | `Object[]` | Each object matches the configuration specifications | 77 | 78 | ## Configuration object 79 | 80 | The configuration object is an argument to [`define`](api#define), 81 | [`extend`](api#extend), and the override argument for a [`Factory`](api#factory). 82 | Each attribute can be: 83 | 84 | 1. A hard-coded value (eg: `string`, `number`). This will be 85 | identical for all invocations. 86 | 1. A function that receives the invocation count as the only argument. This 87 | can be useful to have a unique field each invocation by appending the 88 | count or using as a seed. 89 | 1. Another factory. This will be invoked every time the parent factory is 90 | invoked. 91 | 92 | !> Prefer composing factories over hard-coding `object` or `array` attributes. 93 | Hard-coded objects will be identical across all factory invocations so any 94 | mutations will affect all instances. 95 | -------------------------------------------------------------------------------- /docs/array.md: -------------------------------------------------------------------------------- 1 | # Arrays of factories 2 | 3 | The `cooky-cutter` package provides an `array` method to create a generator 4 | function to return an array of objects matching the provided factory definition. 5 | The generator accepts a [configuration object](api#configuration-object) 6 | to override the underlying factories. Each generator invocation returns a new 7 | array with new objects matching the factory and optional config. 8 | 9 | ## Usage 10 | 11 | ### Creating an array of factories 12 | 13 | In this example we will use `User` factory to create arrays of various size. 14 | 15 | ```typescript 16 | type User = { firstName: string; age: number }; 17 | 18 | const user = define({ 19 | firstName: "Bob", 20 | age: 42 21 | }); 22 | 23 | const pairOfUsers = array(user, 2); 24 | 25 | const trioOfUsers = array(user, 3); 26 | 27 | pairOfUsers(); // returns an array of two user objects 28 | trioOfUsers(); // returns an array of three user objects 29 | ``` 30 | 31 | ### Overriding an array of factories 32 | 33 | Similar to the [define method](define) the array generator accepts a config 34 | to override the underlying factories. 35 | 36 | ```typescript 37 | pairOfUsers({ firstName: "Joe" }); // return an array of two user object with the `firstName` "Joe" 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skovy/cooky-cutter/7c942bd056a2794aa222b0e47d8b6022aca9fbf5/docs/assets/images/favicon.ico -------------------------------------------------------------------------------- /docs/assets/styles.css: -------------------------------------------------------------------------------- 1 | .app-sub-sidebar li:before { 2 | display: none; 3 | } 4 | 5 | /* Fix for linked code snippets having mismatched text and underline color */ 6 | .markdown-section a code { 7 | text-decoration: underline; 8 | } -------------------------------------------------------------------------------- /docs/configure.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The library can be configured via the `configure` function, which accepts a 4 | plain object that will overwrite the existing configuration. 5 | 6 | ## Usage Example 7 | 8 | ```typescript 9 | import { configure } from "cooky-cutter"; 10 | 11 | configure({ errorOnHardCodedValues: true }); 12 | ``` 13 | 14 | ### Configuration Options 15 | 16 | `errorOnHardCodedValues`: Enabling this setting will throw (rather than warn) 17 | when an object or array is hard-coded in the configuration for a factory (not in 18 | the override). It's strongly discouraged to use a shared instance of an object 19 | or an array between different factory instances. If one test case modifies the 20 | object or array, the next test that relies on this factory will still have those 21 | mutations which can lead to confusing and subtle bugs or random test failures. 22 | -------------------------------------------------------------------------------- /docs/define.md: -------------------------------------------------------------------------------- 1 | # Defining factories 2 | 3 | The `cooky-cutter` package provides a `define` method to [configure](api#configuration-object) 4 | a factory for a given type. Each factory invocation return a new object matching 5 | the config. Each attribute can be a hardcoded value (eg: `string` or `number`), 6 | a function that receives the invocation count as the only argument or another 7 | factory. 8 | 9 | ## Usage 10 | 11 | ### Basic example 12 | 13 | In this example, each invocation of the `user` factory will return an identically 14 | shaped object (but each is a new instance). 15 | 16 | ```typescript 17 | type User = { firstName: string; age: number }; 18 | 19 | const user = define({ 20 | firstName: "Bob", 21 | age: 42 22 | }); 23 | ``` 24 | 25 | ### Attribute functions 26 | 27 | In this example, each invocation of the `user` factory will return a unique 28 | factory. The value of `i` for each function starts at `1` and will increment each 29 | call. For example, the first three calls for `firstName` will be `Bob #1`, 30 | `Bob #2` and `Bob #3`. Similarily, `age` will be `42`, `84` and `126`. See 31 | [helpers](helpers) for utility methods for common patterns. 32 | 33 | ```typescript 34 | type User = { firstName: string; age: number }; 35 | 36 | const user = define({ 37 | firstName: (i: number) => `Bob #${i}`, 38 | age: (i: number) => i * 42 39 | }); 40 | ``` 41 | 42 | ### Composing factories 43 | 44 | Factories can also reference other factories. In this example, a `Post` has 45 | a `User`. We could manually define this `User`, but since we likely already have 46 | a `User` factory for elsewhere, we can reference it. 47 | 48 | ```typescript 49 | type User = { firstName: string; age: number }; 50 | type Post = { title: string; user: User }; 51 | 52 | const user = define({ 53 | firstName: "Bob", 54 | age: 42 55 | }); 56 | 57 | const post = define({ 58 | title: "The Best Post Ever", 59 | user 60 | }); 61 | ``` 62 | 63 | ?> **TIP:** Name factories the same as attributes that reference that type to leverage 64 | [ES6 Object Punning](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#New_notations_in_ECMAScript_2015). 65 | For example, the user factory above. 66 | -------------------------------------------------------------------------------- /docs/derive.md: -------------------------------------------------------------------------------- 1 | # Deriving values 2 | 3 | The `cooky-cutter` package provides a `derive` method to compute a single value 4 | and assign it to the attribute based off any number of other attributes defined 5 | in the factory. This is useful for deriving a fields value off of other dynamic 6 | field(s) that are not known until a factory is invoked. The order does not 7 | matter, dependent attributes will be calculated first. 8 | 9 | !> A derived field can reference other derived fields, but they **cannot be 10 | circularly referenced.** 11 | 12 | ## Usage 13 | 14 | ### Basic example 15 | 16 | ```typescript 17 | type User = { 18 | firstName: string; 19 | lastName: string; 20 | fullName: string; 21 | }; 22 | 23 | const user = define({ 24 | firstName: "Bob", 25 | lastName: "Smith", 26 | fullName: derive( 27 | ({ firstName, lastName }) => `${firstName} ${lastName}`, 28 | "firstName", 29 | "lastName" 30 | ) 31 | }); 32 | 33 | user().fullName; // "Bob Smith" 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/extend.md: -------------------------------------------------------------------------------- 1 | # Extending factories 2 | 3 | The `cooky-cutter` package provides an `extend` method to extend an existing 4 | factory (created using `define` or possibly even `extend`). The `extend` method 5 | receives a base factory to extend and a second argument to 6 | [configure](api#configuration-object) additional fields or override the base factory 7 | fields. The configuration object behavior is identical to [`define`](define). 8 | 9 | ## Usage 10 | 11 | ### Basic example 12 | 13 | In this example, each invocation of the `user` factory will return an object 14 | with `firstName`, `age` **and** with a random `id`. 15 | 16 | ```typescript 17 | type Model = { id: number }; 18 | type User = { firstName: string; age: number } & Model; 19 | 20 | const model = define({ 21 | id: random 22 | }); 23 | 24 | const user = extend(model, { 25 | firstName: (i: number) => `Bob #${i}`, 26 | age: 42 27 | }); 28 | ``` 29 | 30 | ### Overriding base factories 31 | 32 | In addition to defining new attributes with the config method, existing base 33 | factory attributes can be overridden. For example, the entity `type` field 34 | has a generic value defined in the base `model` factory, but is more specific 35 | in the `user` factory. 36 | 37 | ```typescript 38 | type Model = { id: number; type: string }; 39 | type User = { firstName: string; age: number } & Model; 40 | 41 | const model = define({ 42 | id: sequence, 43 | type: "BaseModel" 44 | }); 45 | 46 | const user = extend(model, { 47 | firstName: "Bob", 48 | age: 42, 49 | type: "User" 50 | }); 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/helpers.md: -------------------------------------------------------------------------------- 1 | # Helpers 2 | 3 | The `cooky-cutter` package provides a few utility methods for common patterns 4 | when defining factory attributes. 5 | 6 | ## sequence 7 | 8 | This helper increments the value on each factory invocation starting at `1`. 9 | 10 | ### Example 11 | 12 | In this example, `age` will be `1` the first time `user` is invoked, `2` the 13 | second, `3` the third and so on. 14 | 15 | ```typescript 16 | type User = { age: number }; 17 | 18 | const user = define({ 19 | age: sequence 20 | }); 21 | ``` 22 | 23 | To reset this value (if a test is dependent on starting at `1`) the 24 | `resetSequence` function can be invoked on the factory to reset the sequence 25 | back to `1`. 26 | 27 | ```typescript 28 | user(); // { age: 1 } 29 | user(); // { age: 2 } 30 | 31 | user.resetSequence(); 32 | 33 | user(); // { age: 1 } 34 | user(); // { age: 2 } 35 | ``` 36 | 37 | ## random 38 | 39 | This helpers assigns a random positive integer on each factory invocation. 40 | 41 | ?> **TIP:** If you have an integer `id` (or equivalent unique identifier) use 42 | `random` to avoid bugs and fragile tests due to sequential unique identifiers. 43 | 44 | ### Example 45 | 46 | In this example, `age` could be any positive integer (eg: `980711200` ). 47 | 48 | ```typescript 49 | type User = { age: number }; 50 | 51 | const user = define({ 52 | age: random 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | cooky-cutter - Object factories for testing in TypeScript 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/project-setup.md: -------------------------------------------------------------------------------- 1 | # Project setup 2 | 3 | The `cooky-cutter` package is flexible and can be used in any way you'd like. 4 | However, this section of the documentation will offer a _suggested_ project 5 | setup following patterns that work well. 6 | 7 | ## Extending 8 | 9 | Many projects with entities or data models often have a unique identifer or 10 | other attributes that are shared across all entities. For example, maybe a 11 | `type` property, or a `createdAt` timestamp. If this is the case, consider 12 | creating a "base" factory that all other factories can extend. 13 | 14 | **`model.ts`** 15 | 16 | ```typescript 17 | import { define, random } from "cooky-cutter"; 18 | 19 | import { BaseModel } from "models"; 20 | 21 | export const model = define({ 22 | id: random, 23 | createdAt: new Date() 24 | }); 25 | ``` 26 | 27 | **`user.ts`** 28 | 29 | ```typescript 30 | import { extend, sequence } from "cooky-cutter"; 31 | 32 | import { User, BaseModel } from "models"; 33 | import { model } from "./model"; 34 | 35 | export const user = extend(model, { 36 | type: "User", 37 | name: "Joe", 38 | age: sequence 39 | }); 40 | ``` 41 | 42 | ## Naming 43 | 44 | As with everything in this section, you're free to name things in any way you'd 45 | like, but this convention can work well in larger projects. In the above 46 | examples, both factories were camel cased and the name of the entity as opposed 47 | to appending factory (eg: `userFactory`). This is useful for composing factories 48 | and making the configuration very declartive. For example, a post with a user 49 | is a single line with [ES6 Object Punning](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#New_notations_in_ECMAScript_2015). 50 | 51 | **`post.ts`** 52 | 53 | ```typescript 54 | import { extend, sequence } from "cooky-cutter"; 55 | 56 | import { Post, BaseModel } from "models"; 57 | import { model } from "./model"; 58 | import { user } from "./user"; 59 | 60 | export const post = extend(model, { 61 | type: "Post", 62 | title: "A fun post", 63 | user 64 | }); 65 | ``` 66 | 67 | ## Directory structure 68 | 69 | Lastly, consider containing all factories within a single directory 70 | (eg: `factories`) with an `index` file that exports all factories. This is 71 | useful for tests that need multiple factories. They can all be imported with 72 | a single line: 73 | 74 | ```typescript 75 | import { user, post } from "factories"; 76 | ``` 77 | 78 | Additionally, it makes it easy to import factories into other factories and 79 | compose them. Using the above examples and following this approach, the directory 80 | structure would look like the following. 81 | 82 | ``` 83 | . 84 | └── factories 85 | ├── index.ts 86 | ├── model.ts 87 | ├── post.ts 88 | └── user.ts 89 | ``` 90 | -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | ## Installation 4 | 5 | Install `cooky-cutter` as a development dependency with your favorite package 6 | manager. 7 | 8 | ```bash 9 | npm install --save-dev cooky-cutter 10 | # or 11 | yarn add --dev cooky-cutter 12 | ``` 13 | 14 | ## Usage 15 | 16 | To define a factory for a given type, import `define`. The only argument 17 | is a required config object that matches that shape of the type. Each attribute 18 | can be a hardcoded value, a function that receives the invocation count as 19 | the only argument or another factory. Additionally, there are utility helpers 20 | like `random` and `sequence` for common factory attributes. 21 | 22 | ```typescript 23 | import { define, random, sequence } from "cooky-cutter"; 24 | 25 | // Define an interface (or type) for the entity that the factory will represent 26 | interface User { 27 | id: number; 28 | firstName: string; 29 | lastName: string; 30 | age: number; 31 | } 32 | 33 | // Define a factory that represents the defined interface 34 | const user = define({ 35 | id: random, // This will be a random integer each invocation 36 | firstName: i => `Bob #${i}`, // `i` will be incremented each invocation 37 | lastName: "Smith", // This will always be the hardcoded value 38 | age: sequence // This will increment each invocation 39 | }); 40 | 41 | // Invoke the factory a few times, see example outputs below... 42 | console.log(user()); 43 | // => { id: 980711200, firstName: 'Bob #1', lastName: 'Smith', age: 1 } 44 | 45 | console.log(user()); 46 | // => { id: 1345667839, firstName: 'Bob #2', lastName: 'Smith', age: 2 } 47 | 48 | console.log(user()); 49 | // => { id: 796816401, firstName: 'Bob #3', lastName: 'Smith', age: 3 } 50 | ``` 51 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 7 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"] 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cooky-cutter", 3 | "version": "1.4.0", 4 | "description": "Object factories for testing in TypeScript", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "keywords": [ 8 | "typescript", 9 | "test", 10 | "testing", 11 | "factory", 12 | "factories" 13 | ], 14 | "scripts": { 15 | "build": "tsc", 16 | "test": "jest --coverage", 17 | "check-types": "yarn run build --noEmit", 18 | "formatting:check": "./node_modules/.bin/prettier --list-different \"src/**/*.ts*\"", 19 | "formatting:write": "./node_modules/.bin/prettier --write \"src/**/*.ts*\"", 20 | "coverage": "nyc report --temp-directory=coverage --reporter=text-lcov | coveralls", 21 | "docs": "docsify serve docs", 22 | "prepack": "yarn build", 23 | "commit": "commit" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/skovy/cooky-cutter.git" 28 | }, 29 | "author": "Spencer Miskoviak", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/skovy/cooky-cutter/issues" 33 | }, 34 | "homepage": "https://skovy.github.io/cooky-cutter/", 35 | "devDependencies": { 36 | "@commitlint/cli": "^11.0.0", 37 | "@commitlint/config-conventional": "^11.0.0", 38 | "@commitlint/prompt-cli": "^11.0.0", 39 | "@commitlint/travis-cli": "^11.0.0", 40 | "@types/jest": "^26.0.19", 41 | "coveralls": "^3.0.2", 42 | "docsify-cli": "^4.4.2", 43 | "husky": "^4.3.6", 44 | "jest": "^26.6.3", 45 | "nyc": "^15.1.0", 46 | "prettier": "2.2.1", 47 | "pretty-quick": "^3.1.0", 48 | "semantic-release": "^17.3.0", 49 | "ts-jest": "^26.4.4", 50 | "typescript": "^4.1.3" 51 | }, 52 | "husky": { 53 | "hooks": { 54 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 55 | "pre-commit": "pretty-quick --staged" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/__tests__/array.test.ts: -------------------------------------------------------------------------------- 1 | import { define, random, sequence, extend } from "../index"; 2 | import { array } from "../array"; 3 | 4 | type User = { firstName: string; age: number; admin?: boolean }; 5 | type Post = { title: string; user: User }; 6 | type UsersCollection = { users: User[]; role: string }; 7 | type Model = { id: number }; 8 | type Record = { id: number; value: string }; 9 | type Thing = { id: number; name: string; users: User[] }; 10 | 11 | describe("array", () => { 12 | test("returns array with 5 elements by default", () => { 13 | const user = define({ 14 | age: random, 15 | firstName: "Mike", 16 | }); 17 | 18 | const users = array(user); 19 | expect(Array.isArray(users())).toBe(true); 20 | expect(users().length).toBe(5); 21 | }); 22 | 23 | test("allows overriding array size", () => { 24 | const user = define({ 25 | age: random, 26 | firstName: "Mike", 27 | }); 28 | 29 | const users = array(user, 10); 30 | expect(users().length).toBe(10); 31 | }); 32 | 33 | test("calls underlying factories during generation", () => { 34 | const user = define({ 35 | age: sequence, 36 | firstName: (i: number) => `Mike ${i}`, 37 | }); 38 | 39 | const users = array(user, 2); 40 | const first = users(); 41 | const second = users(); 42 | 43 | expect(first[0]).toEqual({ firstName: "Mike 1", age: 1 }); 44 | expect(first[1]).toEqual({ firstName: "Mike 2", age: 2 }); 45 | expect(second[0]).toEqual({ firstName: "Mike 3", age: 3 }); 46 | expect(second[1]).toEqual({ firstName: "Mike 4", age: 4 }); 47 | }); 48 | 49 | test("allows overriding underlying factory", () => { 50 | const user = define({ 51 | age: 30, 52 | firstName: "Mike", 53 | }); 54 | 55 | const users = array(user, 1); 56 | expect(users({ firstName: "Mickey", admin: true })[0]).toEqual({ 57 | firstName: "Mickey", 58 | age: 30, 59 | admin: true, 60 | }); 61 | expect(users({ firstName: "M", age: 20 })[0]).toEqual({ 62 | firstName: "M", 63 | age: 20, 64 | }); 65 | }); 66 | 67 | test("allows compound factories", () => { 68 | const user = define({ 69 | age: 30, 70 | firstName: "Mike", 71 | }); 72 | const post = define({ 73 | user, 74 | title: "Test title", 75 | }); 76 | 77 | const posts = array(post); 78 | expect(posts().length).toBe(5); 79 | expect(posts()[0]).toEqual({ 80 | title: "Test title", 81 | user: { age: 30, firstName: "Mike" }, 82 | }); 83 | }); 84 | 85 | test("can be used inline with define", () => { 86 | const user = define({ 87 | age: 30, 88 | firstName: "Mike", 89 | }); 90 | const moderators = define({ 91 | role: "moderator", 92 | users: array(user, 2), 93 | }); 94 | 95 | expect(moderators().users.length).toBe(2); 96 | }); 97 | 98 | test("allows overriding initial config", () => { 99 | const user = define({ 100 | age: 30, 101 | firstName: "Mike", 102 | }); 103 | const moderators = define({ 104 | role: "moderator", 105 | users: array(user, 2), 106 | }); 107 | 108 | expect(moderators().users.length).toBe(2); 109 | expect(moderators({ users: array(user, 3) }).users.length).toBe(3); 110 | }); 111 | 112 | test("allows extending an existing factory and use an array factory", () => { 113 | const model = define({ 114 | id: sequence, 115 | }); 116 | 117 | const user = define({ 118 | age: random, 119 | firstName: "Mike", 120 | }); 121 | 122 | const thing = extend(model, { 123 | name: "Some Thing", 124 | users: array(user, 3), 125 | }); 126 | 127 | const value = thing(); 128 | expect(value.id).toBe(1); 129 | expect(value.name).toBe("Some Thing"); 130 | expect(value.users).toHaveLength(3); 131 | }); 132 | 133 | test("allows creating an array factory from extend", () => { 134 | const model = define({ 135 | id: sequence, 136 | }); 137 | 138 | const record = extend(model, { 139 | value: "foo", 140 | }); 141 | 142 | const records = array(record, 2); 143 | expect(records()).toHaveLength(2); 144 | }); 145 | 146 | test("readonly arrays", () => { 147 | type Bar = { 148 | index: number; 149 | }; 150 | 151 | type Foo = { 152 | readonly bars: ReadonlyArray; 153 | }; 154 | 155 | const barFactory = define({ index: sequence }); 156 | 157 | const fooFactory = define({ 158 | bars: array(barFactory, 2), 159 | }); 160 | 161 | expect(fooFactory().bars).toHaveLength(2); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/__tests__/define.test.ts: -------------------------------------------------------------------------------- 1 | import { define, derive, configure, sequence } from "../"; 2 | 3 | type User = { firstName: string; age: number; admin?: boolean }; 4 | type Post = { title: string; user: User; tags?: string[] }; 5 | 6 | describe("define", () => { 7 | let warnSpy: jest.SpyInstance; 8 | 9 | beforeEach(() => { 10 | warnSpy = jest.spyOn(console, "warn").mockImplementation(); 11 | }); 12 | 13 | afterEach(() => { 14 | warnSpy.mockRestore(); 15 | }); 16 | 17 | test("handles hard-coded attributes", () => { 18 | const user = define({ 19 | firstName: "Bob", 20 | age: 42, 21 | }); 22 | 23 | expect(user()).toEqual({ 24 | firstName: "Bob", 25 | age: 42, 26 | }); 27 | 28 | expect(warnSpy).not.toHaveBeenCalled(); 29 | }); 30 | 31 | test("handles functional attributes", () => { 32 | const user = define({ 33 | firstName: () => "Bob", 34 | age: () => 42, 35 | }); 36 | 37 | expect(user()).toEqual({ 38 | firstName: "Bob", 39 | age: 42, 40 | }); 41 | 42 | expect(warnSpy).not.toHaveBeenCalled(); 43 | }); 44 | 45 | test("returns a factory that returns a new instance each invocation", () => { 46 | const user = define({ 47 | firstName: () => "Bob", 48 | age: () => 42, 49 | }); 50 | 51 | const firstInvocation = user(); 52 | const secondInvocation = user(); 53 | 54 | expect(firstInvocation).not.toBe(secondInvocation); 55 | 56 | expect(warnSpy).not.toHaveBeenCalled(); 57 | }); 58 | 59 | test("passes the number of invocations to functional attributes", () => { 60 | const user = define({ 61 | firstName: (i: number) => `Bob #${i}`, 62 | age: (i: number) => i * 42, 63 | }); 64 | 65 | // First invocation 66 | expect(user()).toEqual({ 67 | firstName: "Bob #1", 68 | age: 42, 69 | }); 70 | 71 | // Second invocation 72 | expect(user()).toEqual({ 73 | firstName: "Bob #2", 74 | age: 84, 75 | }); 76 | 77 | // Third invocation 78 | expect(user()).toEqual({ 79 | firstName: "Bob #3", 80 | age: 126, 81 | }); 82 | 83 | expect(warnSpy).not.toHaveBeenCalled(); 84 | }); 85 | 86 | test("handles nested factories", () => { 87 | const user = define({ 88 | firstName: "Bob", 89 | age: 42, 90 | }); 91 | 92 | const post = define({ 93 | title: "The Best Post Ever", 94 | user, 95 | }); 96 | 97 | expect(post()).toEqual({ 98 | title: "The Best Post Ever", 99 | user: { 100 | firstName: "Bob", 101 | age: 42, 102 | }, 103 | }); 104 | 105 | expect(warnSpy).not.toHaveBeenCalled(); 106 | }); 107 | 108 | test("allows overriding the initial config", () => { 109 | const user = define({ 110 | firstName: "Bob", 111 | age: 42, 112 | }); 113 | 114 | expect(user({ firstName: "Sarah" })).toEqual({ 115 | firstName: "Sarah", 116 | age: 42, 117 | }); 118 | 119 | expect(user({ age: (i: number) => i })).toEqual({ 120 | firstName: "Bob", 121 | age: 2, 122 | }); 123 | 124 | expect(user({ firstName: "Jill", age: 43 })).toEqual({ 125 | firstName: "Jill", 126 | age: 43, 127 | }); 128 | 129 | expect(warnSpy).not.toHaveBeenCalled(); 130 | }); 131 | 132 | test("allows defining optional attributes as overrides", () => { 133 | const user = define({ 134 | firstName: "Bob", 135 | age: 42, 136 | }); 137 | 138 | expect(user({ firstName: "Sarah", admin: true })).toEqual({ 139 | firstName: "Sarah", 140 | age: 42, 141 | admin: true, 142 | }); 143 | 144 | expect(warnSpy).not.toHaveBeenCalled(); 145 | }); 146 | 147 | test("allows overriding with 'falsy' values", () => { 148 | const user = define({ 149 | firstName: "Bob", 150 | age: 42, 151 | admin: true, 152 | }); 153 | 154 | expect(user({ firstName: undefined, admin: false, age: 0 })).toEqual({ 155 | firstName: undefined, 156 | admin: false, 157 | age: 0, 158 | }); 159 | 160 | expect(warnSpy).not.toHaveBeenCalled(); 161 | }); 162 | 163 | describe("resetSequence", () => { 164 | interface Task { 165 | position: number; 166 | } 167 | 168 | test("it resets the sequence value", () => { 169 | // A Task can have a position within a list 170 | const task = define({ 171 | position: sequence, 172 | }); 173 | 174 | expect(task()).toHaveProperty("position", 1); 175 | expect(task()).toHaveProperty("position", 2); 176 | expect(task()).toHaveProperty("position", 3); 177 | 178 | task.resetSequence(); 179 | 180 | expect(task()).toHaveProperty("position", 1); 181 | expect(task()).toHaveProperty("position", 2); 182 | expect(task()).toHaveProperty("position", 3); 183 | 184 | expect(warnSpy).not.toHaveBeenCalled(); 185 | }); 186 | }); 187 | 188 | describe("hard-coded values", () => { 189 | test("warns about objects", () => { 190 | const post = define({ 191 | title: "The Best Post Ever", 192 | user: { 193 | firstName: "Hard-coded", 194 | age: 1, 195 | }, 196 | }); 197 | 198 | const firstPost = post(); 199 | expect(firstPost).toEqual({ 200 | title: "The Best Post Ever", 201 | user: { 202 | firstName: "Hard-coded", 203 | age: 1, 204 | }, 205 | }); 206 | 207 | expect(warnSpy).toBeCalledWith( 208 | "`user` contains a hard-coded object. It will be shared across all instances of this factory. Consider using a factory function." 209 | ); 210 | 211 | // When this warning is ignored, this will be the "expected" (accepted) behavior. 212 | firstPost.user.firstName = "Joe"; 213 | expect(post().user.firstName).toEqual("Joe"); 214 | }); 215 | 216 | test("warns about arrays", () => { 217 | const user = define({ 218 | firstName: "Bob", 219 | age: 42, 220 | }); 221 | 222 | const post = define({ 223 | title: "The Best Post Ever", 224 | user, 225 | tags: ["popular", "trending"], 226 | }); 227 | 228 | const firstPost = post(); 229 | expect(firstPost).toEqual({ 230 | title: "The Best Post Ever", 231 | tags: ["popular", "trending"], 232 | user: { 233 | firstName: "Bob", 234 | age: 42, 235 | }, 236 | }); 237 | 238 | expect(warnSpy).toBeCalledWith( 239 | "`tags` contains a hard-coded array. It will be shared across all instances of this factory. Consider using a factory function." 240 | ); 241 | 242 | // When this warning is ignored, this will be the "expected" (accepted) behavior. 243 | firstPost.tags!.push("YOLO"); 244 | expect(post().tags).toEqual(["popular", "trending", "YOLO"]); 245 | }); 246 | 247 | test("does not warn about objects or arrays as overrides", () => { 248 | const user = define({ 249 | firstName: "Bob", 250 | age: 42, 251 | }); 252 | 253 | const post = define({ 254 | title: "The Best Post Ever", 255 | user, 256 | }); 257 | 258 | const firstPost = post({ 259 | user: { 260 | firstName: "Hard-coded", 261 | age: 1, 262 | }, 263 | tags: ["popular", "trending"], 264 | }); 265 | 266 | expect(firstPost).toEqual({ 267 | title: "The Best Post Ever", 268 | user: { 269 | firstName: "Hard-coded", 270 | age: 1, 271 | }, 272 | tags: ["popular", "trending"], 273 | }); 274 | 275 | expect(warnSpy).not.toHaveBeenCalled(); 276 | }); 277 | 278 | test("does not warn when using derive with an override value", () => { 279 | interface Child { 280 | id: number; 281 | } 282 | 283 | interface Parent { 284 | childId: number | null; 285 | child?: Child; 286 | } 287 | 288 | const parent = define({ 289 | childId: derive( 290 | ({ child }) => (child ? child.id : null), 291 | "child" 292 | ), 293 | }); 294 | 295 | parent({ 296 | child: { id: 1 }, 297 | }); 298 | 299 | expect(warnSpy).not.toHaveBeenCalled(); 300 | }); 301 | 302 | describe("with errorOnHardCodedValues enabled", () => { 303 | let traceSpy: jest.SpyInstance; 304 | 305 | beforeEach(() => { 306 | configure({ errorOnHardCodedValues: true }); 307 | 308 | traceSpy = jest.spyOn(console, "trace").mockImplementation(); 309 | }); 310 | 311 | afterEach(() => { 312 | traceSpy.mockRestore(); 313 | }); 314 | 315 | test("throws about objects", () => { 316 | const post = define({ 317 | title: "The Best Post Ever", 318 | user: { 319 | firstName: "Hard-coded", 320 | age: 1, 321 | }, 322 | }); 323 | 324 | expect(() => { 325 | post(); 326 | }).toThrow( 327 | "`user` contains a hard-coded object. It will be shared across all instances of this factory. Consider using a factory function." 328 | ); 329 | expect(traceSpy).toHaveBeenCalled(); 330 | }); 331 | 332 | test("throws about arrays", () => { 333 | const user = define({ 334 | firstName: "Bob", 335 | age: 42, 336 | }); 337 | 338 | const post = define({ 339 | title: "The Best Post Ever", 340 | user, 341 | tags: ["popular", "trending"], 342 | }); 343 | 344 | expect(() => { 345 | post(); 346 | }).toThrow( 347 | "`tags` contains a hard-coded array. It will be shared across all instances of this factory. Consider using a factory function." 348 | ); 349 | expect(traceSpy).toHaveBeenCalled(); 350 | }); 351 | }); 352 | }); 353 | }); 354 | -------------------------------------------------------------------------------- /src/__tests__/derive.test.ts: -------------------------------------------------------------------------------- 1 | import { define, derive, sequence, extend } from "../index"; 2 | 3 | type Model = { id: number }; 4 | type Post = { title: string } & Model; 5 | type User = { 6 | firstName: string; 7 | lastName: string; 8 | fullName: string; 9 | age: number; 10 | }; 11 | 12 | describe("derive", () => { 13 | test("computes derived attributes", () => { 14 | const user = define({ 15 | firstName: "Bob", 16 | lastName: "Smith", 17 | age: 3, 18 | fullName: derive( 19 | ({ firstName, lastName, age }) => `${firstName} ${lastName} ${age}`, 20 | "firstName", 21 | "lastName", 22 | "age" 23 | ), 24 | }); 25 | 26 | expect(user()).toEqual({ 27 | firstName: "Bob", 28 | lastName: "Smith", 29 | age: 3, 30 | fullName: "Bob Smith 3", 31 | }); 32 | }); 33 | 34 | test("computes derived attributes using extend", () => { 35 | const model = define({ 36 | id: sequence, 37 | }); 38 | 39 | const post = extend(model, { 40 | title: derive(({ id }) => `Post ${id}`, "id"), 41 | }); 42 | 43 | expect(post()).toEqual({ 44 | id: 1, 45 | title: "Post 1", 46 | }); 47 | }); 48 | 49 | test("computes derived attributes when dependent attributes have not been evaluated", () => { 50 | const user = define({ 51 | firstName: "Bob", 52 | fullName: derive( 53 | ({ firstName, lastName, age }) => `${firstName} ${lastName} ${age}`, 54 | "firstName", 55 | "lastName", 56 | "age" 57 | ), 58 | lastName: "Smith", 59 | age: 3, 60 | }); 61 | 62 | expect(user()).toEqual({ 63 | firstName: "Bob", 64 | lastName: "Smith", 65 | age: 3, 66 | fullName: "Bob Smith 3", 67 | }); 68 | }); 69 | 70 | test("computes derived attributes dependent on other derived attributes", () => { 71 | const user = define({ 72 | age: sequence, 73 | firstName: derive(({ age }) => `Bob ${age}`, "age"), 74 | fullName: derive( 75 | ({ firstName, lastName }) => `${firstName} ${lastName}`, 76 | "firstName", 77 | "lastName" 78 | ), 79 | lastName: derive(({ age }) => `Smith ${age}`, "age"), 80 | }); 81 | 82 | expect(user()).toEqual({ 83 | age: 1, 84 | firstName: "Bob 1", 85 | lastName: "Smith 1", 86 | fullName: "Bob 1 Smith 1", 87 | }); 88 | 89 | expect(user()).toEqual({ 90 | age: 2, 91 | firstName: "Bob 2", 92 | lastName: "Smith 2", 93 | fullName: "Bob 2 Smith 2", 94 | }); 95 | }); 96 | 97 | test("throws on circularly derived fields at runtime", () => { 98 | const user = define({ 99 | age: sequence, 100 | fullName: derive( 101 | ({ lastName }) => `${lastName}`, 102 | "lastName" 103 | ), 104 | lastName: derive( 105 | ({ firstName }) => `${firstName} Smith`, 106 | "firstName" 107 | ), 108 | firstName: derive( 109 | ({ fullName }) => `Bob ${fullName}`, 110 | "fullName" 111 | ), 112 | }); 113 | 114 | expect(() => { 115 | user(); 116 | }).toThrowError( 117 | "lastName cannot circularly derive itself. Check along this path: lastName->firstName->fullName->lastName" 118 | ); 119 | }); 120 | 121 | test("only invokes a function once when deriving", () => { 122 | interface Child { 123 | id: number; 124 | } 125 | 126 | interface Parent { 127 | childId: number | null; 128 | child?: Child; 129 | } 130 | 131 | const child = define({ 132 | id: sequence, 133 | }); 134 | 135 | const parent = define({ 136 | childId: derive( 137 | ({ child }) => (child ? child.id : null), 138 | "child" 139 | ), 140 | }); 141 | 142 | expect( 143 | parent({ 144 | child, 145 | }) 146 | ).toEqual({ 147 | childId: 1, 148 | child: { 149 | id: 1, 150 | }, 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/__tests__/extend.test.ts: -------------------------------------------------------------------------------- 1 | import { define, extend, sequence, configure } from "../index"; 2 | 3 | type Model = { id: number; type?: string }; 4 | type User = { firstName: string; age: number; admin?: boolean } & Model; 5 | type Post = { title: string; user: User; tags?: string[] } & Model; 6 | 7 | describe("extend", () => { 8 | let warnSpy: jest.SpyInstance; 9 | 10 | beforeEach(() => { 11 | warnSpy = jest.spyOn(console, "warn").mockImplementation(); 12 | }); 13 | 14 | afterEach(() => { 15 | warnSpy.mockRestore(); 16 | }); 17 | 18 | test("allows extending an existing factory", () => { 19 | const model = define({ 20 | id: sequence, 21 | }); 22 | 23 | const user = extend(model, { 24 | firstName: (i: number) => `Bob #${i}`, 25 | age: 42, 26 | }); 27 | 28 | expect(user()).toEqual({ 29 | id: 1, 30 | firstName: "Bob #1", 31 | age: 42, 32 | }); 33 | 34 | expect(user()).toEqual({ 35 | id: 2, 36 | firstName: "Bob #2", 37 | age: 42, 38 | }); 39 | 40 | expect(warnSpy).not.toHaveBeenCalled(); 41 | }); 42 | 43 | test("allows overriding both the factory and the config", () => { 44 | const model = define({ 45 | id: sequence, 46 | }); 47 | 48 | const user = extend(model, { 49 | firstName: "Bob", 50 | age: 42, 51 | }); 52 | 53 | expect(user({ id: -1, firstName: "Jill" })).toEqual({ 54 | id: -1, 55 | firstName: "Jill", 56 | age: 42, 57 | }); 58 | 59 | expect(warnSpy).not.toHaveBeenCalled(); 60 | }); 61 | 62 | test("allows overriding with 'falsy' values", () => { 63 | const model = define({ 64 | id: sequence, 65 | }); 66 | 67 | const user = extend(model, { 68 | firstName: "Bob", 69 | age: 42, 70 | admin: true, 71 | }); 72 | 73 | expect(user({ firstName: undefined, admin: false, age: 0 })).toEqual({ 74 | id: 1, 75 | firstName: undefined, 76 | admin: false, 77 | age: 0, 78 | }); 79 | 80 | expect(warnSpy).not.toHaveBeenCalled(); 81 | }); 82 | 83 | test("allows overriding the factory with the config", () => { 84 | const model = define({ 85 | id: sequence, 86 | type: "BaseModel", 87 | }); 88 | 89 | const user = extend(model, { 90 | firstName: "Bob", 91 | age: 42, 92 | type: "User", 93 | }); 94 | 95 | expect(user()).toEqual({ 96 | id: 1, 97 | firstName: "Bob", 98 | age: 42, 99 | type: "User", 100 | }); 101 | 102 | expect(warnSpy).not.toHaveBeenCalled(); 103 | }); 104 | 105 | test("allows extending the same factory multiple times", () => { 106 | const model = define({ 107 | id: sequence, 108 | }); 109 | 110 | const user = extend(model, { 111 | firstName: "Bob", 112 | age: 42, 113 | }); 114 | 115 | const post = extend(model, { 116 | title: (i: number) => `My Post #${i}`, 117 | user, 118 | }); 119 | 120 | expect(post()).toEqual({ 121 | id: 1, 122 | title: "My Post #1", 123 | user: { 124 | id: 2, 125 | firstName: "Bob", 126 | age: 42, 127 | }, 128 | }); 129 | 130 | expect(user()).toEqual({ 131 | id: 3, 132 | firstName: "Bob", 133 | age: 42, 134 | }); 135 | 136 | expect(warnSpy).not.toHaveBeenCalled(); 137 | }); 138 | 139 | test("allows defining optional attributes as overrides", () => { 140 | const model = define({ 141 | id: sequence, 142 | }); 143 | 144 | const user = extend(model, { 145 | firstName: "Bob", 146 | age: 42, 147 | }); 148 | 149 | expect(user({ admin: true })).toEqual({ 150 | id: 1, 151 | firstName: "Bob", 152 | age: 42, 153 | admin: true, 154 | }); 155 | 156 | expect(warnSpy).not.toHaveBeenCalled(); 157 | }); 158 | 159 | describe("resetSequence", () => { 160 | interface BaseTask { 161 | id: number; 162 | } 163 | 164 | interface Task { 165 | id: number; 166 | position: number; 167 | } 168 | 169 | test("it resets the sequence value for only the extended factory (not the base factory)", () => { 170 | const baseTask = define({ 171 | id: sequence, 172 | }); 173 | 174 | // A Task can have a position within a list 175 | const task = extend(baseTask, { 176 | position: sequence, 177 | }); 178 | 179 | expect(task()).toEqual({ id: 1, position: 1 }); 180 | expect(task()).toEqual({ id: 2, position: 2 }); 181 | 182 | task.resetSequence(); 183 | 184 | expect(task()).toEqual({ id: 3, position: 1 }); 185 | expect(task()).toEqual({ id: 4, position: 2 }); 186 | 187 | baseTask.resetSequence(); 188 | task.resetSequence(); 189 | 190 | expect(task()).toEqual({ id: 1, position: 1 }); 191 | expect(task()).toEqual({ id: 2, position: 2 }); 192 | 193 | expect(warnSpy).not.toHaveBeenCalled(); 194 | }); 195 | }); 196 | 197 | describe("hard-coded values", () => { 198 | test("warns about objects", () => { 199 | const model = define({ 200 | id: sequence, 201 | }); 202 | 203 | const post = extend(model, { 204 | title: "The Best Post Ever", 205 | user: { 206 | id: 1, 207 | firstName: "Hard-coded", 208 | age: 1, 209 | }, 210 | }); 211 | 212 | const firstPost = post(); 213 | expect(firstPost).toEqual({ 214 | id: 1, 215 | title: "The Best Post Ever", 216 | user: { 217 | id: 1, 218 | firstName: "Hard-coded", 219 | age: 1, 220 | }, 221 | }); 222 | 223 | expect(warnSpy).toBeCalledWith( 224 | "`user` contains a hard-coded object. It will be shared across all instances of this factory. Consider using a factory function." 225 | ); 226 | 227 | // When this warning is ignored, this will be the "expected" (accepted) behavior. 228 | firstPost.user.firstName = "Joe"; 229 | expect(post().user.firstName).toEqual("Joe"); 230 | }); 231 | 232 | test("warns about arrays", () => { 233 | const model = define({ 234 | id: sequence, 235 | }); 236 | 237 | const user = extend(model, { 238 | firstName: "Bob", 239 | age: 42, 240 | }); 241 | 242 | const post = extend(model, { 243 | title: "The Best Post Ever", 244 | user, 245 | tags: ["popular", "trending"], 246 | }); 247 | 248 | const firstPost = post(); 249 | expect(firstPost).toEqual({ 250 | id: 1, 251 | title: "The Best Post Ever", 252 | tags: ["popular", "trending"], 253 | user: { 254 | id: 2, 255 | firstName: "Bob", 256 | age: 42, 257 | }, 258 | }); 259 | 260 | expect(warnSpy).toBeCalledWith( 261 | "`tags` contains a hard-coded array. It will be shared across all instances of this factory. Consider using a factory function." 262 | ); 263 | 264 | // When this warning is ignored, this will be the "expected" (accepted) behavior. 265 | firstPost.tags!.push("YOLO"); 266 | expect(post().tags).toEqual(["popular", "trending", "YOLO"]); 267 | }); 268 | 269 | test("does not warn about objects or arrays as overrides", () => { 270 | const model = define({ 271 | id: sequence, 272 | }); 273 | 274 | const user = extend(model, { 275 | firstName: "Bob", 276 | age: 42, 277 | }); 278 | 279 | const post = extend(model, { 280 | title: "The Best Post Ever", 281 | user, 282 | }); 283 | 284 | const firstPost = post({ 285 | user: { 286 | id: 2, 287 | firstName: "Hard-coded", 288 | age: 1, 289 | }, 290 | tags: ["popular", "trending"], 291 | }); 292 | 293 | expect(firstPost).toEqual({ 294 | id: 1, 295 | title: "The Best Post Ever", 296 | user: { 297 | id: 2, 298 | firstName: "Hard-coded", 299 | age: 1, 300 | }, 301 | tags: ["popular", "trending"], 302 | }); 303 | 304 | expect(warnSpy).not.toHaveBeenCalled(); 305 | }); 306 | 307 | describe("with errorOnHardCodedValues enabled", () => { 308 | let traceSpy: jest.SpyInstance; 309 | 310 | beforeEach(() => { 311 | configure({ errorOnHardCodedValues: true }); 312 | 313 | traceSpy = jest.spyOn(console, "trace").mockImplementation(); 314 | }); 315 | 316 | afterEach(() => { 317 | traceSpy.mockRestore(); 318 | }); 319 | 320 | test("throws about objects", () => { 321 | const model = define({ 322 | id: sequence, 323 | }); 324 | 325 | const post = extend(model, { 326 | title: "The Best Post Ever", 327 | user: { 328 | id: 1, 329 | firstName: "Hard-coded", 330 | age: 1, 331 | }, 332 | }); 333 | 334 | expect(() => { 335 | post(); 336 | }).toThrow( 337 | "`user` contains a hard-coded object. It will be shared across all instances of this factory. Consider using a factory function." 338 | ); 339 | expect(traceSpy).toHaveBeenCalled(); 340 | }); 341 | 342 | test("throws about arrays", () => { 343 | const model = define({ 344 | id: sequence, 345 | }); 346 | 347 | const user = extend(model, { 348 | firstName: "Bob", 349 | age: 42, 350 | }); 351 | 352 | const post = extend(model, { 353 | title: "The Best Post Ever", 354 | user, 355 | tags: ["popular", "trending"], 356 | }); 357 | 358 | expect(() => { 359 | post(); 360 | }).toThrow( 361 | "`tags` contains a hard-coded array. It will be shared across all instances of this factory. Consider using a factory function." 362 | ); 363 | expect(traceSpy).toHaveBeenCalled(); 364 | }); 365 | }); 366 | }); 367 | }); 368 | -------------------------------------------------------------------------------- /src/__tests__/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { define, sequence, random } from "../index"; 2 | import { MIN_RANDOM_VALUE, MAX_RANDOM_VALUE } from "../helpers"; 3 | 4 | type User = { age: number }; 5 | 6 | describe("helpers", () => { 7 | describe("sequence", () => { 8 | test("increments each time a factory is invoked", () => { 9 | const user = define({ 10 | age: sequence, 11 | }); 12 | 13 | expect(user()).toEqual({ 14 | age: 1, 15 | }); 16 | expect(user()).toEqual({ 17 | age: 2, 18 | }); 19 | expect(user()).toEqual({ 20 | age: 3, 21 | }); 22 | }); 23 | }); 24 | 25 | describe("random", () => { 26 | test("returns a random number", () => { 27 | const user = define({ 28 | age: random, 29 | }); 30 | 31 | const { age } = user(); 32 | 33 | expect(age).toBeGreaterThanOrEqual(MIN_RANDOM_VALUE); 34 | expect(age).toBeLessThanOrEqual(MAX_RANDOM_VALUE); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import { Factory, FactoryConfig } from "./define"; 2 | 3 | export type ArrayFactory = (override?: FactoryConfig) => T[]; 4 | 5 | export type ArrayFactoryPassThrough = (override?: FactoryConfig) => T; 6 | 7 | export const ARRAY_FACTORY_KEY = "arrayFactory"; 8 | 9 | /** 10 | * Define a new array factory function. The return value is a function that can be 11 | * invoked as many times as needed to create an array of object of given type. 12 | * 13 | * @param factory An existing factory object. 14 | * @param size Size of target array can be a static value. 15 | */ 16 | export function array( 17 | factory: Factory, 18 | size: number = 5 19 | ): ArrayFactory { 20 | const arrayFactory = (override?: FactoryConfig) => { 21 | const arr = []; 22 | for (let i = 0; i < size; i++) { 23 | arr.push(factory(override)); 24 | } 25 | return arr; 26 | }; 27 | 28 | // Define a property to differentiate this function during the evaluation 29 | // phase when the factory is later invoked. 30 | arrayFactory.__cooky_cutter = ARRAY_FACTORY_KEY as typeof ARRAY_FACTORY_KEY; 31 | 32 | return arrayFactory; 33 | } 34 | -------------------------------------------------------------------------------- /src/compute.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isAttributeFunction, 3 | isDerivedFunction, 4 | isFactoryFunction, 5 | isArrayFactoryFunction, 6 | } from "./utils"; 7 | import { Config } from "./define"; 8 | import { getConfig } from "./config"; 9 | 10 | // Given a key, the configuration object (with overrides already applied) and 11 | // the end result object, compute the current value for the given key and write 12 | // that value to the result object. Optionally track the path values are 13 | // computed along in cases where it's possible to define circular dependencies. 14 | function compute< 15 | Key extends keyof Result, 16 | Values extends Config, 17 | Result 18 | >( 19 | key: Key, 20 | values: Values, 21 | result: Result, 22 | invocations: number, 23 | path: Key[], 24 | override: Partial, 25 | computedKeys: Key[] 26 | ) { 27 | // If this key was already computed (according to the passed array) then skip 28 | // this computation. This likely was a result of a `derive` function requiring 29 | // this key to be computed to invoke the derived function because it was a 30 | // dependency. In these cases, avoid re-computing the value because often 31 | // the factories are not idempotent (eg: each call the invocation is 32 | // incremented) which can lead to unexpected inconsistencies. 33 | if (computedKeys.indexOf(key) >= 0) { 34 | return; 35 | } 36 | 37 | const value = values[key]; 38 | 39 | // In essence, this is "exhaustively" checking for each type of attribute that 40 | // can be defined for a given key. Unfortunately it's not truly exhaustive, 41 | // but would be great to update this to do true exhaustive type checking. 42 | if (isDerivedFunction(value)) { 43 | result[key] = value( 44 | result, 45 | values, 46 | invocations, 47 | path, 48 | override, 49 | computedKeys 50 | ); 51 | } else if (isFactoryFunction(value)) { 52 | result[key] = value(); 53 | } else if (isArrayFactoryFunction(value)) { 54 | result[key] = value(); 55 | } else if (isAttributeFunction(value)) { 56 | result[key] = value(invocations); 57 | } else { 58 | if (!(key in override)) { 59 | warnAboutHardCodedValues(key, value); 60 | } 61 | 62 | result[key] = value as Result[Key]; 63 | } 64 | 65 | // Mark this key has having it's value computed. 66 | computedKeys.push(key); 67 | } 68 | 69 | /** 70 | * Explicitly setting an object or array as a value in a factory can lead to 71 | * really challenging and subtle bugs since they will be shared across all 72 | * instances of a factory. Check for objects and arrays and by default display 73 | * a warning. 74 | */ 75 | const warnAboutHardCodedValues = (key: Key, value: Value) => { 76 | let message: string | undefined; 77 | if (Array.isArray(value)) { 78 | message = `\`${key}\` contains a hard-coded array.`; 79 | } else if (typeof value === "object" && value !== null) { 80 | message = `\`${key}\` contains a hard-coded object.`; 81 | } 82 | 83 | const { errorOnHardCodedValues } = getConfig(); 84 | 85 | if (message) { 86 | message += ` It will be shared across all instances of this factory. Consider using a factory function.`; 87 | 88 | if (errorOnHardCodedValues) { 89 | console.trace(); 90 | throw message; 91 | } else { 92 | console.warn(message); 93 | } 94 | } 95 | }; 96 | 97 | export { compute }; 98 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | interface Configuration { 2 | /** 3 | * Enabling this setting will throw (rather than warn) when an object 4 | * or array is hard-coded in the configuration for a factory (not in the 5 | * override). It's strongly discouraged to use a shared instance of an 6 | * object or an array between different factory instances. If one test case 7 | * modifies the object or array, the next test that relies on this factory 8 | * will still have those mutations which can lead to confusing and subtle 9 | * bugs or random test failures. 10 | * 11 | * @default false 12 | */ 13 | errorOnHardCodedValues: boolean; 14 | } 15 | 16 | let config: Configuration = { 17 | errorOnHardCodedValues: false, 18 | }; 19 | 20 | /** 21 | * Configure global settings to cooky-cutter. This will affect ALL factories. 22 | * This should be ran once before all tests and factories so that all factories 23 | * rely on the same configuration. 24 | * 25 | * @param newConfig - an object that represents the desired configuration 26 | */ 27 | export const configure = (newConfig: Configuration) => { 28 | config = newConfig; 29 | }; 30 | 31 | /** 32 | * Retrieve the current global configuration options. 33 | */ 34 | export const getConfig = () => config; 35 | -------------------------------------------------------------------------------- /src/define.ts: -------------------------------------------------------------------------------- 1 | import { DerivedFunction } from "./derive"; 2 | import { compute } from "./compute"; 3 | import { ArrayFactory } from "./array"; 4 | import { ArrayElement } from "./utils"; 5 | 6 | type Config = { 7 | [Key in keyof T]: 8 | | T[Key] 9 | | AttributeFunction 10 | | Factory 11 | | DerivedFunction 12 | | ArrayFactory>; 13 | }; 14 | 15 | type AttributeFunction = (invocation: number) => T; 16 | 17 | type FactoryConfig = Partial>; 18 | 19 | const FACTORY_FUNCTION_KEY = "factory"; 20 | 21 | interface Factory { 22 | (override?: FactoryConfig): T; 23 | __cooky_cutter: typeof FACTORY_FUNCTION_KEY; 24 | resetSequence(): void; 25 | } 26 | 27 | /** 28 | * Define a new factory function. The return value is a function that can be 29 | * invoked as many times as needed to create a given type of object. Use the 30 | * config param to define how the object is generated on each invocation. 31 | * 32 | * @param config An object that defines how the factory should generate objects. 33 | * Each key can either be a static value, a function that receives the 34 | * invocation count as the only parameter or another factory. 35 | */ 36 | function define(config: Config): Factory { 37 | let invocations = 0; 38 | 39 | const factory = (override = {}) => { 40 | invocations++; 41 | 42 | let result = {} as Result; 43 | let computedKeys: Array = []; 44 | const values = Object.assign({}, config, override); 45 | 46 | for (let key in values) { 47 | compute(key, values, result, invocations, [], override, computedKeys); 48 | } 49 | 50 | return result; 51 | }; 52 | 53 | // Define a property to differentiate this function during the evaluation 54 | // phase when the factory is later invoked. 55 | factory.__cooky_cutter = FACTORY_FUNCTION_KEY as typeof FACTORY_FUNCTION_KEY; 56 | factory.resetSequence = () => { 57 | invocations = 0; 58 | }; 59 | 60 | return factory; 61 | } 62 | 63 | export { 64 | define, 65 | AttributeFunction, 66 | Config, 67 | Factory, 68 | FactoryConfig, 69 | FACTORY_FUNCTION_KEY, 70 | }; 71 | -------------------------------------------------------------------------------- /src/derive.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "./define"; 2 | import { compute } from "./compute"; 3 | 4 | const DERIVE_FUNCTION_KEY = "derived"; 5 | 6 | interface DerivedFunction { 7 | ( 8 | result: Base, 9 | values: Config, 10 | invocations: number, 11 | path: (keyof Base)[], 12 | override: Partial, 13 | computedKeys: Array 14 | ): Output; 15 | __cooky_cutter: typeof DERIVE_FUNCTION_KEY; 16 | } 17 | 18 | /** 19 | * Compute a single value and assign it to the attribute based off any number 20 | * of other attributes defined in the factory. This is useful for deriving a 21 | * fields value off of other dynamic field(s) that are not known until a factory 22 | * is invoked. A derived field can reference other derived fields, but they 23 | * cannot be circularly referenced. 24 | * 25 | * @param fn a function to reduce all of the dependent keys into a single 26 | * derived value. The return value will be assigned to the attribute. 27 | * @param dependentKeys a list of all keys that the derive function is dependent 28 | * on. If the key is not defined in this list, it is not guaranteed to be 29 | * defined. 30 | */ 31 | function derive( 32 | fn: (input: Partial) => Output, 33 | ...dependentKeys: (keyof Base)[] 34 | ): DerivedFunction { 35 | const derivedFunction: DerivedFunction = function ( 36 | result, 37 | values, 38 | invocations, 39 | path, 40 | override, 41 | computedKeys 42 | ) { 43 | // Construct the input object from all of the dependent values that are 44 | // needed to derive the value. 45 | const input = dependentKeys.reduce>((input, key) => { 46 | // Verify the derived value has been computed, otherwise compute any 47 | // derived values before continuing. 48 | if (!result.hasOwnProperty(key)) { 49 | // Verify the field has not already been visited. If it has, there 50 | // is a circular reference and it cannot be resolved. 51 | if (path.indexOf(key) > -1) { 52 | throw `${key} cannot circularly derive itself. Check along this path: ${path.join( 53 | "->" 54 | )}->${key}`; 55 | } 56 | 57 | compute( 58 | key, 59 | values, 60 | result, 61 | invocations, 62 | [...path, key], 63 | override, 64 | computedKeys 65 | ); 66 | } 67 | 68 | input[key] = result[key]; 69 | return input; 70 | }, {} as Partial); 71 | 72 | return fn(input); 73 | }; 74 | 75 | // Define a property to differentiate this function during the evaluation 76 | // phase when the factory is later invoked. 77 | derivedFunction.__cooky_cutter = DERIVE_FUNCTION_KEY as typeof DERIVE_FUNCTION_KEY; 78 | 79 | return derivedFunction; 80 | } 81 | 82 | export { derive, DerivedFunction, DERIVE_FUNCTION_KEY }; 83 | -------------------------------------------------------------------------------- /src/extend.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Factory, 3 | FactoryConfig, 4 | AttributeFunction, 5 | Config, 6 | ArrayFactory, 7 | } from "./index"; 8 | import { DiffProperties, ArrayElement } from "./utils"; 9 | import { compute } from "./compute"; 10 | import { DerivedFunction } from "./derive"; 11 | import { FACTORY_FUNCTION_KEY } from "./define"; 12 | 13 | // Helper specific to extending factories. Any keys required in the base type 14 | // should be optional in the result config because they've already been defined 15 | // in the base. However, they should still be overridable. 16 | type Merge = DiffProperties & Partial; 17 | 18 | // A config similar to the `define` method's config. However, there are a few 19 | // differences. For example, to simplify the derived function, it expects the 20 | // result type instead of the merged type to simplify the external type API. 21 | type ExtendConfig = { 22 | [Key in keyof Merge]: 23 | | Merge[Key] 24 | | AttributeFunction[Key]> 25 | | Factory[Key]> 26 | | DerivedFunction[Key]> 27 | | ArrayFactory[Key]>>; 28 | }; 29 | 30 | /** 31 | * Define a new factory function from an existing factory. The return value is a 32 | * function that can be invoked as many times as needed to create a given type 33 | * of object. Use the config param to define how the object is generated on each 34 | * invocation. 35 | * 36 | * @param base An existing factory to extend. 37 | * @param config An object that defines how the factory should generate objects. 38 | * Each key can either be a static value, a function that receives the 39 | * invocation count as the only parameter or another factory. 40 | */ 41 | function extend( 42 | base: Factory, 43 | config: ExtendConfig 44 | ): Factory { 45 | let invocations = 0; 46 | 47 | const factory = (override = {}) => { 48 | invocations++; 49 | let result = base(override as FactoryConfig) as Result; 50 | // The computed keys starts empty (rather than including the base result 51 | // keys) because those values should get overridden and recomputed by the 52 | // extended values. 53 | let computedKeys: Array = []; 54 | 55 | // TODO: this cast is necessary for the correct `key` typings and playing 56 | // nice with `compute`. Ideally, this can be avoided. 57 | const values = Object.assign({}, config, override) as Config; 58 | 59 | for (let key in values) { 60 | compute(key, values, result, invocations, [], override, computedKeys); 61 | } 62 | 63 | return result; 64 | }; 65 | 66 | // Define a property to differentiate this function during the evaluation 67 | // phase when the factory is later invoked. 68 | factory.__cooky_cutter = FACTORY_FUNCTION_KEY as typeof FACTORY_FUNCTION_KEY; 69 | factory.resetSequence = () => { 70 | invocations = 0; 71 | }; 72 | 73 | return factory; 74 | } 75 | 76 | export { extend, ExtendConfig }; 77 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | // NOTE: using the 32-bit max integer instead of `Number.MAX_VALUE` to enable 2 | // readability and avoid exponentials. eg: `1.4991242955357377e+308` 3 | // TODO: consider exposing these as config options. 4 | const MAX_RANDOM_VALUE = 2147483647; 5 | const MIN_RANDOM_VALUE = 1; 6 | 7 | /** 8 | * Assign a random number to the attribute. This is useful for attributes like 9 | * seeds or ids (to avoid tests passing as a result of ordering) 10 | */ 11 | const random = () => 12 | Math.floor(Math.random() * MAX_RANDOM_VALUE) + MIN_RANDOM_VALUE; 13 | 14 | /** 15 | * Increment the attribute each time the factory is invoked. This is useful 16 | * for counts (`random` may be a better option for `ids`). 17 | * 18 | * @param invocation 19 | */ 20 | const sequence = (invocation: number) => invocation; 21 | 22 | export { random, sequence, MAX_RANDOM_VALUE, MIN_RANDOM_VALUE }; 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { configure } from "./config"; 2 | export { 3 | define, 4 | AttributeFunction, 5 | Config, 6 | Factory, 7 | FactoryConfig, 8 | } from "./define"; 9 | export { extend, ExtendConfig } from "./extend"; 10 | export { derive } from "./derive"; 11 | export { array, ArrayFactory } from "./array"; 12 | export { random, sequence } from "./helpers"; 13 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { DerivedFunction, DERIVE_FUNCTION_KEY } from "./derive"; 2 | import { Factory, AttributeFunction, FACTORY_FUNCTION_KEY } from "./define"; 3 | import { ARRAY_FACTORY_KEY, ArrayFactoryPassThrough } from "./array"; 4 | 5 | // Determine if the function is an internal derive function based on properties 6 | // defined on the function. 7 | function isDerivedFunction( 8 | fn: any 9 | ): fn is DerivedFunction { 10 | return fn && fn.__cooky_cutter === DERIVE_FUNCTION_KEY; 11 | } 12 | 13 | // Determine if the function is an internal factory function based on properties 14 | // defined on the function. 15 | function isFactoryFunction(fn: any): fn is Factory { 16 | return fn && fn.__cooky_cutter === FACTORY_FUNCTION_KEY; 17 | } 18 | 19 | // Determine if the function is an internal array factory function based on 20 | // properties defined on the function. 21 | function isArrayFactoryFunction( 22 | fn: any 23 | ): fn is ArrayFactoryPassThrough { 24 | return fn && fn.__cooky_cutter === ARRAY_FACTORY_KEY; 25 | } 26 | 27 | // Determine if the function is an attribute function. Since this is end-user 28 | // defined there is not a great way to know for sure if it exactly matches 29 | // an attribute function, but this is a best guess. This should be used after 30 | // all other function type checks. 31 | function isAttributeFunction(fn: any): fn is AttributeFunction { 32 | return fn && {}.toString.call(fn) === "[object Function]"; 33 | } 34 | 35 | // Returns a union of the keys. 36 | // e.g. it will convert `{ a: {}, b: {} }` into `"a" | "b"` 37 | type Keys = keyof T; 38 | 39 | // Remove types from T that are assignable to U 40 | // e.g. it will convert `Diff<"a" | "b" | "d", "a" | "f">` into `"b" | "d"` 41 | type Diff = T extends U ? never : T; 42 | 43 | // Remove attributes from T that are in U 44 | // e.g. it will convert 45 | // `DiffProperties<{ a: string; b: number; }, { a: string; c: string; }>` 46 | // into `{ b: number; }` 47 | type DiffProperties = Pick, Keys>>; 48 | 49 | // Given an array, infer the type of it's elements 50 | export type ArrayElement = ArrayType extends ReadonlyArray< 51 | infer ElementType 52 | > 53 | ? ElementType 54 | : never; 55 | 56 | export { 57 | isAttributeFunction, 58 | isDerivedFunction, 59 | isFactoryFunction, 60 | isArrayFactoryFunction, 61 | DiffProperties, 62 | }; 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "strict": true, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "outDir": "dist" 12 | }, 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | --------------------------------------------------------------------------------