diff --git a/README.md b/README.md index 6825aad..19619ff 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![CircleCI](https://circleci.com/gh/thoughtbot/fishery.svg?style=svg)](https://circleci.com/gh/thoughtbot/fishery) -Fishery is a library for setting up JavaScript objects for use in tests, -Storybook, and anywhere else you need to set up data. It is loosely modeled -after the Ruby gem, [factory_bot][factory_bot]. +Fishery is a library for setting up JavaScript objects for use in tests and +anywhere else you need to set up data. It is loosely modeled after the Ruby +gem, [factory_bot][factory_bot]. Fishery is built with TypeScript in mind. Factories accept typed parameters and return typed objects, so you can be confident that the data used in your tests @@ -38,33 +38,22 @@ objects. Here's how it's done: // factories/user.ts import { Factory } from 'fishery'; import { User } from '../my-types'; +import postFactory from './post'; -export default Factory.define(({ sequence, factories }) => ({ +export default Factory.define(({ sequence }) => ({ id: sequence, - name: 'Bob', - address: { city: 'Grand Rapids', state: 'MI', country: 'USA' }, - posts: factories.post.buildList(2), + name: 'Rosa', + address: { city: 'Austin', state: 'TX', country: 'USA' }, + posts: postFactory.buildList(2), })); ``` -### Combine factories - -Now combine your factories together and register them: +### Build objects with your factories ```typescript -// factories/index.ts -import { register } from 'fishery'; -import user from './user'; -import post from './post'; - -export const factories = register({ - user, - post, -}); +const user = userFactory.build({ name: 'Sandra' }); ``` -### Build objects with your factories - Pass parameters as the first argument to `build` to override your factory defaults. These parameters are deep-merged into the default object returned by your factory. @@ -74,8 +63,7 @@ your factory. - `transient`: data for use in your factory that doesn't get overlaid onto your result object. More on this in the [Transient Params](#params-that-dont-map-to-the-result-object-transient-params) section -- `associations`: often not required but can be useful in the case of - bi-directional associations. More on this in the [Associations](#Associations) +- `associations`: often not required but can be useful in order to short-circuit creating associations. More on this in the [Associations](#Associations) section ```typescript @@ -110,7 +98,7 @@ const user = factories.user.build({ foo: 'bar' }); // type error! Argument of ty ``` ```typescript -export default Factory.define( +export default Factory.define( ({ sequence, params, transientParams, associations, afterBuild }) => { params.firstName; // Property 'firstName' does not exist on type 'DeepPartial transientParams.foo; // Property 'foo' does not exist on type 'Partial' @@ -131,61 +119,44 @@ export default Factory.define( ### Associations -If your factory references another factory, use the `factories` object -provided to the factory: +Factories can import and reference other factories for associations: ```typescript -const postFactory = Factory.define(({ factories }) => ({ +import userFactory from './user'; + +const postFactory = Factory.define(() => ({ title: 'My Blog Post', - author: factories.user.build(), + author: userFactory.build(), })); ``` If you'd like to be able to pass in an association when building your object and -short-circuit the call to `factories.xxx.build()`, use the `associations` +short-circuit the call to `yourFactory.build()`, use the `associations` variable provided to your factory: ```typescript -const postFactory = Factory.define( - ({ factories, associations }) => ({ - title: 'My Blog Post', - author: associations.author || factories.user.build(), - }), -); +const postFactory = Factory.define(({ associations }) => ({ + title: 'My Blog Post', + author: associations.author || userFactory.build(), +})); ``` Then build your object like this: ```typescript -factories.post.build({}, { associations: { author: susan } }); +const jordan = userFactory.build({ name: 'Jordan' }); +factories.post.build({}, { associations: { author: jordan } }); ``` -#### Typing the `factories` factory argument - -In the above examples, the `Factories` generic parameter is passed to -`define`. This is optional but recommended in order to get type-checking of -the `factories` object. You can define your `Factories` type like this: +If two factories reference each other, they can usually import each other +without issues, but TypeScript might require you to explicitly type your +factory before exporting so it can determine the type before the circular +references resolve: ```typescript -// factories/types.ts -export interface Factories { - user: Factory; - post: Factory; -} -``` - -Once you've defined your `Factories` type, it can also be used when -registering your factories. This ensures that your `Factories` type is always -in sync with the actual factories that you have registered: - -```typescript -// factories/index.ts -import { register } from 'fishery'; -import user from './user'; -import post from './post'; -import { Factories } from './types'; - -export const factories: Factories = register({ user, post }); +// the extra Factory typing can be necessary with circular imports +const postFactory: Factory = Factory.define(() => ({ ...})); +export default postFactory; ``` ### Use `params` to access passed in properties @@ -196,7 +167,7 @@ explicitly access the params in your factory. This can, however, be useful, for example, if your factory uses the params to compute other properties: ```typescript -const userFactory = Factory.define(({ params }) => { +const userFactory = Factory.define(({ params }) => { const { name = 'Bob Smith' } = params; const email = params.email || `${kebabCase(name)}@example.com`; @@ -234,13 +205,13 @@ interface UserTransientParams { numPosts: number; } -const userFactory = Factory.define( - ({ transientParams, factories, sequence }) => { +const userFactory = Factory.define( + ({ transientParams, sequence }) => { const { registered, numPosts = 1 } = transientParams; const user = { name: 'Susan Velasquez', - posts: factories.posts.buildList(numPosts), + posts: postFactory.buildList(numPosts), memberId: registered ? `member-${sequence}` : null, permissions: { canPost: registered, @@ -251,9 +222,8 @@ const userFactory = Factory.define( ``` In the example above, we also created a type called `UserTransientParams` and -passed it as the third generic type to `define`. This isn't required but -gives you type checking of transient params, both in the factory and when -calling `build`. +passed it as the second generic type to `define`. This gives you type +checking of transient params, both in the factory and when calling `build`. When constructing objects, any regular params you pass to `build` take precedence over the transient params: @@ -278,20 +248,18 @@ This can be useful if a reference to the object is needed, like when setting up relationships: ```typescript -export default Factory.define( - ({ factories, sequence, afterBuild }) => { - afterBuild(user => { - const post = factories.post.build({}, { associations: { author: user } }); - user.posts.push(post); - }); +export default Factory.define(({ sequence, afterBuild }) => { + afterBuild(user => { + const post = factories.post.build({}, { associations: { author: user } }); + user.posts.push(post); + }); - return { - id: sequence, - name: 'Bob', - posts: [], - }; - }, -); + return { + id: sequence, + name: 'Bob', + posts: [], + }; +}); ``` ### Extending factories @@ -310,11 +278,12 @@ const admin = adminFactory.build(); admin.admin; // true ``` -Factories are immutable, so the extension methods return a new factory with -the specified `params`, `transientParams`, `associations`, or `afterBuild` -added to it. When `build` is called on the factory, the `params`, -`transientParams`, and `associations` are passed in along with the values -supplied to `build`. Values supplied to `build` override these defaults. +The extension methods return a new factory with the specified `params`, +`transientParams`, `associations`, or `afterBuild` added to it and do not +modify the factory they are called on. When `build` is called on the factory, +the `params`, `transientParams`, and `associations` are passed in along with +the values supplied to `build`. Values supplied to `build` override these +defaults. `afterBuild` just adds a function that is called when the object is built. The `afterBuild` defined in `Factory.define` is always called first if @@ -352,8 +321,8 @@ Factories are just classes, so adding reusable builder methods is as simple as subclassing `Factory` and defining any desired methods: ```typescript -class UserFactory extends Factory { - admin(adminId: string) { +class UserFactory extends Factory { + admin(adminId?: string) { return this.params({ admin: true, adminId: adminId || `admin-${this.sequence()}`, @@ -364,7 +333,7 @@ class UserFactory extends Factory { return this .params({ memberId: this.sequence() }) .transient({ registered: true }) - .associations({ profile: factories.profile.build() }) + .associations({ profile: profileFactory.build() }) .afterBuild(user => console.log(user)) } } @@ -378,39 +347,6 @@ const user = userFactory.admin().registered().build() To learn more about the factory builder methods `params`, `transient`, `associations`, and `afterBuild`, see [Extending factories](#extending-factories), above. -### Defining one-off factories without calling `register` - -Factories should usually be defined and then combined together using `register`: - -```typescript -// factories/index.ts -import { register } from 'fishery'; -import user from './user'; -import post from './post'; -import { Factories } from './types'; - -export const factories: Factories = register({ user, post }); -``` - -The factories passed to register get injected into each factory so factories can -access each other. This prevents circular dependencies that could arise if your -factories try to access other factories directly by importing them and also -creates a convenient way for factories to access other factories without having -to explicitly import them. - -If you are defining a factory for use in a single test file, you might not wish -to register the factory or use the `factories` object that gets injected to the -factory. In this case, you can use `defineUnregistered` instead of `define` and -then skip calling `register`, eg: - -```typescript -const personFactory = Factory.defineUnregistered(() => ({ - name: 'Sasha', -})); - -const person = personFactory.build(); -``` - ### Rewind Sequence A factory's sequence can be rewound with `rewindSequence()`. diff --git a/lib/__tests__/associations.test.ts b/lib/__tests__/associations.test.ts index b692268..37c3364 100644 --- a/lib/__tests__/associations.test.ts +++ b/lib/__tests__/associations.test.ts @@ -1,4 +1,4 @@ -import { Factory, register } from 'fishery'; +import { Factory } from 'fishery'; interface Post { title: string; @@ -10,35 +10,15 @@ interface User { posts: Array; } -interface Factories { - user: Factory; - post: Factory; -} - describe('associations', () => { - it('can access named factories object in generator fn', () => { - expect.assertions(2); - const factory = Factory.define(({ factories }) => { - // TODO: type assertions https://github.com/Microsoft/dtslint#write-tests - expect((factories as any).bla.build).not.toBeUndefined(); - expect(factories.post).toBeUndefined(); - return {} as User; - }); - - register({ bla: factory }); - factory.build(); - }); - it('can create bi-directional has-many/belongs-to associations', () => { - const userFactory = Factory.define( - ({ factories, afterBuild, transientParams }) => { + const userFactory = Factory.define( + ({ afterBuild, transientParams }) => { const { skipPosts } = transientParams; afterBuild(user => { if (!skipPosts) { - user.posts.push( - factories.post.build({}, { associations: { user } }), - ); + user.posts.push(postFactory.build({}, { associations: { user } })); } }); @@ -49,20 +29,13 @@ describe('associations', () => { }, ); - const postFactory = Factory.define( - ({ factories, associations }) => { - return { - title: 'A Post', - user: - associations.user || - factories.user.build({}, { transient: { skipPosts: true } }), - }; - }, - ); - - register({ - user: userFactory, - post: postFactory, + const postFactory = Factory.define(({ associations }) => { + return { + title: 'A Post', + user: + associations.user || + userFactory.build({}, { transient: { skipPosts: true } }), + }; }); const user = userFactory.build(); diff --git a/lib/__tests__/class-factories.test.ts b/lib/__tests__/class-factories.test.ts index 2f6e768..a20d063 100644 --- a/lib/__tests__/class-factories.test.ts +++ b/lib/__tests__/class-factories.test.ts @@ -1,4 +1,4 @@ -import { Factory, HookFn, register } from 'fishery'; +import { Factory } from 'fishery'; describe('Using with classes', () => { class Address { @@ -16,10 +16,6 @@ describe('Using with classes', () => { () => new User('Sharon', new Address('Detroit', 'MI')), ); - register({ - user: userFactory, - }); - it('works correctly with read-only properties', () => { const user = userFactory.build(); expect(user).toBeInstanceOf(User); diff --git a/lib/__tests__/factory-builders.test.ts b/lib/__tests__/factory-builders.test.ts index 7ddc2d0..053c552 100644 --- a/lib/__tests__/factory-builders.test.ts +++ b/lib/__tests__/factory-builders.test.ts @@ -1,4 +1,4 @@ -import { register, Factory } from 'fishery'; +import { Factory } from 'fishery'; type Post = { id: string }; type User = { @@ -15,7 +15,7 @@ type User = { const postFactory = Factory.define(() => ({ id: '1' })); type TransientParams = { registered: boolean }; -class UserFactory extends Factory { +class UserFactory extends Factory { admin(adminId = '') { return this.params({ admin: true, @@ -44,8 +44,6 @@ const userFactory = UserFactory.define(({ associations, transientParams }) => { }; }); -register({ user: userFactory, post: postFactory }); - describe('afterBuild', () => { it('defines a function that is called after build', () => { const afterBuild = jest.fn(user => { @@ -87,7 +85,7 @@ describe('afterBuild', () => { }); type User = { id: string }; - const userFactory = Factory.defineUnregistered(({ afterBuild }) => { + const userFactory = Factory.define(({ afterBuild }) => { afterBuild(afterBuildGenerator); return { id: '1' }; }); diff --git a/lib/__tests__/factory.test.ts b/lib/__tests__/factory.test.ts index 64301b2..fa0ad89 100644 --- a/lib/__tests__/factory.test.ts +++ b/lib/__tests__/factory.test.ts @@ -1,4 +1,4 @@ -import { register, Factory, HookFn } from 'fishery'; +import { Factory, HookFn } from 'fishery'; type User = { id: string; @@ -18,8 +18,6 @@ const userFactory = Factory.define(({ sequence }) => { }; }); -register({ user: userFactory }); - describe('factory.build', () => { it('builds the object', () => { const user = userFactory.build({ name: 'susan' }); @@ -52,7 +50,6 @@ describe('factory.buildList', () => { return { id: '1', name: 'Ralph' }; }); - register({ user: factory }); expect(factory.buildList(2).every(u => u.name === 'Bill')).toBeTruthy(); expect(afterBuildFn).toHaveBeenCalledTimes(2); }); @@ -68,7 +65,6 @@ describe('afterBuild', () => { return { id: '1', name: 'Ralph' }; }); - register({ user: factory }); expect(factory.build().id).toEqual('bla'); }); @@ -79,7 +75,6 @@ describe('afterBuild', () => { return { id: '1', name: 'Ralph' }; }); - register({ user: factory }); expect(() => { factory.build(); }).toThrowError(/must be a function/); @@ -93,8 +88,6 @@ describe('factory.rewindSequence', () => { return { id: `user-${sequence}`, name: 'Ralph' }; }); - register({ user: factory }); - expect(factory.build().id).toBe('user-1'); factory.rewindSequence(); @@ -107,8 +100,6 @@ describe('factory.rewindSequence', () => { return { id: `user-${sequence}`, name: 'Ralph' }; }); - register({ user: factory }); - expect(factory.buildList(2)).toEqual([ { id: 'user-1', name: 'Ralph' }, { id: 'user-2', name: 'Ralph' }, diff --git a/lib/__tests__/register.test.ts b/lib/__tests__/register.test.ts deleted file mode 100644 index 4358617..0000000 --- a/lib/__tests__/register.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { register, Factory, HookFn } from 'fishery'; - -interface Post { - id: number; -} - -describe('register', () => { - it('returns an object of the same type as passed in, with factories set', () => { - interface User { - post: Post; - } - - const userFactory = Factory.define(({ factories }) => ({ - post: factories.post.build(), - })); - const postFactory = Factory.define(() => ({ id: 1 })); - - const factories = register({ - user: userFactory, - post: postFactory, - }); - - expect(factories.user).toEqual(userFactory); - expect(factories.post).toEqual(postFactory); - expect(factories.user.build().post.id).toBe(1); - }); - - it('raises an error if trying to build with a factory when factory not registered', () => { - const postFactory = Factory.define(() => ({ id: 1 })); - expect(() => postFactory.build()).toThrowError( - 'Your factory has not been registered. Call `register` before using factories or define your factory with `defineUnregistered` instead of `define`', - ); - }); - - it("can use a factory without registering if defined with 'defineUnregistered'", () => { - const postFactory = Factory.defineUnregistered(() => ({ - id: 1, - })); - - expect(postFactory.build().id).toEqual(1); - }); -}); diff --git a/lib/__tests__/sample-app/factories/index.ts b/lib/__tests__/sample-app/factories/index.ts index 9b2ca5b..166b262 100644 --- a/lib/__tests__/sample-app/factories/index.ts +++ b/lib/__tests__/sample-app/factories/index.ts @@ -1,11 +1,7 @@ import user from './user'; import post from './post'; -import { register } from 'fishery'; -import { Factories } from '../types'; -export const factories: Factories = register({ +export const factories = { user, post, -}); - -export { user, post }; +}; diff --git a/lib/__tests__/sample-app/factories/post.ts b/lib/__tests__/sample-app/factories/post.ts index e8831ff..4c7647a 100644 --- a/lib/__tests__/sample-app/factories/post.ts +++ b/lib/__tests__/sample-app/factories/post.ts @@ -1,10 +1,13 @@ import { Factory } from 'fishery'; -import { Post, Factories } from '../types'; +import { Post } from '../types'; +import userFactory from './user'; -export default Factory.define( - ({ sequence, params, factories }) => ({ +const postFactory: Factory = Factory.define( + ({ sequence, params, associations }) => ({ id: sequence, title: 'A Post', - user: factories.user.build(params.user || {}), + user: associations.user || userFactory.build(params.user || {}), }), ); + +export default postFactory; diff --git a/lib/__tests__/sample-app/factories/user.ts b/lib/__tests__/sample-app/factories/user.ts index c718362..613b9d2 100644 --- a/lib/__tests__/sample-app/factories/user.ts +++ b/lib/__tests__/sample-app/factories/user.ts @@ -1,10 +1,21 @@ import { Factory } from 'fishery'; -import { User, Factories } from '../types'; +import { User } from '../types'; +import postFactory from './post'; -export default Factory.define(({ sequence }) => { - return { - id: `user-${sequence}`, - name: 'Bob', - post: null, - }; -}); +const userFactory = Factory.define( + ({ associations, sequence, afterBuild }) => { + afterBuild(user => { + if (!user.posts.length) { + user.posts = postFactory.buildList(1, {}, { associations: { user } }); + } + }); + + return { + id: `user-${sequence}`, + name: 'Bob', + posts: associations.posts || [], + }; + }, +); + +export default userFactory; diff --git a/lib/__tests__/sample-app/types.ts b/lib/__tests__/sample-app/types.ts index a1c6446..cf17cd2 100644 --- a/lib/__tests__/sample-app/types.ts +++ b/lib/__tests__/sample-app/types.ts @@ -1,14 +1,7 @@ -import { Factory } from 'fishery'; - -export interface Factories { - user: Factory; - post: Factory; -} - export interface User { id: string; name: string; - post: Post | null; + posts: Post[]; } export interface Post { diff --git a/lib/__tests__/transient-params.test.ts b/lib/__tests__/transient-params.test.ts index 9162616..ea47ee1 100644 --- a/lib/__tests__/transient-params.test.ts +++ b/lib/__tests__/transient-params.test.ts @@ -1,4 +1,4 @@ -import { Factory, HookFn, register } from 'fishery'; +import { Factory } from 'fishery'; describe('Transient params', () => { interface User { @@ -14,7 +14,7 @@ describe('Transient params', () => { name: string; } - const userFactory = Factory.define( + const userFactory = Factory.define( ({ transientParams }) => { const { name = 'Sharon Jones', @@ -37,9 +37,9 @@ describe('Transient params', () => { }, ); - const factories = register({ + const factories = { user: userFactory, - }); + }; it('uses default when no transient param passed', () => { const user = factories.user.build(); diff --git a/lib/builder.ts b/lib/builder.ts index 451ed74..d1be434 100644 --- a/lib/builder.ts +++ b/lib/builder.ts @@ -1,10 +1,9 @@ import { GeneratorFn, HookFn, GeneratorFnOptions, DeepPartial } from './types'; import merge from 'lodash.merge'; -export class FactoryBuilder { +export class FactoryBuilder { constructor( - private generator: GeneratorFn, - private factories: F, + private generator: GeneratorFn, private sequence: number, private params: DeepPartial, private transientParams: Partial, @@ -13,10 +12,9 @@ export class FactoryBuilder { ) {} build() { - const generatorOptions: GeneratorFnOptions = { + const generatorOptions: GeneratorFnOptions = { sequence: this.sequence, afterBuild: this.setAfterBuild, - factories: this.factories, params: this.params, associations: this.associations, transientParams: this.transientParams, @@ -27,7 +25,7 @@ export class FactoryBuilder { // merge params and associations into object. The only reason 'associations' // is separated is because it is typed differently from `params` (Partial // vs DeepPartial) so can do the following in a factory: - // `user: associations.user || factories.user.build()` + // `user: associations.user || userFactory.build()` merge(object, this.params, this.associations); this._callAfterBuilds(object); return object; diff --git a/lib/factory.ts b/lib/factory.ts index 0e748ce..cc61145 100644 --- a/lib/factory.ts +++ b/lib/factory.ts @@ -7,67 +7,41 @@ import { } from './types'; import { FactoryBuilder } from './builder'; -export interface AnyFactories { - [key: string]: Factory; -} - const SEQUENCE_START_VALUE = 1; -export class Factory { +export class Factory { private nextId: number = SEQUENCE_START_VALUE; - private factories?: F; private _afterBuilds: HookFn[] = []; private _associations: Partial = {}; private _params: DeepPartial = {}; private _transient: Partial = {}; constructor( - private readonly generator: (opts: GeneratorFnOptions) => T, + private readonly generator: (opts: GeneratorFnOptions) => T, ) {} /** * Define a factory. This factory needs to be registered with * `register` before use. * @template T The object the factory builds - * @template F The `factories` object * @template I The transient parameters that your factory supports * @param generator - your factory function */ - static define>( - this: new (generator: GeneratorFn) => C, - generator: GeneratorFn, + static define>( + this: new (generator: GeneratorFn) => C, + generator: GeneratorFn, ): C { return new this(generator); } - /** - * Define a factory that does not need to be registered with `register`. The - * factory will not have access the `factories` parameter. This can be useful - * for one-off factories in individual tests - * @param generator - your factory - * function - */ - static defineUnregistered(generator: GeneratorFn) { - const factory = new Factory(generator); - factory.setFactories(null); - return factory; - } - /** * Build an object using your factory * @param params * @param options */ build(params: DeepPartial = {}, options: BuildOptions = {}): T { - if (typeof this.factories === 'undefined') { - throw new Error( - 'Your factory has not been registered. Call `register` before using factories or define your factory with `defineUnregistered` instead of `define`', - ); - } - - return new FactoryBuilder( + return new FactoryBuilder( this.generator, - this.factories, this.sequence(), { ...this._params, ...params }, { ...this._transient, ...options.transient }, @@ -140,13 +114,9 @@ export class Factory { this.nextId = SEQUENCE_START_VALUE; } - setFactories(factories: F) { - this.factories = factories; - } - - protected clone>(this: C): C { + protected clone>(this: C): C { const copy = new (this.constructor as { - new (generator: GeneratorFn): C; + new (generator: GeneratorFn): C; })(this.generator); Object.assign(copy, this); return copy; diff --git a/lib/index.ts b/lib/index.ts index e01c5ea..d0cea87 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,2 @@ export * from './types'; export * from './factory'; -export * from './register'; diff --git a/lib/register.ts b/lib/register.ts deleted file mode 100644 index ba4a4c1..0000000 --- a/lib/register.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AnyFactories } from './factory'; - -// TODO: would like to type this argument as AnyFactories but issue with -// inheritance since user-defined Factories will not have index property set -// see: https://github.com/Microsoft/TypeScript/issues/15300 -export const register = (allFactories: T) => { - const factories = allFactories as AnyFactories; - Object.keys(factories).forEach((key: string) => { - factories[key].setFactories(factories); - }); - - return allFactories; -}; diff --git a/lib/types.ts b/lib/types.ts index 5bc54b8..a86a587 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,13 +1,12 @@ export type DeepPartial = { [P in keyof T]?: DeepPartial }; -export type GeneratorFnOptions = { +export type GeneratorFnOptions = { sequence: number; afterBuild: (fn: HookFn) => any; params: DeepPartial; associations: Partial; transientParams: Partial; - factories: F; }; -export type GeneratorFn = (opts: GeneratorFnOptions) => T; +export type GeneratorFn = (opts: GeneratorFnOptions) => T; export type HookFn = (object: T) => any; export type BuildOptions = { associations?: Partial;