Chapter 8. Organizing tests with factory functions

This chapter covers

  • Understanding factory functions
  • Using factory functions to organize tests

As a test suite grows in size, you start to see repeated code. One way to avoid this is to use factory functions to organize tests.

Factory functions are functions that return new objects or instances (you might know them as builders). You can add factory functions to tests that require repetitive setup to remove code duplication.

Using factory functions is a pattern that helps keep test code easy to read and understand. In this chapter, you’ll learn what factory functions are, how they can reduce repetition in tests, and how they improve your test code structure.

After you’ve learned what factory functions are and the benefits that they bring, you’ll refactor the test code in ItemList.vue to use factory functions.

8.1. Understanding factory functions

Factory functions make it easier to create objects by extracting the logic used to create the objects into a function. The best way to explain factory functions is with an example. Imagine you were writing tests for a component that used a Vue instance $t property. Each time you created a wrapper with Vue Test Utils, you would need to mock the $t function, as shown in the next listing.

Listing 8.1. Creating a wrapper object
const wrapper = shallowMount(TestComponent, {
  mocks: {
    $t: () => {}
  }
}

Instead of adding the same mocks option to each shallowMount call, you could write a createWrapper function that creates and returns a wrapper with the mocks option, as shown next.

Listing 8.2. Using a createWrapper function
function createWrapper() {
  return shallowMountMount(TestComponent, {
    mocks: {
      $t: () => {}
    }
  })
}

const wrapper = createWrapper()
const wrapper2 = createWrapper()
const wrapper3 = createWrapper()

Using factory functions in tests offers two benefits:

  • You avoid repeating code.
  • Factory functions give you a pattern to follow.

In this section, I’ll talk you through both of these benefits in detail, as well as the trade-offs of factory functions. First, let’s take a look at why you should avoid repetition by discussing the DRY principle and how factory functions keep code DRY.

8.1.1. Keeping code DRY

Don’t repeat yourself (DRY) is a well-known programming principle. The DRY principle states that if you find yourself writing similar code multiple times in an application, you should extract the shared logic into a function or method instead of duplicating code between parts of a codebase.

You can use factory functions to follow the DRY principle (also known as keeping the code DRY). Moving duplicated object-creation logic into a factory function keeps the code DRY.

In Vue unit tests, it’s common to call shallowMount with lots of options, like the code in the following listing.

Listing 8.3. Creating a wrapper
const wrapper = shallowMount(TestComponent, {
  mocks: {
    $bar: {
      start: jest.fn(),
      finish: jest.fn()
    }
  }
})

If you have multiple tests that call shallowMount with the same options, you can move logic for calling shallowMount into a createWrapper function that calls shallowMount with the correct options. Rather than writing the same wrapper options in each test, you call the createWrapper function to get a wrapper of the mounted component:

const wrapper = createWrapper()

Now the logic for creating a wrapper with shallowMount is in one place and the code is kept DRY. If you add new dependencies to the component that you are testing, you can mock the dependency in one place in the factory function, rather than making changes in multiple places.

The other benefit of factory functions is that they give you a pattern to follow.

8.1.2. Improving test code by following a pattern

Most people don’t think about patterns when they write test code. That works for a small test suite, but it can be damaging when your test suite grows large. Without a clear pattern, test code can grow into an unmaintainable mess.

Often, in large codebases, unplanned patterns appear. At first a developer might decide to write a function that mounts a component and checks that the root element has a class. Then other developers start using that function. One day a developer decides to pass some extra data into the function, so they add a new parameter. Before you know it, you have functions with a hundred parameters and names that read like tongue twisters, as follows:

mountComponentAndCheckRendersClass(store, useShallowMount, props, overrides)
     {
  // ..
}

I’ve seen this happen in many different codebases. Each team invents their own pattern to solve common problems without putting any thought into it. It’s completely understandable—if you don’t have a pattern to follow from the start, you’ll end up creating your own.

In the previous chapter, I taught you the before each pattern. In the before each pattern, you rewrite common variables before each test in a beforeEach setup function. This approach avoids repetition in creating objects in tests and is a common pattern used in tests.

Factory functions are an alternative pattern you can use to avoid repetition. Whereas the before each pattern mutates variables that are used between tests, factory functions create new objects each time they are called. If done right, tests that use the factory function pattern are easier to follow than tests that use the before each pattern. That said, the factory function pattern does have some downsides.

8.1.3. Understanding the trade-offs of factory functions

Everything in life comes at a cost. The cost of using factory functions is that you increase the abstractions in your code, which can make tests more difficult for future developers to understand.

Many times, I’ve worked on a codebase and made a change that broke a dusty old test. When I opened the test file to read the broken test, I had to spend 20 minutes deciphering abstractions that I didn’t understand.

When a future developer reads a test they didn’t write, they won’t know what a factory function does without looking at the internals of the function. This requires spending extra time in the file to understand how the test code behaves.

Considering this, in tests, repetition isn’t always bad. If a test is self-contained without any abstractions, it will be easier for a future developer to understand. That’s why I haven’t had you writing factory functions or using the before each pattern from the start of this book.

But at this point in the test suite, the benefit of factory functions is worth the cost of extra abstractions. You’re going to add factory functions to the ItemList component tests. The test code is already quite complex, and you’ll add extra tests to this component in chapter 10. Adding factory functions now will make it much easier to write future tests.

Note

You won’t refactor other test files to use factory functions, because the test code isn’t complex enough to benefit from it.

The first factory function you’ll write will create a Vuex store object.

8.2. Creating a store factory function

To create a Vuex store, you need to instantiate a Vuex instance with a configuration object. This is the kind of object creation that works well in a factory function.

A simple Vuex store factory function returns a store using a configuration object. The ItemList component requires a store with a displayItems getter and a fetchListData action. In src/views/__tests__/ItemList.spec.js, remove let store and let storeOptions from the code, and replace the beforeEach function with the createStore function in the following listing.

Listing 8.4. A createStore factory function
function createStore () {
  const defaultStoreConfig = {
    getters: {
      displayItems: jest.fn()
    },
    actions: {
      fetchListData: jest.fn(() => Promise.resolve())
    }
  }
  return new Vuex.Store(defaultStoreConfig)
}

This solution would work fine if the store should always behave in the same way. But in tests, you often need to control what the store returns. For example, take a look at the first test in src/views/__tests__/ItemList.spec.js—renders an Item with data for each item in displayItems. This test controls the displayItems getter return value. At the moment, the createStore function you wrote will always returns an empty array from the displayItems getter. You need a way to overwrite some of the defaultStoreConfig values used by the createStore factory function.

8.3. Overwriting default options in factory functions

Sometimes you need to change the options used to create objects in factory functions. You can do this in many ways, but I’ll show you what I think is the most intuitive way—merging options.

You have a createStore function that creates and returns a store with some default options, but you want to change the return value of the displayItems getter for one of your tests. One way to change the return value would be to add an items parameter to the createStore function, and set displayItems to return the items, like so:

const items = [{}, {}, {}]
createStore(items)

This solves the problem. You can control the displayItems return value. But what if you want to change the actions.fetchListData value in another test? Well, you could add another parameter, as follows:

const fetchListData = jest.fn()
createStore([], fetchListData)

Again, this works. But continually adding parameters isn’t a good long-term solution. You can imagine other values you would want to overwrite in the future. I’ve seen factory functions in test code with 15 parameters!

Another option would be to pass in an object of values to use instead of the defaults, such as the following:

const fetchListData = jest.fn()
createStore({ actions: { fetchListData }})
const items = [{}, {}, {}]
createStore({ state: { items } })

Passing in an object is good API. It’s easy to add extra options to pass in without editing older tests that use the function.

You can write the createStore function to overwrite only options that are passed in to the object. For example, imagine your default state has two values—items and page. You could write the createStore function to overwrite only the value passed in to the options and to leave the other property of state as the default, as shown next:

const store = createStore({ state: { items: [{}] } })
store.state.page // 1
store.state.items // [{}]

I find this approach easy to work with, especially when you need to override deeply nested objects. To overwrite existing properties in an object with properties from a new object without overwriting the entire object, you need to merge the objects.

Note

Merging an object means combining object properties recursively. In a merge, one object takes precedence over another, so the final object will always use the priority object when a clash of properties occurs.

Writing code to merge objects can be complicated, so why reinvent the wheel? The Lodash library has a merge option that does everything you want it to do. It recursively merges a source object into a destination object, as shown in the next listing.

Listing 8.5. Using Lodash merge
import merge from "lodash.merge"

const defaultOptions = {       1
  state: {
    items: null,
    page: 1
  }
}

const overrides = {            2
  state: {
    items: [{}]
  }
}

merge(defaultOptions, overrides)

  • 1 The destination object to merge into
  • 2 A source object that will be merged into the destination object

By default, it’s not possible to override values with an empty array or an object. This can be a pain if you want to replace a default value with an empty object or an empty array, as shown in the next listing.

Listing 8.6. Using Lodash merge with an array or object
import merge from "lodash.merge"

const defaultOptions = {
  state: {
    arr: [{}],
    obj: {
      nestedProp: true
    }
  }
}

const overrides = {
  state: {
    arr: [],
    obj: {}
  }
}

merge(defaultOptions, overrides)       1

  • 1 The returned object will equal the defaultOptions object. The empty array and the empty object does not overwrite the defaultOptions object properties.

You can change the merge strategy by using the Lodash mergeWith function and providing a customizer function, as shown in listing 8.7. Lodash calls the customizer function each time there is a collision of properties during a merge. If customizer returns a value, Lodash will assign the property using the new value. If customizer returns undefined, Lodash will use the default merge strategy.

Listing 8.7. Using mergeWith with a customizer function
import mergeWith from "lodash.mergewith"

function customizer(objValue, srcValue) {           1
  return srcValue ? srcValue : objValue
}

mergeWith(defaultOptions, overrides, customizer)    2

  • 1 Returns srcValue if it exists; otherwise returns objValue. srcValue is the value of a property in the source object that takes precedence over the target object. This customizer function would always reassign objValue with srcValue when there is a collision.
  • 2 Merges using the customizer function

In your factory function, you’ll use a customizer to overwrite properties if the source object properties are an empty object or an array. Add the following customizer function to the ItemList file, just after the import statements.

Listing 8.8. A customizer function to overwrite empty objects and arrays
function customizer(objValue, srcValue) {
  if (Array.isArray(srcValue)) {                                           1
    return srcValue
  }
  if (srcValue instanceof Object && Object.keys(srcValue).length === 0) {  2
    return srcValue
  }
}

  • 1 If the property that takes precedence is an array, overwrite the value rather than merging the arrays.
  • 2 If the property that takes precedence is an empty object, overwrite the property with an empty object.

Now install lodash.mergewith by running the following command in the command line:

npm install --save-dev lodash.mergewith

When lodash.mergewith has installed, you can use it in the createStore function. Add an import statement next to the other import statements in src/views/__tests__/ItemList.spec.js as follows:

import mergeWith from 'lodash.mergewith'

Edit the createStore function in src/views/__tests__/ItemList.spec.js to take an overrides parameter, and create the store using the result of mergeWith, as in the following code snippet:

function createStore (overrides) {
const defaultStoreConfig = {
getters: {
    displayItems: jest.fn()
  },
  actions: {
    fetchListData: jest.fn(() => Promise.resolve())
  }
}
return new Vuex.Store(
    mergeWith(defaultStoreConfig, overrides, customizer)
  )
}

Awesome—now you can change the store values in each test by passing in overrides to the createStore function. Replace the renders an Item with data for each item in displayItems test in src/views/__tests__/ItemList.spec.js with the code in the next listing.

Listing 8.9. Using a createStore factory function
test('renders an Item with data for each item in displayItems', () => {
  const $bar = {                                                        1
    start: () => {},
    finish: () => {}
  }
  const items = [{}, {}, {}]                                            2
  const store = createStore({                                           3
    getters: {
      displayItems: () => items
    }
  })
  const wrapper = shallowMount(ItemList, {                              4
    mocks: {$bar},
    localVue,
    store
  })
  const Items = wrapper.findAll(Item)
  expect(Items).toHaveLength(items.length)                              5
  Items.wrappers.forEach((wrapper, i) => {
    expect(wrapper.vm.item).toBe(items[i])
  })
})

  • 1 $bar mock to avoid errors when mounting the component
  • 2 Creates mock items to pass into the store
  • 3 Creates a store using mock items
  • 4 Creates the wrapper
  • 5 Asserts that ItemList renders the correct number of Item components

If you run npm run test:unit, you’ll see that the test you just refactored is passing. Unfortunately, lots of other tests aren’t, because you deleted the beforeEach function. You can refactor those tests in a minute, but before you do that, there’s another factory function for you to create.

8.4. Creating a wrapper factory function

In unit tests for Vue components, you can use Vue Test Utils to create a wrapper object of a mounted component. Often you need to add lots of mounting options to create a wrapper, which makes it a prime candidate to move into a factory function.

The wrapper factory function should return a wrapper with the default mounting options. To keep with the convention of createStore, it will be called createWrapper.

The createWrapper function will be similar to the createStore function. It takes optional overrides and returns a mounted component wrapper. Add the createWrapper function from the following listing to src/views/__tests__/Item List.spec.js, below the createStore function.

Listing 8.10. A createWrapper function
function createWrapper (overrides) {    1
  const defaultMountingOptions = {      2
    mocks: {
      $bar: {
        start: jest.fn(),
        finish: jest.fn(),
        fail: jest.fn()
      }
    },
    localVue,
    store: createStore()                3
  }
  return shallowMount(                  4
    ItemList,
    mergeWith(
      defaultMountingOptions,
      overrides,
      customizer
    )
  )
}

  • 1 Accepts optional overrides
  • 2 Defines the default mounting options
  • 3 Creates a default store with the createStore function
  • 4 Returns a wrapper

Now refactor the first test to use createWrapper. Open src/views/__tests__/Item List.spec.js, and replace the renders an Item with data for each item in displayItems test to use the code in the following listing.

Listing 8.11. Using createStore and createWrapper in a test
test('renders an Item with data for each item in displayItems', () => {
  const items = [{}, {}, {}]
  const store = createStore({                    1
    getters: {
      displayItems: () => items
    }
  })

  const wrapper = createWrapper({ store })       2
  const Items = wrapper.findAll(Item)
  expect(Items).toHaveLength(items.length)
  Items.wrappers.forEach((wrapper, i) => {
    expect(wrapper.vm.item).toBe(items[i])
  })
})

  • 1 Uses the createStore factory function to create a store with the correct displayItems getter
  • 2 Uses the createWrapper factory function to return a wrapper, using the store you created as an override

Now run the test to make sure the refactored test still passes: npm run test:unit. As long as you added the correct code it will, but lots of other now-failing tests need your attention.

Tip

After you refactor tests, you should always check that they still pass. If you want to be extra careful (which I always am), you can edit the assertion to make sure it also still fails correctly.

In the next two tests, you don’t need to mock the store, so you don’t need to pass a store to the createWrapper factory function. But you do need to check that the methods inside the mock $bar object are called in the test.

One of the problems with factory functions is that you don’t have a reference to functions or objects used as properties to create an object. This is problematic if you want to test that a mock function was called. The solution is to create mock functions in the test and pass them in as overrides to a factory function, as shown next.

Listing 8.12. Keeping a reference to a mock when using a factory function
test('calls onClose prop when clicked', () => {
  const propsData = {
    onClose: jest.fn()                              1
  }
  const wrapper = createWrapper({ propsData })      2
  wrapper.trigger('click')
  expect(propsData.onClose).toHaveBeenCalled()      3
})

  • 1 Creates a mock function
  • 2 Passes a mock function in propsData to override the default options
  • 3 Asserts that the mock function was called

In your test, you need to keep a reference to the $bar.start function. Replace the calls $bar start on load test with the code in the following listing.

Listing 8.13. Passing a mocks object to createWrapper
test('calls $bar start on render', () => {
  const mocks = {                                 1
    $bar: {
      start: jest.fn()                            2
    }
  }
  createWrapper({ mocks })                        3
  expect(mocks.$bar.start).toHaveBeenCalled()     4
})

  • 1 Creates a mocks object that contains the $bar object
  • 2 Sets $bar.start to a jest mock function
  • 3 Creates a wrapper. There’s no need to assign it to a variable. Creating the wrapper will mount the component and call $bar.start.
  • 4 Uses the reference to $bar.start to check whether it was called

Check that the test passes—npm run test:unit. Now you can refactor the next test to use the factory function. Replace the calls $bar finish when load successful test with the following code.

Listing 8.14. Using a createWrapper factory function
  test('calls $bar finish when load successful', async () => {
    const mocks = {
      $bar: {
        finish: jest.fn()
      }
    }
    createWrapper({ mocks })
    await flushPromises()
    expect(mocks.$bar.finish).toHaveBeenCalled()
  })

Check that the tests pass again with npm run test:unit. The next test to refactor is the dispatches fetchListData with top test. In this test, you need to create a store so that you can mock the store dispatch function. Then you pass the store to the createWrapper function, mount the component, and assert that dispatch was called with the correct arguments. Replace the test with the following code.

Listing 8.15. Mocking actions
test('dispatches fetchListData with top', async () => {
  const store = createStore()                                  1
  store.dispatch = jest.fn(() => Promise.resolve())            2
  createWrapper({ store })                                     3
  await flushPromises()
  expect(store.dispatch).toHaveBeenCalledWith('fetchListData', 4
 { type: 'top' })
})

  • 1 Creates a store
  • 2 Mocks the store dispatch function so you can check that it was called
  • 3 createWrapper, which will mount the component using the store passed in
  • 4 Asserts that the dispatch was called with the correct arguments

The final test to refactor is the calls $bar fail when load is unsuccessful. Replace the test with the following code.

Listing 8.16. Mocking actions to reject
test('calls $bar fail when fetchListData throws', async () => {
    const store = createStore({                                    1
      actions: { fetchListData: jest.fn(() => Promise.reject()) }
    })
    const mocks = {
      $bar: {
        fail: jest.fn()
      }
    }
    createWrapper({ mocks, store })
    await flushPromises()
    expect(mocks.$bar.fail).toHaveBeenCalled()                     2
  })

  • 1 Creates a store with the fetchListData action to return a rejected promise
  • 2 Asserts that $bar.fail was called

Congratulations—you’ve refactored all the tests in ItemList.spec.js. In chapter 10 you’ll see the awesome power of factory functions when you learn how to test Vue Router.

Before you move on to the next chapter, let’s recap what you learned in this chapter.

Summary

  • Factory functions remove duplicate logic.
  • 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.
  • You can merge options in factory functions using the Lodash mergeWith function to easily overwrite default options.

Exercises

1

What does DRY stand for?

2

What are the benefits of using factory functions in tests?

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.46.36