Elevate Your Vue.js Game: Effective Component Testing Strategies

Elevate Your Vue.js Game: Effective Component Testing Strategies
Photo by Sigmund / Unsplash

In the dynamic landscape of web development, Vue.js has emerged as a powerful framework for building interactive and robust user interfaces. However, as your applications grow in complexity, ensuring the reliability and stability of your Vue.js components becomes paramount. Welcome to my guide on mastering Vue.js component testing, where i will try my best to give you the best practices to fortify your codebase and elevate your development workflow.

Structure your tests effectively

The first step to write a better test is to understand what you are trying to archive when writing unit tests. We should focus on the component outputs, which are:

  • Rendered template
  • Emitted events
  • Side effects ( api calls , call imported functions, vuex action call)

However, this is how we are commonly structuring our test cases:

describe('methods', () => {
   // Testing every method property
})
describe('computed', () => {
   // Testing every computed property
})
describe('template', () => {
   // Snapshot assert
})

This however is not very effective since we are testing a lot of unnecessary things, the only valuable test will be snapshot assert. So how can we organize things better?

Test the expected behaviour

Organize desired behaviours in different described blocks ( Do you know that you can have describe inside describe that inside describe … 🧐 ? but you shouldn’t be nested it too hard)

The describe + it or the describe + test should sound like a phrase. Example with describe + it: My awesome button should render a text. Example with describe + test: My awesome button renders a text

describe('My awesome button', () => {
    it('should render a text', () => {})
	
    describe('Active state',() => {
      it('should be clickable',() => {})
    })

	describe('In Active state', () => {
	  it('should not be clickable',() => {})
	})
})

A real-world example:

<template>
	<div class="container">
	  <p v-if="isLoading">
		Loading ...
	  </p>
      <div v-else>
        <p v-if="hasError">
    	  Oh no 😭
    	</p>
        <p v-else>
    	  Hello World
    	</p>
       </div>
	</div>
</template>

Test cases should be organized based on the template condition and things we are expected when using this component, what is going to show when the component is loading? when it is finished loading with no error do we have something displayed? By focusing on the expected output we can start to have better organization of our tests. And another advantage is it makes everything more readable and easier to maintain.

describe('When loading',() => {
  it('should render loading message', () => {});
  it('should not render error message', () => {});
  it('should not render content', () => {});
})
describe('Error occured', () => {
  it('should not render loading message', () => {});
  it('should render error message', () => {});
  it('should does not render content', () => {});
})

Start writing your test with a factory / component builder !

The next thing you can try is to have a factory create your component so that you can avoid repeating yourself and crentralizing the component creation logic.

Consider the following setup using a factory function

describe('Component', () => {
  let wrapper
  
  const factory = ({ /* ... optional parameters */ }) => {
    return mount(Component, {
      localVue,
      props: { /* ... optional parameters */ }
    })
  }

  afterEach(() => {
     // Clean up
     wrapper.destroy()
  })

  it('mounts', () => {
	 wrapper = factory();
     expect(wrapper.exists()).toBeTruthy();
  })
})

Tips: You should recreate your wrapper for each test to avoid side effects (state shared between test cases creating weird stuff, and prone to break when something changes). This will ensure that each test case operate independently, reducing the likelihood of unexpected behavior.

Use a helper to find your element

Now we already centralize our logic to create the component. To further enhance the clarity and maintainability of your tests, you can try is to have a dedicated function to find your element in the wrapper’s DOM. This not only simplifies the test code but also ensures consistency in selecting elements across multiple test cases.

💡 Remember to use data-testid as a test selector!

Test selectors for markup

describe('Component', () => {
  let wrapper;
 
  const findButton = () =>  wrapper.find("[data-testid='my-button']");

  it('has a button', () => {
     wrapper = factory();
     const button = findButton();
     expect(button.exists()).toBeTruthy();
  })
})

Using data-testid for testability

When developing Vue.js components, it's crucial to ensure that our code remains robust and maintainable, especially as our application evolves to accommodate product requirements, bug fixes, and new features. One effective practice to enhance the testability of our components is by utilizing data-testid attributes to uniquely identify elements.

Consider the following Vue.js component snippet

<div>
	<button @click="increment()" data-testid="myBtn">
		Current {{ value }} 
		</button>
		<p data-testid="myBtnDouble">{{ double }}</p>
</div>

In this example, we've added data-testid attributes to both the button and the paragraph elements. This will serve as markers that can be easily targeted by our unit tests and end-to-end tests.

