How to reduce duplicate code and improve your tests with factory functions

by Sander — 7 minutes

When a test suite grows in size, you often start to see code duplication. This is mainly reflected in the arrange / setup step. One way to avoid this is to take advantage of factory functions. The use of factory functions is a universal pattern that ensures the code remains maintainable and easy to read.

In this blog post, you’ll learn what factory functions are, how they can reduce duplication and improve your tests. The code samples were created with JavaScript, Jest and Vue. Because this pattern is universal, it can be applied with any other language, tool or framework.

The problem

Imagine we have 20 tests, each creating objects by invoking the MasterService constructor (could be other factory functions instead) like the following example:

describe("master-service.js", () => {
  it("Test 1", () => {
    // Arrange
    const http = "fakeHttpMock";
    const logger = "fakeLoggerMock";
    const anotherParameter = "fakeAnotherParameterMock";
    const masterService = new MasterService({
      http,
      logger,
      anotherParameter,
    });

    // Act & Assert..
  });

  it("Test 2", () => {
    // Arrange
    const http = "fakeHttpMock";
    const logger = "fakeLoggerMock";
    const anotherParameter = "fakeAnotherParameterMock";
    const masterService = new MasterService({
      http,
      logger,
      anotherParameter,
    });

    // Act & Assert..
  });

  it("Test 3", () => {
    // Arrange
    const http = "--anotherFakeHttpMock--";
    const logger = "fakeLoggerMock";
    const anotherParameter = "fakeAnotherParameterMock";
    const masterService = new MasterService({
      http,
      logger,
      anotherParameter,
    });

    // Act & Assert..
  });

  // Other 17 tests...
});

It doesn't look too bad, but if the requirements change and the MasterService needs additional or less constructor parameters, all the tests will fail and we have to fix them individually. Besides that, a lot of duplicate arrange / setup code starts to appear, especially when the parameters of the constructor increase. This can cause a lot of noise in your tests and violates the famous DRY (Don't Repeat Yourself) principle.

What are factory functions?

Before we go to the solution you need to know what factory functions are. A factory function is a design pattern or a common way to solve a problem. There is really nothing difficult about them. They are just functions that creates objects and returns them. Factory functions make it easy to create objects by extracting the logic of object creation into a function. The best way to explain them are by a example:

// Import a object
import MasterService from "master-service";

const createMasterService = () => {
  // Create the object and return it
  return new MasterService();
};

Note: A common naming convention is that factory functions start with the word create.

Now that we know what factory functions are, we can evolve them further by setting default constructor parameters and overwrite the parameters given by the function. This is very useful when we have to set different parameters for each test:

import MasterService from "master-service";

const createMasterService = (overrides = {}) => {
  return new MasterService({
    http: "fakeHttpMock",
    logger: "fakeLoggerMock",
    fakeAnotherParameterMock: "fakeAnotherParameterMock",
    ...overrides,
  });
};

The solution

Let's take the MasterService.js test suite from the problem chapter and refactor it with the factory function called createMasterService which we also created before:

import MasterService from "master-service";

describe("master-service.js", () => {
  const createMasterService = (overrides = {}) => {
    return new MasterService({
      http: "fakeHttpMock",
      logger: "fakeLoggerMock",
      fakeAnotherParameterMock: "fakeAnotherParameterMock",
      ...overrides,
    });
  };

  it("Test 1", () => {
    // Arrange
    const masterService = createMasterService();

    // Act & Assert..
  });

  it("Test 2", () => {
    // Arrange
    const masterService = createMasterService();

    // Act & Assert..
  });

  it("Test 3", () => {
    // Arrange
    const masterService = createMasterService({
      http: "--anotherFakeHttpMock--",
    });

    // Act & Assert..
  });

  // Other 17 tests...
});

We've reduced the duplicate code and moved the logic of object creation into a factory function. We also set some default parameters ('fakeHttpMock' and 'fakeLoggerMock'). Overriding the default parameters is still possible:

it("Test 3", () => {
  // Arrange
  const masterService = createMasterService({
    http: "--anotherFakeHttpMock--",
  });

  // Act & Assert..
});

