Unit Testing UI Components

Unit testing is a valuable practice in software development that can significantly reduce the number of bugs by 40% — 80%. It also offers other benefits, such as:
- Improvement of your application architecture and maintainability.
- Enhancement of the developer experience by prioritizing better APIs and composability before implementation details.
- Boost confidence when adding new features or refactoring existing ones.
However, not all aspects of an application are equally easy to unit test. They are particularly effective for testing pure functions, which always return the same output given the same input, and have no side effects.
In the context of UI components, they often don’t fit into the category of easy to unit test, making it challenging to adopt the test-driven development (TDD) discipline of writing tests first.
Writing tests first is important to get the benefits mentioned earlier. It takes practice and discipline to adopt TDD, as most developers prefer to code first before writing tests. If we skip the initial test writing, we end up missing some valuable features of unit tests.
Despite the challenges, practising TDD with unit tests can help simplify UI components, improve maintainability, and enhance their composability and reusability with other components.
Regardless of the testing framework used, the following tips can help in writing better and more testable UI components:
Prefer presentational components
These components always render the same output given the same props. If state is required, we can wrap presentational components with container components, which are responsible for managing state and side effects. Example in Vue.js and Testing Library:
<template>
<div class="greeting">Hello, {{ userName }}!</div>
</template>
<script setup>
defineProps(['userName'])
</script>
These kinds of components are generally very easy to test. You will need a way to select the component (in this case, we’re using the getByText method from the Testing Library), and you’ll need to know the expected output.
import { render, screen } from '@testing-library/vue';
import Hello from './hello.vue';
describe('Hello component', () => {
test('renders a greeting to the correct username', () => {
render(Hello, {
props: {
userName: 'Spiderman'
}
});
screen.getByText('Hello, Spiderman!');
});
});
But that’s not very interesting. What if you need to test a stateful component or a component with side effects? That’s where TDD gets interesting for UI components because the answer to that question is the same as the answer to another important question: “How can I make my UI components more maintainable and easy to debug?”
The answer: Isolate your state and side effects from your presentation components. You can do that by encapsulating your state and side-effect management in a container component and then passing the state into a pure component through props.
But didn’t the composition API (for Vue.js, and hooks API for React) make it so that we can have flat component hierarchies and forget about all that component nesting stuff? Well, not quite. It’s still a good idea to keep your code in three different buckets, and keep these buckets isolated from each other:
- Display/UI Components
- Program logic/business rules — the stuff that deals with the problem you’re solving for the user.
- Side effects (I/O, network, disk, etc.)
If you keep the display/UI concerns separate from program logic and side effects, it makes your life a lot easier.
Let’s demonstrate stateful components by creating a click counter. First, we will create the UI component. It should display something like “Times clicked: 2” to tell you how many times a button has been clicked. The button will say “Click”.
Unit tests for the display component are pretty easy. We only need to test that the button gets rendered (we don’t care about what the label says, it may say different things in different languages, depending on user locale settings). We do want to make sure that the correct number of clicks gets displayed. Let’s write two tests: one for the button display and one for the number of clicks to be rendered correctly.
We will create a component called <ClickCounter>
, and that component will have a prop for the click count, called clicks
. To use it, simply render the component and set the clicks
prop to the number of clicks you want it to display.
Let’s look at a pair of unit tests that could ensure we are pulling the click count from props. Let’s create a new file, click-counter/click-counter.test.ts
:
import { render, fireEvent, screen } from '@testing-library/vue'
import ClickCounter from './click-counter.vue';
describe('ClickCounter component', async () => {
render(ClickCounter, { props: count: 2});
test('renders the click button', async () => {
screen.getByTestId('click-button');
});
test('renders the correct number of clicks', async () => {
screen.getByText('Times clicked: 2');
});
});
With the tests written, it’s time to create our ClickCounter
component:
<template>
<div>
<p>Times clicked: {{ count }}</p>
<button data-testid="click-button">Click</button>
</div>
</template>
<script setup>
defineProps<{
count: number;
}>();
</script>
You will notice the tests are passing. Now we need to implement the state logic and hook up the event handler.
Unit Testing Stateful Components
The approach I will show you is overkill for a click counter, but most apps are far more complex than a click counter. The state is often shared between components or saved to a database. The popular approach is to start with a local component state and then lift it to a parent component or global app state on an as-needed basis.
As we are using Vue.js in our example, we will share the state using the Composition API. First, let’s create a new test file for the composable and place it in the same folder. We are calling this one click-counter/composables/use-click-counter.test.ts
:
import { useClickCounter } from './click-counter/composables/use-click-counter';
describe('useClickCounter composable', async () => {
test('increases the counter on call', () => {
const { counter, increase } = useClickCounter();
expect(counter.value).toBe(0);
increase();
expect(counter.value).toBe(1);
});
});
We start with an assertion to ensure that the counter
variable will have a valid initial state. Then, we call the increment()
method and assert the counter
variable again to check the new value.
Now let’s make the test pass:
export function useClickCounter() {
const counter = ref(0);
function increase() {
counter.value += 1;
}
return { counter, increase }
}
Just one more step: connecting our behaviour to our component. We can do that with a container component. We will call that click-counter-container.vue
and place it with the other files. It should look like this:
<template>
<ClickCounter :count="counter" @onClick="increase" />
</template>
<script setup>
import ClickCounter from './click-counter/click-counter.vue';
import { useClickCounter } from './click-counter/composables/use-click-counter';
const { counter, increase } = useClickCounter();
</script>
That’s it. This component’s only job is to connect our state and pass it in as props to our unit-tested pure component. To test it, load the app in your browser and click the ‘click’ button.
We will now hook up the increase
function in the ClickCounter
component by emitting a click event to the parent component:
<template>
<div>
<p>Times clicked: {{ count }}</p>
<button data-testid="click-button" @click="onClick">Click</button>
</div>
</template>
<script setup>
defineProps<{
count: number;
}>();
defineEmits(['onClick']);
</script>
And all the unit tests still pass.
What about tests for the container component? We don’t have to unit test container components. Instead, we can use end-to-end tests which simulate user interactions with the actual UI.
We need both kinds of tests (unit and end-to-end) in our applications, and writing unit tests for container components (which are mostly connective components like the one that wires up our composable function above) would be too redundant with end-to-end tests and not particularly easy to unit test properly. Often, you would have to mock various container component dependencies to get them to work.
Meanwhile, we have unit-tested all the important units that don’t depend on side effects: we tested that the expected data gets rendered and that the state is managed correctly. We should also load the component in the browser and see that the button works and the UI responds.
Developers can effectively test their UI components by adopting these guidelines, leading to more robust and maintainable applications.
In Plain English 🚀
Thank you for being a part of the In Plain English community! Before you go:
- Be sure to clap and follow the writer ️👏️️
- Follow us: X | LinkedIn | YouTube | Discord | Newsletter
- Visit our other platforms: Stackademic | CoFeed | Venture
- More content at PlainEnglish.io