•
Easier Vue Tests
Reading my colleague Markus Eliasson’s post on test-data builders brought back a memory from one of my own projects. I was working on a Vue app with a team that included a mix of developers: some new to the company, others new to the project, and many without much experience writing tests.
At that point, I had already been practicing TDD for a while and had come to appreciate its benefits—fewer regressions, better-structured code, and more confidence in every change. This particular project was struggling with a steady stream of small bugs, the kind that could have been avoided with even a basic suite of tests.
I wasn’t trying to convert anyone or push a specific process. I simply suggested we try writing more tests and see how it worked for us.
Still, I recognized a pattern. Writing tests often felt like a burden, especially for those unfamiliar with the codebase or testing tools. And it wasn’t just “junior” developers! Even experienced developers would sometimes avoid it. Some found the process tedious. Others weren’t sure where to start. Some just didn’t want to ask for help. The reasons varied, but the outcome was familiar: the tests didn’t get written.
That experience reinforced something I believe strongly: if we want testing to be a natural part of development, we need to lower the barrier. It should be easy to get started, easy to understand, and ideally, something you don’t dread doing.
Tests aren’t just a theoretical “best practice”. They catch bugs before users do, help you refactor safely, and act as living documentation for how your code behaves. Whether you’re working on UI or backend logic, testing helps you think more clearly about what you’re building and how it will be used.
Over time, testing shapes your thinking. You stop relying on assumptions and start asking better questions: What happens when this fails? What input is unexpected? What would the user try here?
Write Tests You Want to Read
Most of the boilerplate in Vue tests comes from setting up props, mocks, stores, plugins, and providers. This is fine once or twice. But across dozens of tests, it becomes tedious and error-prone. Worse, it makes tests hard to skim — the setup buries the intent.
When something feels hard or clunky, I usually start by imagining how I wish it worked. I ignore the implementation details at first — they’re not important yet. What matters is designing an API that feels intuitive and pleasant to use. I often sketch out the shape of it, show it to teammates for feedback, and only then start thinking about how to implement it.
Let’s look at a small but common case: a Vue 2 component that takes a prop and uses the $t translation function to render some text. It’s a simple pattern, but even here, test setup can start to get repetitive.
<template>
<span>{{ $t(title) }}</span>
</template>
<script lang="ts">
// ... omitted for brevity ...
</script>
Here’s what a typical test might look like:
const wrapper = mount(MyComponent, {
propsData: {
title: 'greeting.title',
},
mocks: {
$t: (key) => `translated:${key}`, // simple translation mock
},
});
expect(wrapper.text()).toBe("translated:greeting.title")
It works, but there’s a lot of noise. If you’re familiar with Vue Test Utils, you can scan it and follow along. But if you’re not, or if you’re reading through a large test file, this kind of repetition adds up quickly. It also makes the test brittle! If the mocks change slightly, you need to update every test manually.
In many of these cases, I find myself reaching for the builder pattern — it’s a great way to pull all the noisy setup into a clean, reusable utility. For this project, I wanted writing tests to feel closer to using a small, focused DSL:
const wrapper = WrapperBuilder
.forComponent(MyComponent)
.withProp("title", "greeting.title")
.mount()
expect(wrapper.text()).toBe("translated:greeting.title")
Using just my imagination, I can produce a simple API that’s easy to read and understand:
import { mount, Wrapper } from '@vue/test-utils'
import Vue from 'vue'
import type { ComponentOptions } from 'vue'
export class WrapperBuilder {
private component: ComponentOptions<Vue>
private propsData: Record<string, unknown> = {}
static forComponent(component: ComponentOptions<Vue>) {
const builder = new WrapperBuilder()
builder.component = component
return builder
}
public withProp(key: string, value: unknown) {
this.propsData[key] = value
return this
}
public mount(): Wrapper<Vue> {
const localVue = createLocalVue()
return mount(this.component, {
localVue,
propsData: this.propsData,
})
}
}
That base implementation works good. We can expand it further to allow others to add custom translation mocks:
it("uses a custom translation mock", () => {
const wrapper = WrapperBuilder
.forComponent(MyComponent)
.withProp("title", "greeting.title")
.withI18n((key: string) => `custom:${key}`)
.mount()
expect(wrapper.text()).toBe("custom:greeting.title")
})
All that is needed is to extend our builder with withI18n, which sets up the $t mock:
class WrapperBuilder {
private mocks: Record<string, any> = {}
// ... other methods ...
public withI18n(mockFn = (key: string) => string) {
this.mocks.$t = mockFn
return this
}
public mount(): Wrapper<Vue> {
const localVue = createLocalVue()
return mount(this.component, {
// ... other options ...
mocks: this.mocks,
})
}
}
This pattern can be extended further. For example, we could add methods for setting up stores, Vue Router, or any other common dependencies. The key is to keep the API fluent and focused on the intent of the test.
For dependencies, the added benefit is that we can centralize changes to mocks and setup in one place. If the way we mock translations changes, we only need to update the withI18n method, rather than every test.