This is a second post in a series discussing test fixture generators and the efate library. The first post discusses how to use efate. This post discusses how you can extend the efate library. You might want to give that a read first to give you a bit of background.
Efate is actually the second test fixture library I've written and there were several lessons I learned after using the first iteration for several years myself. The first version was influenced a great deal by factor_girl (called factory_bot now), with a heavy dependency on strings to define and create the fixtures. It also wasn't very modular. You couldn't just import a specific fixture, you had to bring in the whole library. And it wasn't very extensible, if you needed to define custom behavior for how a field should be created, it wasn't very pretty.
I was able to solve these problems in efate; it's modular by default, some (I would say) clever Typescript practically eliminates strings in definitions, and it was designed to be extensible, which is the subject of this article. You can extend the library by adding your own property builder that lets you define how properties in your object graph should be populated
Creating a Property Builder Plugin
When you define a fixture, you get to determine exactly how each property should be populated, whether it's a string, a date, an email address, property builders are used to generate the value. There are two independent, but linked components that describe the builder and its behavior when populating a field:
- An interface that describes the builder function when
- An object that has a method that matches the name declared in the interface and returns a curried function that generates value at creation time.
Defining the Property Builder Interface
We'll use the asDate()
property builder as our example*. It takes an option during definition to affect its behavior during creation, whether or not the next date is incremented by day. If not, the default behavior to use now
as the value of the field.
The first step is to create an interface that describes the method that is used during fixture definition, describing any options that can be expressed during definition. The DateBuilder
lets you customize how the date values are created. The interface is relatively simple:
export interface DateBuilderOptions {
incrementDay: boolean;
}
export interface DateBuilderExtension{
asDate: (options?: DateBuilderOptions) => void;
}
There is no implementation of this interface, it's just used to define the builder function and what options it provides at fixture definition. My next post will be about efate internals and how this works.
Create the builder object
Okay, the next part is a bit tricky, but once you see the pattern and understand what it's doing it's not so bad. You now need an object that matches the name of the function in the Extension interface, but it will return a curried function that returns a FieldGeneratorFunc
type FieldGeneratorFunc = (increment: number) => Field<any>;
// I'm writing these out as named functions,
// but in the source code you will see these as lambda functions
function asDateBuilder(name: string, [options]: [DateBuilderOptions?] = [defaultOptions]){
return function dateBuilderGenerator(increment: number){
const date = new Date();
if (options?.incrementDay) {
date.setDate(date.getDate() + (increment - 1));
}
return new Field(fieldName, date);
}
}
export const dateBuilderExtension {
asDate: asDateBuilder
}
Okay, so let's break this down a bit. The outer function gets executed during fixture definition. It has one required parameter, name
that is passed to the function internally when the fixture is defined. The second parameter is the array of arguments that are passed to the definition function. As a convention, I use a single options object for this parameter for consistency. However that is not a requirement.
The second function is executed at creation time and must conform to the FieldGeneratorFunc
type definition. The fixture tracks how many times you create a fixture internally and will pass that value as the increment
parameter. You can choose how to use this parameter, or ignore it. The function returns a Field<T>
object, which is just a simple object that tracks the name of the field and it's value.
Adding your Extension
To Actually extend efate, you need to pass the extension interface as a type parameter and the extension object as a function parameter to the fixture factory.
import {createFixtureFactory} from 'efate';
// import both the interface and the object for the extension
import {DateBuilderExtension, dateBuilderExtension} from 'DateBuilderExtension'
const createFixture = createFixtureFactory<DateBuilderExtension>(dateBuilderExtension);
//start using your extension to build your fixtures
If you have more than one extenstion, you can combine them using a type intersection.
import {createFixtureFactory} from 'efate';
// import both the interface and the object for the extension
import {DateBuilderExtension, dateBuilderExtension} from 'DateBuilderExtension'
import {UUIDExtension, uuidExtension} from 'efate-uuid';
const createFixture = createFixtureFactory<DateBuilderExtension & UUIDExtension>(dateBuilderExtension, uuidExtension);
//start using your extension to build your fixtures
const userFixture = createFixture<User>(t => {
t.id.asUUID();
t.date.asDate();
});
Adding the extensible behavior to efate's growing list of internally provided property builders, it should be able to meet any need. It does gets more complicated as you move away from random fields to a complete object graph. efate-uuid is an example of a simple extension you can review. If you come up with a good extension, please consider contributing it to the library. I accept pull requests!
*The date builder was the first pass at passing additional options to a builder at design time. There are lots of other ways someone might want to modify the behavior of date generation. As always suggestions and pull requests are welcome.
Top comments (0)