This approach will help us to rely on CSS selectors or complex DOM traversal which are fragile and prone to breaking with changes in the UI structures. On top of that the data-testid attribute enhance our code readability as we can explicitly comment the purpose of each element within the context of testing.

Don’t test the internal

Given that we need to test this component

<template>
  <div>
    <button @click="increment()">
	  Current {{ value }} 
	</button>
	<p>{{ double }}</p>
  </div>
</template>
<script>
export default {
  data() {
   return {
	 value: 1
	}
   },
   computed() {
  	 double() {
  	   return this.value * 2;
	  }
	},
    methods: {
	  increment() {
		this.value++;
	  }
   }
}
</script>

Then this is how you normally will test it

describe('button',() => {
	it('should have correct double', () => {
		wrapper = factory();
		expect(wrapper.double).toBe(2);
	});

	it('should call sendTracking method when click', () => {
		wrapper = factory();
        jest.spyOn(wrapper.vm, 'increment').mockImplementation(() => {});
		const button = findBtn();
		button.trigger('click');
		expect(wrapper.vm.increment).toHaveBeenCalled();
  })
})

You are testing the framework, not the real behavior, unfortunately. How Vue detects the change of property and reflects correctly with a computed method is not what we are interested in.

But what is the recommended way, you may ask 🤔

Test the rendered output !

Instead of using wrapper.vm to test the internal calculated value. We should use the text() method or html() method of Wrapper to have the rendered content of an element.

A better test for the previous example:

describe('button',() => {
	let wrapper;
	const findBtn = () => wrapper.find("[data-testid='myBtn']");
	const findBtnDouble = () => wrapper.find("[data-testid='myBtnDouble']");
	it('should have correct double', () => {
		wrapper = factory();
		expect(findBtnDouble().text()).toBe(2);
	});

	it('should increase value when click', async () => {
		wrapper = factory();

		const button = findBtn();
		button.trigger('click');
		// We need a next tick here to wait for vue to update the template 🤓
		await wraper.$nextTick();
		expect(findBtn().text()).toBe('Current 2');
  })
})

Even better

