My 6 insights about testing

24 Apr, 2023

undefined

Photo credit @neonbrand from unsplash.

The importance of testing

Testing is an important part of development to find bugs and make sure it behaves as expected. As a developer, I think everyone knows the importance of testing. So I won't go over the importance of testing again; instead, I'd like to share how we can improve the quality of testing and make it easier to maintain in our project.

Disclaimer: I wouldn't call myself a testing expert; I simply have some observations. 😅

I'll use Jest as an example testing framework to demonstrate how each insight can be implemented in practice.

# Leverage setup files 📂

Jest's setup files provide a way to configure the test before running any tests. We can use either setupFiles or setupFilesAfterEnv, or both. I'm not sure what the best practice is when using both, I'm used to using only setupFilesAfterEnv. Back to this point, I think setup files can be useful for the following reasons:

  1. Reducing Repetitive Code

If we have some common code that needs to be executed before each test, we can put it in a setup file instead of copying and pasting it into every test file.

  1. Global Mock/Configuration

We can use setup files to set up mocks for the utils or external dependencies. Or doing timeout configuration like jest.setTimeout.

TBH, these reasons are somewhat similar; in my hands-on experience, when setupFiles is used, these two benefits are usually combined. For example if there are encryptUtils and API services:

├── src/
│ ├── api/
│ │ ├── service1.js
│ │ ├── service2.js
│ ├── utils/
│ │ ├── encryptUtils.js
├── test/
│ ├── unit/
│ │ ├── service1.spec.js
│ │ ├── service2.spec.js
│ ├── setup.js

Both service1.js and service2.js use encryptUtils.js, but we don't want encryptUtils to do the real encryption, we may mock this utility in both test files.

❌ Don't
// service1.spec.js
jest.mock('../../src/utils/encryptUtils', () => ({
decrypt: ({ data }) => data,
encrypt: ({ data }) => data,
}));

describe('service1', () => { ... })

// service2.spec.js
jest.mock('../../src/utils/encryptUtils', () => ({
decrypt: ({ data }) => data,
encrypt: ({ data }) => data,
}));

describe('service2', () => { ... })

If we use setup.js, we can make this mock won't be duplicated in every file.

✅ Do
// setup.js
jest.mock('../src/utils/encryptUtils', () => ({
decrypt: ({ data }) => data,
encrypt: ({ data }) => data,
}));

// service1.spec.js
describe('service1', () => { ... })

// service2.spec.js
describe('service2', () => { ... })
Other examples:
# Mocking Node modules 📦

Continuing from the previous point, we can mock external dependencies in setup files. However, if we're mocking the node modules, I prefer to use jest's mocking-node-modules feature. IMO, It can make mocks clearer, also makes more sense.

Let say if the encryptUtils of previous is a node library. We can do like this.

├── node_modules/
│ ├── encryptUtils/ // encryption library
├── __mocks__/ // directory should be adjacent to node_modules
│ ├── encryptUtils.js
├── src/
│ ├── api/
│ │ ├── service1.js
├── test/
│ ├── unit/
│ │ ├── service1.spec.js
✅ Do
// __mocks__/encryptUtils.js
export const decrypt = ({ data }) => data;
export const encrypt = ({ data }) => data;
Note: The common ways to do mocking
# Use it.each 🔄

Jest provides a convenient way to write multiple tests that share the same structure or logic using the it.each method. This allows us to write more concise and readable tests that are easier to maintain over time.

Let say if we have a format number util, it is a very good time to use it.each to test common and edge cases.

✅ Do
// formatNumber.spec.js
describe('formatNumber', () => {
it.each([
{ value: undefined, expected: '0' },
{ value: null, expected: '0' },
{ value: {}, expected: '0' },
{ value: NaN, expected: '0' },
{ value: 1000, expected: '1,000' },
{ value: 10000, expected: '10k' },
])('format $value to $expected', (value, expected) => {
expect(formatNumber(value)).toBe(expected);
});
});
# Test cases should not have dependencies ⚠️

Each test case should be independent.

IMO, when writing tests, ensuring that each test case is independent of other test cases is most important to me, dependencies can make it difficult to isolate and debug issues when tests fail.

I have seen many UI unit tests, each case corresponds to a different UI state, and each case is dependent on the others:

❌ Don't
const component = mount(Component)
describe('Component', () => {
it('should render button', () => { ... })
it('should render modal after clicking button', () => {
button.click() // open the modal
expect(modal.exists()).toBe(true)
})
it('should render modal content when input', () => {
modalInput.input('xxx')
expect(modalContent.exists()).toBe(true)
})
})

If the UI flow changes, testing like above is prone to make many errors. Instead, we should do the following:

✅ Do
describe('Component', () => {
let component
beforeEach(() => {
component = mount(Component)
})
it('should render button', () => { ... })
it('should render modal after clicking button', () => {
button.click() // open the modal
expect(modal.exists()).toBe(true)
})
it('should render modal content when input', () => {
button.click() // open the modal
modalInput.input('xxx')
expect(modalContent.exists()).toBe(true)
})
})

When UI flow changes cause tests to fail, at least we only need to focus on the failed cases, without having to trace the entire code.

# Snapshot 🖨️

Snapshot testing is a powerful tool in Jest that allows we to easily compare the output of a function or component with a saved "snapshot" of the expected output.

I usually use it when testing component or a normalize utility. It can make us write test more clearly and effectively.

describe('MyComponent', () => {
test('should render correctly', () => {
const component = renderer.create(<MyComponent />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
❌ Don't
describe('normalizeObject', () => {
test('should normalize correctly', () => {
const data = { ... } // A big and nested object
const result = normalizeObject()
expect(result).toEqual({
...
...
...
});
});
});

✅ Do
describe('normalizeObject', () => {
test('should normalize correctly', () => {
const data = { ... } // A big and nested object
const result = normalizeObject(data)
expect(result).toMatchSnapshot();
});
});
# Think which part could we extract to a util 🧐

In addition to the business logic code, it is also important to think about what can be extracted into a utility function when writing tests. This can make the code more modular and easier to test. e.g,

Let's take more specific example, since I currently work on vue-test-util migration, there are many updates this time.

// ComponentA.spec.js
import { mount } from '@vue/test-utils'
const wrapper = mount(ComponentA, {
propsData: {}, // --------> this should be updated to props
}

// ComponentB.spec.js
import { mount } from '@vue/test-utils'
const wrapper = mount(ComponentB, {
propsData: {}, // --------> this should be updated to props
}

// ComponentC.spec.js
// ComponentD.spec.js
// ...

Assume we have a large number of test files; migration will be difficult. However, if we wrap the testing library in a util, we should save a lot of time when migrating.

// test/utils/createWrapper
import { mount } from '@vue/test-utils'
const createWrapper = (Component, options) => {
const {
propsData, // --------> no need to update
} = options
return mount(Component, {
propsData, // --------> this should be updated to props: propsData
})
}
// ComponentA.spec.js ----> It might no need to make any changes
import createWrapper from 'utils/createWrapper'
const wrapper = createWrapper(ComponentA, {
propsData: {},
}

// ComponentB.spec.js ----> It might no need to make any changes
import createWrapper from 'utils/createWrapper'
const wrapper = createWrapper(ComponentA, {
propsData: {},
}
// ...

Sum up

I'm happy to share my insights on how to make unit tests more structured. By using these insights, I believe that developers like us can create more reliable and maintainable test suites. I hope that these insights will be useful to developers who want to improve the quality and reliability of their code, and we can feel more confident when it is deployed into production!





← Back home