thoughtbot / fishery

A library for setting up JavaScript objects as test data
MIT License
877 stars 36 forks source link

factory.create() expected usage with an ORM or 3rd party API #53

Closed avimoondra closed 3 years ago

avimoondra commented 3 years ago

I'm integrating fishery with Sequelize (https://sequelize.org/), and am not sure if I'm taking the correct approach... Is this the expected usage?

export type Organization = {
  id: number
  name: string
}

class OrganizationFactory extends Factory<Organization> {}
export const organizationFactory = OrganizationFactory.define(({ sequence, params, onCreate }) => {
  onCreate((org) => {
    return models.Organization.build({ ...org }).save()
  })

  return {
    id: sequence,
    name: params.name || `A New Org ${sequence}`,
  }
})
const { id } = await organizationFactory.create()
const organization = await models.Organization.findByPk(id)

Is there a way to return the created database object back from someFactory.create() calls? So this reads like...

const organization = await organizationFactory.create()
// organization is a Sequelize object.

A different approach was taken here - https://paramander.com/en/blog/extending-fishery-to-build-test-data-for-sequelize-models but seems to miss onCreate hooks since super.create is not called in the mixin?

Thanks!

stevehanson commented 3 years ago

Thanks for reporting this, @avimoondra! I just started a Sequelize project to play around with this. Would it potentially help if we allowed specifying a different return type for create instead of it always returning the same thing as build? This is what I'm picturing but want to make sure it would solve your problem:

// These are all the attributes in the User model
interface UserAttributes {
  id: number;
  name: string;
}

// Some attributes are optional in `User.build` and `User.create` calls
interface UserCreationAttributes extends Optional<UserAttributes, "id"> {}

class User
  extends Model<UserAttributes, UserCreationAttributes>
  implements UserAttributes
{
  public id!: number; // Note that the `null assertion` `!` is required in strict mode.
  public name!: string;

  // timestamps!
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
  ...
}

// template arg 1 (currently exists) = The type that is returned from build()
// template arg 2 (currently exists) = The type of any transient params that your factory takes (defaults to 'any')
// template arg 3 (new) = The type that is returned from create()
const userFactory = Factory.define<UserAttributes, TransientParams, User>(({ onCreate }) => {
  onCreate((user) => User.create(user));
  return {
    id: 1,
    name: "Stephen",
  };
});

const user = userFactory.create({ name: "Bill" });
user.createdAt // works
avimoondra commented 3 years ago

@stevehanson that looks great!

Since the library is only a few core files (yet very powerful!) and we heavily use create (vs. build), we decided to port it over for our use with Sequelize v5/v6 with the hopes of coming back around to use Fishery again sometime in the future.

Below is what we extracted out... (very similar, using TS generics)

Would it potentially help if we allowed specifying a different return type for create instead of it always returning the same thing as build?

(yes!)

import type { DeepPartial } from 'fishery'

// Our factory class is heavily inspried by Fishery. The reason we're using our
// own is that Fishery makes it difficult to use different types for input
// parameters and output objects.
//
// The downside to using our own class is that the interface isn't as nice. As
// time goes on, we'll add more changes to make it easier to write better
// factories.
//
// https://github.com/thoughtbot/fishery/blob/master/lib/factory.ts
export abstract class Factory<Model, Attributes> {
  attrs: DeepPartial<Attributes>
  model: Model
  hooks: ((model: Model) => Promise<void>)[]

  abstract make(params: DeepPartial<Attributes>): Promise<Model>

  constructor() {
    this.hooks = []
    this.attrs = {}
  }

  params(newAttrs: DeepPartial<Attributes>) {
    this.attrs = { ...this.attrs, ...newAttrs }
    return this
  }

  onCreate(hook: (model: Model) => Promise<void>) {
    this.hooks.push(hook)
    return this
  }

  async createList(count: number, params: DeepPartial<Attributes>): Promise<Model[]> {
    const models: Model[] = []
    for (let i = 0; i < count; i++) {
      const model = await this.create(params)
      models.push(model)
    }
    return models
  }

  async create(params?: DeepPartial<Attributes>): Promise<Model> {
    const model = await this.make({ ...this.attrs, ...params })
    for (const hook of this.hooks) {
      await hook(model)
    }
    return model
  }
}

Example model

import { DataTypes, Model, Sequelize } from 'sequelize'

import { Group } from './group'
import { User } from './user'

export interface UserGroupAttributes {
  id: number
  groupId: number | null
  userId: number | null
  isAdmin: boolean
}

export class UserGroup extends Model implements UserGroupAttributes {
  public id!: number
  public groupId!: number | null
  public userId!: number | null
  public isAdmin!: boolean

  public readonly createdAt!: Date
  public readonly updatedAt!: Date

  static associate() {
    this.belongsTo(User, {
      foreignKey: 'userId',
      onDelete: 'CASCADE',
    })

    this.belongsTo(Group, {
      foreignKey: 'groupId',
      onDelete: 'CASCADE',
    })
  }
}

export function init(sequelize: Sequelize) {
  UserGroup.init(
    {
      id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
      userId: DataTypes.INTEGER,
      groupId: DataTypes.INTEGER,
      isAdmin: DataTypes.BOOLEAN,
    },
    {
      modelName: 'userGroup',
      tableName: 'user_groups',
      scopes: {
        isAdmin: {
          where: {
            isAdmin: true,
          },
        },
        isMember: {
          where: {
            isAdmin: false,
          },
        },
      },
      sequelize,
    },
  )
}

Example factory

import type { DeepPartial } from 'fishery'
import { Factory } from './factory'
import { UserGroup, UserGroupAttributes } from './../../server/orm/models/userGroup'

class UserGroupFactory extends Factory<UserGroup, UserGroupAttributes> {
  async make(params: DeepPartial<UserGroupAttributes>) {
    return UserGroup.create({
      userId: params.userId ?? -1,
      groupId: params.groupId ?? -1,
      isAdmin: params.isAdmin ?? false,
    })
  }

  admin() {
    return this.params({
      isAdmin: true,
    })
  }
}

export const userGroupFactory = () => {
  return new UserGroupFactory()
}
nolaneo commented 3 years ago

@stevehanson that'd be fantastic. I've a similar use case where I need to call plain ol' Typescript class constructors after generating the POJO so your proposal looks perfect. 🙂