describe('button',() => {
	let wrapper;
	const findBtn = () => wrapper.find("[data-testid='myBtn']");
	const findBtnDouble = () => wrapper.find("[data-testid='myBtn']");
	
	it.each`
     value | double
     0     | 0
	 1     | 2
     4     | 8
     10    | 20
  `('should render $double when value is $value', (({ value, double}) => {
			// Factory function with parameter in action ✨
			wrapper = factory({ value });
			expect(findBtnDouble().text()).toBe(double);
  });

	it('should increase value when click', async () => {
		wrapper = factory();
		const button = findBtn();
		button.trigger('click');
		await wraper.$nextTick();
		expect(findBtn().text()).toBe('Current 2');
  })
})

With this approach when we rename/refactor data, computed, or method we don’t need to change the test. But why ? I will explain it further in the next section.

“Oops” moments 🤷‍♂️

Imagine someone changes the template

<div>
		<button @click="increment()" data-testid="myBtn">
				Current {{ value }} 
		</button>
		<p data-testid="myBtnDouble">{{ value }}</p>
</div>

The following test will still work

	it('should have correct double', () => {
		wrapper = factory();
		expect(wrapper.double).toBe(2);
	});

By asserting the correct render value, our new test will fail

it('should have correct double', () => {
		wrapper = factory();
		expect(findBtnDouble().text()).toBe(2);
});

This may sound silly but it will save you a lot of headaches in the long run 😅. By correctly asserting the expected behaviour, your test code will be easier to understand and you will catch a lot of these gotcha moment easier.

Simulate user behaviour

In our unit tests, we should try to avoid setData or setProp or even worse changing directly the internal value by doing something like wrapper.vm..value = 10

. This doesn't accurately represent how users interact with our component.

Consider the following test case:

it('should increase value when click', () => {
		wrapper = factory();
		const button = findBtn();
		expect(findBtn().text()).toBe('Current 1');
		
		wrapper.vm.value = 2;
		expect(findBtn().text()).toBe('Current 2');
 })

This is not how your component will be used, a better version of this code will be:

it('should increase value when click', async () => {
		wrapper = factory();
		const button = findBtn();
		expect(findBtn().text()).toBe('Current 1');
		
		button.trigger('click');
		await wrapper.$nextTick();
		
		expect(findBtn().text()).toBe('Current 2');
 })

The test becomes more verbose and self-explanatory, making it easier for developers to understand the intended behavior of the component without looking into its implementation details. In this case, when the initial value of the button is 1, when we click on the button, it should be changed to 2.

Don’t forget about vue lifecycle and Microtasks

When you mutate the reactive state in Vue, the resulting DOM updates are not applied synchronously. Instead, Vue buffers them until the "next tick" to ensure that each component updates only once no matter how many state changes you have made.

When you have promises in your mounted or created hook, you should wait for them to finish to start your test by using flushPromise .

Making use of describe.each or it.each

Grouping your test cases using describe.each or it.each makes it easier for others to understand what is going on by using table of possible arguments and making repeatable tests easier (DRY right? 😁)

For example, we have these test cases

const multiply = (a, b) => a * b;

describe("'multiply' utility", () => {
  it("given 2 and 2 as arguments, returns 4", () => {
    const result = add(2, 2);
    expect(result).toEqual(4);
  });
  it("given -2 and -2 as arguments, returns 4", () => {
    const result = add(-2, -2);
    expect(result).toEqual(-4);
  });
  it("given 2 and -2 as arguments, returns 4", () => {
    const result = add(2, -2);
    expect(result).toEqual(0);
  });
});

As you can see a lot of repetition and boilerplate, we can make it easier using it.each !

const multiply = (a, b) => a * b;

const cases = [[2, 2, 4], [-2, -2, 4], [2, -2, 4]];

describe("'multiply' utility", () => {
  it.each(cases)(
    "given %p and %p as arguments, returns %p",
    (firstArg, secondArg, expectedResult) => {
      const result = multiply(firstArg, secondArg);
      expect(result).toEqual(expectedResult);
    }
  );
});
// Or more verbose
describe("'multiply' utility", () => {
  test.each`
      a     | b     | result 
      ${2}  | ${2}  | ${4}
      ${-2} | ${-2} | ${4}
      ${2}  | ${-2} | ${4}
   `(
    "given $a and $b as arguments, returns $result",
    ({a,b,result}) => {
      expect(multiply(a, b)).toEqual(result);
    }
  );
});

Mocking computed instead of the whole store

Vuex getter is basically a computed property in the Vue component. We want to test how our component reacts to a different state of data, and props, so why don’t we cut the dependency of using vuex store getters and mocking directly the expected data 🤔? “What does that even mean, are you telling me to completely remove the store from my test” you may think. Let me give you some examples:

const localVue = createLocalVue()
localVue.use(Vuex)

const store = new StoreBuilder()
  .setState({
    firstName: "Alice",
    lastName: "Doe"
  })
  .setStateGetter('fullname', (state) => state.firstName + " " + state.lastName);

const factory = () => {
	return mount(ComponentWithGetters, { store: storeBuilder.build(), localVue });
}

const findFullname = () => wrapper.find("data-testid='fullName'")

it("renders a username using a real Vuex getter", () => {
  wrapper = factory();

  expect(findFullName().text()).toBe("Alice Doe");
})

In the above code, we are basically rebuilding the store. A lot of setups are involved to get the test running, with a larger component things begin to look messy. But we have to focus on what we want to test: When we have a property fullname with the value “Alice Doe”, our component should render a text block with the content “Alice Doe”, we don’t need to understand how the fullname is calculated, that's what the unit test of the store must cover.

const factory = () => {
	return mount(ComponentWithGetters, { 
		computed: {
			fullname: () => "Alice Doe"
		}
	});
}

const findFullname = () => wrapper.find("data-testid='fullName'")

it("renders a username using a real Vuex getter", () => {
  wrapper = factory();

  expect(findFullName().text()).toBe("Alice Doe");
})

With this approach, we treat the vuex computed like “props”, by mocking it in the factory, another advantage of decoupling like this is in the future if we decided not to use vuex anymore (I heard that Pinia 🍍 is quite awesome 😛, have you tried it? ) our tests don’t need a lot of refactoring!

Mocking vuex action

Something we need to verify is that when we perform an action, a vuex action is called. Using the same approach as the previous section, we can do that without a vuex store!


const mockStore = { dispatch: jest.fn() }

const factory = () => {
	return mount(ComponentWithGetters, { 
		mocks: {
      $store: mockStore
    }
	});
}

it("dispatches an action when a button is clicked", async () => {
    wrapper = factory();
	const button = findButton();
    await wrapper.find(".dispatch").trigger("click")
    expect(mockStore.dispatch).toHaveBeenCalledWith(
      "testAction" , { msg: "Test Dispatch" }
    )
})

Whether you use a real store or a mock store your tests are down to personal preference. Both are correct. The important thing is you are testing your components.

Additional Resources:

Duy NGUYEN

Duy NGUYEN

Paris