typical-data
v0.5.0
Published
Test data factory
Downloads
3,012
Readme
Typical Data is a library for building mock data with factories and querying it with a lightweight in-memory database. Although it's designed with Mock Service Worker, React Testing Library, and TypeScript in mind, Typical Data can be used with any JavaScript API mocking or testing framework.
Table of Contents
The Problem
Mock Service Worker makes it easy to create mock APIs and helps you avoid testing implementation details by eliminating request mocking in your tests. But removing API mocking from your tests can make it harder to customize the data returned by your API on a test-by-test basis. Hard-coded fixtures can be cumbersome and endpoint overriding is verbose and re-introduces api mocking into your tests.
The Solution
Typical Data helps bridge the gap between your tests and your mock API. This library provides an expressive factory DSL for creating objects, and an in-memory database for querying and mutating those objects in your mock API.
Installation
Install with npm:
npm install --save-dev typical-data
Install with yarn:
yarn add --dev typical-data
Quick Start
Define a factory for creating an object.
import { createFactory } from 'typical-data';
import { Contact } from './your-types';
export const contactFactory = createFactory<Contact>({
id: ({ sequence }) => sequence,
email: '[email protected]',
phone: '(555) 123-4567',
name: 'name',
});
Create a database with factories.
import { createDatabase } from 'typical-data';
import { contactFactory, userFactory } from './factories';
export const db = createDatabase({
factories: {
contacts: contactFactory,
users: userFactory,
},
});
Now you can create and query data in your mock API and in your tests. Example with Mock Service Worker and React Testing Library:
setupServer(
rest.get('/api/contacts/:id', (req, res, ctx) => {
const { id } = req.params;
const contact = db.contacts.find((contact) => contact.id === id);
if (!contact) {
return res(ctx.status(404));
} else {
return res(ctx.json({ contact }));
}
}),
rest.post('/api/contacts', (req, res, ctx) => {
const { name, email, phone } = req.body;
const contact = db.contacts.create({
name,
email,
phone,
});
return res(ctx.json({ contact }));
})
);
it('fetches and displays contact info', async () => {
// create a contact in the database
const contact = db.contacts.create({
name: 'Alice',
email: '[email protected]',
phone: '(555) 248-1632',
});
// ContactDetails will fetch the contact with the provided id from the api
await render(<ContactDetails id={contact.id} />);
await screen.findByText(contact.name);
screen.getByText(contact.email);
screen.getByText(contact.phone);
});
it('creates a contact', async () => {
await render(<CreateContactScreen />);
user.type(screen.getByLabelText('name'), 'Bob');
user.type(screen.getByLabelText('email'), '[email protected]');
user.type(screen.getByLabelText('phone'), '(555) 123-4567');
user.click(screen.getByRole('button', { name: /created/ }));
await screen.findByText(/contact created!/);
// contact is persisted
expect(db.contacts).toHaveLength(1);
expect(db.contacts[0].name).toBe('Bob');
});
Factories
Factories provide a flexible DSL to customize how your objects are created. Factories are designed to integrate with a database, but can also be used standalone.
Factories are created using the createFactory
function. It supports two different forms to define factories: an "attributes" notation and a "builder callback" notation. The "attributes" notation lets you define factories that just specify attributes. The "builder callback" notation lets you define more complex factories with attributes, inheritance, transient params, traits, and afterBuild hooks.
Attributes Notation
import { createFactory } from 'typical-data';
const contactFactory = createFactory<Contact>({
id: 1,
type: 'individual',
phone: '(555) 123-4567',
name: 'Alice',
});
Builder Callback Notation
import { createFactory } from 'typical-data';
const contactFactory = createFactory((factory) =>
factory
.extends(parentFactory)
.transient({
upcaseName: false,
})
.attributes<Contact>({
id: 1,
type: 'individual',
phone: '(555) 123-4567',
name: 'Alice',
})
.trait('business', {
type: 'business',
})
.afterBuild(({ entity, transientParams }) => {
if (transientParams.upcaseName) {
entity.name = entity.name.toUpperCase();
}
})
);
Attributes
A factory defines default attributes for an object. Attributes can be set to a static value or they can be defined dynamically using a function.
import { createFactory } from 'typical-data';
import faker from 'faker';
import { Contact } from './your-types';
const contactFactory = createFactory<Contact>({
id: 1,
type: 'individual',
phone: '(555) 123-4567',
name: () => faker.name.findName(),
});
The build method accepts attributes that will override the defaults defined on the factory.
const businessContact = contactFactory.build({
type: 'business',
name: 'Mega Lo Mart',
});
Providing an explicit type argument to
createFactory
will enable type-checking in both the factory definition and the build method. Type-safety for the build method requires TypeScript >= 4.0.0 due to the use of variadic tuple types.
Sequences
A sequence is an integer that increments on each invocation of the factory build
or buildList
method. This is helpful for generating unique IDs or varying the data returned by the factory.
const contactFactory = createFactory<Contact>({
id({ sequence }) {
return sequence;
},
type({ sequence }) {
const types = ['individual', 'business'];
return types[sequence % 2];
},
phone: '(555) 123-4567',
name: 'Alice',
});
const contact1 = contactFactory.build();
contact1.id; // 0
contact1.type; // individual
const contact2 = contactFactory.build();
contact2.id; // 1
contact2.type; // business
Sequences can be reset back to 0 with the rewindSequence
method.
contactFactory.rewindSequence();
Derived Attributes
Attributes can be derived from other attributes with the entity
option.
const userFactory = createFactory<User>({
id: 1,
title: 'Dr.',
firstName: () => faker.name.firstName(),
lastName: () => faker.name.lastName(),
fullName({ entity }) {
return `${entity.title} ${entity.firstName} ${entity.lastName}`;
},
});
Transient Params
Transient params are arguments that can be passed to the build method that are not merged into the returned object. They can be used to provide options to attribute builders and afterBuild hooks.
The transient
method defines the default values for transient params. The types for transient params are inferred by the compiler and will be type-safe in the build method, just like regular attributes.
const contactFactory = createFactory((factory) =>
factory
.transient({ areaCode: 555, downcaseName: false })
.attributes<Contact>({
id: 1,
email: '[email protected]',
phone({ transientParams }) {
return `(${transientParams.areaCode}) 123-4567`;
}
name: 'Alice',
})
.afterBuild(({ entity, transientParams }) => {
if (transientParams.downcaseName) {
entity.name = entity.name.toLowerCase();
}
})
);
// no overrides provided, will use default transient param values
contactFactory.build()
contact.phone // '(555) 123-4567'
contact.name // 'Alice'
// will use provided transient params
contactFactory.build({ areaCode: 530, downcaseName: true })
contact.phone // '(530) 123-4567'
contact.name // 'alice'
With the builder callback notation, we provide a type argument to the
attributes
method instead ofcreateFactory
. Moving the type allows the compiler to infer the type of the transient params because TypeScript does not yet support partial type argument inference.
Traits
Traits allow you to group attributes together and apply them by passing the trait name to the build
method. Similar to createFactory
, the trait
method supports two different forms of defining a trait: an "attributes" notation and a "builder callback" notation.
const userFactory = createFactory((factory) =>
factory
.attributes<User>({
id: 1,
type: 'member',
isAdmin: false,
isActive: true,
firstName: 'Alice',
lastName: 'Smith',
})
.trait('admin', {
type: 'admin',
isAdmin: true,
})
.trait('inactive', {
isActive: false,
})
);
const adminUser = userFactory.build('admin');
user.type; // 'admin'
user.isAdmin; // true
const inactiveUserContact = userFactory.build('inactive', 'admin');
user.type; // 'admin'
user.isAdmin; // true
user.isActive; // false
Traits can define their own transient params and after build hooks using the alternative builder syntax.
const userFactory = createFactory((factory) =>
factory
.attributes<User>({
id: 1,
email: '[email protected]',
name: 'Alice',
type: 'admin',
posts: () => [],
})
.trait('withPosts', (trait) =>
trait
.transient({ postCount: 0 })
.attributes({
type: 'author',
})
.afterBuild(({ entity, transientParams }) => {
const { postCount } = transientParams;
entity.posts.push(
...postFactory.buildList(postCount, { userId: entity.id })
);
})
)
);
const user = userFactory.build('withPosts', { postCount: 5 });
users.posts.length; // 5
After Build Hooks
After build hooks allow you to run custom logic after an entity has been created. The created entity is passed to the callback as well as any transient params.
const contactFactory = createFactory((factory) =>
factory
.transient({ upcaseName: false })
.attributes<Contact>({
id: 1,
email: '[email protected]',
phone: '(555) 123,4567',
name: 'Alice',
})
.afterBuild(({ entity, transientParams }) => {
if (transientParams.upcaseName) {
entity.name = entity.name.toUpperCase();
}
})
);
// no overrides provided, will use default transient param values
contactFactory.build();
contact.name; // 'Alice'
// will use provided transient params
contactFactory.build({ upcaseName: true });
contact.name; // 'ALICE'
Extending Factories
Factories can extend from one or more parent factories. This is helpful for sharing logic between factories and modeling inheritance. Transient params, attributes, traits, and after build hooks defined on the parent will be inherited.
Sharing logic
const phoneFactory = createFactory((factory) =>
factory
.transient({ areaCode: 555 })
.attributes<{ phone: string }>({
phone({ transientParams }) {
return `(${transientParams.areaCode}) 123-4567`;
},
})
);
const timestampFactory = createFactory((factory) =>
factory
.transient({ timeZone: 'UTC' })
.attributes<{ createdAt: string; updatedAt: string }>({
createdAt({ transientParams }) {
return dateInTimezone(transientParams.timeZone);
},
updatedAt({ transientParams }) {
return dateInTimezone(transientParams.timeZone);
},
})
);
const contactFactory = createFactory((factory) =>
factory
.extends(phoneFactory, timestampFactory)
.attributes<{
id: number;
name: string;
phone: string;
createdAt: string;
updatedAt: string;
}>({
id: 1,
name: 'Alice',
})
);
const contact = contactFactory.build({
areaCode: 530,
timeZone: 'America/Los_Angeles',
});
Inheritance
interface BaseContact {
id: number;
email: string;
}
interface BusinessContact extends BaseContact {
businessName: string;
}
const baseContactFactory = createFactory<BaseContact>({
id: 1,
email: '[email protected]',
});
const businessContactFactory = createFactory((factory) =>
factory
.extends(baseContactFactory)
.attributes<BusinessContact>({
businessName: 'Mega Lo Mart',
})
);
By providing a type to both the parent and child factories'
attributes
methods, Typical Data will infer which attributes the child shares with the parent and will not require redefining them in the child factory.
Database
Database Setup
Databases are created by passing factories to the factories
config option.
import { createDatabase } from 'typical-data';
import { userFactory, contactFactory } from './factories';
const db = createDatabase({
factories: {
users: userFactory,
contacts: contactFactory,
},
});
Now you can create and query objects through the database. The create
and createList
methods have the same signature as the factory build
and buildList
methods.
db.users.create({ name: 'Bob' });
db.users.createList(10, { tenantId: 20 });
const contact = db.users.find((contact) => contact.name === 'Bob');
Inheritance
Factories can be extended to model inheritance relationships. To store child objects in the same "table", for example to model single-table inheritance, you can nest child factories under a shared key. In the example below, both individual and business contacts will be persisted in db.contacts
.
const db = createDatabase({
contacts: {
individual: individualContactFactory,
business: businessContactFactory,
},
});
db.contacts.individual.create();
db.contacts.business.create();
db.contacts.length; // 2
Fixtures
You can seed your database with pre-defined objects using the fixtures
option. This option accepts a callback function that will be passed the database instance.
The fixtures method can optionally return an object of 'named' fixtures. These fixtures are made accessible on the db.fixtures
property.
const db = createDatabase({
factories: {
tenants: tenantFactory,
users: userFactory,
contacts: contactFactory,
},
fixtures(self) {
const currentTenant = self.tenants.create();
const currentUser = self.users.create({ tenantId: currentTenant.id });
return {
tenants: { currentTenant },
users: { currentUser },
};
},
});
const { currentTenant } = db.fixtures.tenants;
const { currentUser } = db.fixtures.users;
If you don't need direct access to the fixtures, you can just create objects in the fixtures method and return nothing.
const db = createDatabase({
factories: {
contacts: contactFactory,
},
fixtures(self) {
self.contacts.createList(10);
},
});
Querying
Support for database-like querying is on the roadmap. For now, the object stores are just extended JavaScript arrays, so you can use normal array methods to find and manipulate data.
db.users.find((user) => user.id === id);
db.users.filter((user) => user.type === 'admin');
Reset
The state of the database can be reset back to its original state with the reset
method. This will delete everything in the database and also re-initialize any fixtures. Calling this in a global hook before each test can be useful to get your database back to a clean slate for each test. Example with Jest:
// jest.setup-after-env.js
import { db } from './your-db';
beforeEach(() => {
db.reset();
});
Credits
- The factory DSL is modeled after the Factory Bot gem.
- The factory builder callback notation is based on the Redux Toolkit
createReducer
helper. - The idea for an in-memory database composed of factories came from Mirage JS.