The advantage of this is that the arrange / setup becomes less noisy and easier to read. Also adding or removing additional parameters for each test can be done in one single place. This can make the code more maintainable.

Example with a common front-end framework

In this chapter we go through an example with a common front-end framework called Vue. As stated before it could be any framework. I choose Vue because it is known for its simplicity and ease of understanding. Imagine the test below tests a product component which renders a product name, picture and a formatted price through something what's called a filter in Vue. The custom filter: formatCurrency needs to be in place for each test else Vue throws errors. Let's take a look at the test suite before the refactor:

import { shallowMount, createLocalVue } from "@vue/test-utils";
import Product from "@/components/product.vue";
import { formatCurrency } from "@/utils/filters";

describe("product.vue", () => {
  it("mounts without errors", () => {
    const localVue = createLocalVue();
    localVue.filter("formatCurrency", formatCurrency);

    const wrapper = shallowMount(Product, {
      localVue,
    });

    expect(wrapper.vm).toBeTruthy();
  });

  it("renders a product name", () => {
    const localVue = createLocalVue();
    localVue.filter("formatCurrency", formatCurrency);

    const name = "Laudantium excepturi";
    const wrapper = shallowMount(Product, {
      localVue,
      propsData: {
        name,
      },
    });

    expect(wrapper.find(".product-name").text()).toBe(name);
  });

  it("renders a picture", () => {
    const localVue = createLocalVue();
    localVue.filter("formatCurrency", formatCurrency);

    const picture = "https://svgur.com/i/NS7.svg";
    const wrapper = shallowMount(Product, {
      localVue,
      propsData: {
        picture,
      },
    });

    const image = wrapper.find(".product-picture");

    expect(image.attributes("src")).toBe(picture);
  });

  it("renders a formatted price", () => {
    const localVue = createLocalVue();
    localVue.filter("formatCurrency", formatCurrency);

    const wrapper = shallowMount(Product, {
      propsData: {
        price: 100,
      },
    });

    expect(wrapper.find(".product-price").text()).toBe("€100.00");
  });
});

Now lets refactor the test suite from before with a factory function called createWrapper:

import { shallowMount, createLocalVue } from "@vue/test-utils";
import Product from "@/components/product.component.vue";
import { formatCurrency } from "@/utils/filters";

describe("product.vue", () => {
  const createWrapper = (propsOverrides = {}) => {
    const localVue = createLocalVue();
    localVue.filter("formatCurrency", formatCurrency);
    return shallowMount(Product, {
      localVue,
      propsData: {
        ...propsOverrides,
      },
    });
  };

  it("mounts without errors", () => {
    const wrapper = createWrapper();

    expect(wrapper.vm).toBeTruthy();
  });

  it("renders a product name", () => {
    const name = "Laudantium excepturi";
    const wrapper = createWrapper({ name });

    expect(wrapper.find(".product-name").text()).toBe(name);
  });

  it("renders a picture", () => {
    const picture = "https://svgur.com/i/NS7.svg";
    const wrapper = createWrapper({ picture });

    const image = wrapper.find(".product-picture");

    expect(image.attributes("src")).toBe(picture);
  });

  it("renders a formatted price", () => {
    const wrapper = createWrapper({ price: 100 });

    expect(wrapper.find(".product-price").text()).toBe("€100.00");
  });
});

Again we've reduced the duplicate code and moved the logic of object creation into a factory function.

The trade-offs

Most things in life come with a cost. The cost of using factory functions is that you increase the abstractions in your code. If you take this pattern to the extreme the tests will instead become more difficult to understand, forcing future developers to spend more time to understand how the code behaves. If a small test stands on its own without any abstractions, it can be easier to understand. Taking this into consideration, repetition in testing isn't always bad. In my experience, using factory functions in large test suites is usually worth the cost of additional abstractions.

Summary

  • Use factory functions to get a new instance of an object.
  • Reduce boilerplate and duplicate code by extracting common behavior in a factory function.
  • Using factory functions for code duplication is a universal pattern.
  • Using factory functions can make test code more complex.
  • The trade-off in using factory functions is not always worth the benefit of removing duplication.

meerdivotion

Cases

Blogs

Event