Search
  • Shai Yallin

Beautiful Object Builders in TypeScript

Updated: Jul 20

A big part of testing is getting or creating data to input into our System Under Test. Good tests require varied data to test multiple scenarios or flows in the system, so wherever we have a good test coverage, we may run into an issue of how to manage these data. A common practice is using fixtures - hard coded values, residing either in external files, or in the test file itself. For instance, if my domain is a Product, I might have these fixtures:

type Product = {
  id: string;
  name: string;
  price: number; // yes I know it should be BigInteger
  currency: "usd" | "eur" | "ils";
  photos: Photo[];
}

type Photo = {
  caption: string;
  url: string;
}

const aProduct = {
  id: "1",
  name: "Purple Plushie Penguin",
  price: 66.6,
  currency: "ils",
  photos: [
    { caption: "it's purple", url: "http://s3.com/penguin.png"}
  ],
}

const productWithNoPhotos = {
  id: "1",
  name: "Purple Plushie Penguin",
  price: 66.6,
  currency: "ils",
  photos: [],
}

I think fixtures are a bad approach; they inadvertently couple tests to each other, and when trying to solve this problem, we end up with too many fixtures. For instance, I might write a test using aProduct assuming it only has one photo, but then someone else (or me two weeks from now) might write a test that needs it to have two photos, forgetting that an existing test relies on the existing fixture having a specific structure.


The immediate solution is to have my own fixture with two photos:

test("a product has two photos", () => {
  const aProductWithTwoPhotos = {
    id: "3",
    name: "Plushie Bender",
    price: 42.6,
    currency: "ils",
    photos: [
      { 
        caption: "I'll build my own fixture", 
        url: "http://example.com/bender.png"
      },
      { 
        caption: "With blackjack and two photos", 
        url: "http://example.com/bender2.png"
      },
    ],
  }
  
  ...
})

I'll even declare the const inside my test function, rather than in a separate file, or in the global scope, to prevent anyone else from ever interfering with my safe little product. The thing is, I'm being too specific about the data. I don't care about anything other than the fact that my product needs to have two photos. These extra properties add noise and reduce readability, and I'm coupling myself too much to the specific data. If one day we decide that price needs to be expressed in cents, I'll have to change all fixtures.


This problem can be solved by spreading a template product into a new object which overrides the template:

const aProductWithTwoPhotos = {
  ...aProduct,
  photos: [
    { 
      caption: "I'll build my own fixture", 
      url: "http://example.com/bender.png"
    },
    { 
      caption: "With blackjack and two photos", 
      url: "http://example.com/bender2.png"
    },
  ],
}

This is better, but it's still too specific - I'm explicitly specifying the product template, and I'm coupling all tests to the template. Another issue with reusing a template is that our data isn't fresh. All products will either have a fixed value or an override. There are some properties I'd like to randomize, like ids, and some I'd like to generate with reasonable defaults, like dates. So I can do something like this:

const aPhoto = () => ({
  caption: "this can be static", 
  url: `http://example.com/${nanoid()}.png`  
})

const aRandomProduct: () => Product = () => ({
  id: nanoid(),
  name: "Purple Plushie Penguin",
  price: Math.random() * 1000,
  dateCreated: new Date(),
  currency: "ils",
  photos: [aPhoto()],
})

const aProductWithTwoPhotos: Product = {
  ...aRandomProduct(),
  photos: [ aPhoto(), aPhoto() ]
}

This is almost perfect. I have factory functions, or Object Builders, as these are frequently called in software engineering, for a Photo and for a Product. I can create a fresh, isolated copy of aRandomProduct() for each new test, or extend it and add my own override properties, for instance two photos. However, I still have to specify the template data, and the compiler won't protect me against breaking the Product shape unless I add an explicit type annotation. We can do better.


The next step in the evolution of the builder function is to invert the control, passing our specific data, the data we actually care about, into a shaped function that returns a new, fresh and unique object:

const aPhoto: (ov: Partial<Photo> = {}) => Photo = (ov) => ({
  caption: "this can be static", 
  url: `http://example.com/${nanoid()}.png`,
  ...ov,
})

const aProduct: (ov: Partial<Product> = {}) => Product = (ov) => ({
  id: nanoid(),
  name: "Purple Plushie Penguin",
  price: Math.random() * 1000,
  dateCreated: new Date(),
  currency: "ils",
  photos: [aPhoto()],
  ...ov
})

This allows me to create default or tailored objects at will:

const product1 = aProduct();
const product2 = aProduct({name: "Plushie Vader"});

The ov argument is a Partial of my target object type, meaning that I don't have to supply any of the properties - a Partial of type T has all properties of T made optional. Since it can be empty, I assign it a default value of the empty object, so that callers can just create aProduct(). This fluent and concise way of creating fresh instances of test data allows me to write better code. For instance, if all I care about is that I have two products, I can express it like this:

productRepo.create([aProduct(), aProduct()]);
expect(productRepo.findAll()).toHaveLength(2);

...which is clean, concise, and only as specific as I have to be and not a bit more. This ability of my tests to be as non-specific as possible makes them more lenient where they can be, and thus less fragile, and easier to maintain (and change).


As a final touch, we can create a meta-builder function to clean up the signatures of specific builders:

type Builder<T> = (overrides?: Partial<T>) => T

const aPhoto: Builder<Photo> = (overrides = {}) => ({
  caption: "this can be static", 
  url: `http://example.com/${nanoid()}.png`,
  ...overrides,
})

const aProduct: Builder<Product> = (overrides = {}) => ({
  id: nanoid(),
  name: "Purple Plushie Penguin",
  price: Math.random() * 1000,
  dateCreated: new Date(),
  currency: "ils",
  photos: [aPhoto()], 
  ...overrides
})

Update:

Following ideas I got while editing this post, I spawned a micro library for building builders.

490 views0 comments

Recent Posts

See All