Next Talk: Clean Code is Sexy Again: Making Your Vue Project AI Ready

May 22, 2026 — MadVue, Madrid

Conference
Skip to content

Writing Good Tests for Vue Applications

A principle-driven guide to testing Vue apps. Argues for an Application-Test-heavy strategy with Vitest, Playwright, Testing Library and MSW, glued together by a driver pattern that decouples tests from any specific framework.

book

by Markus Oberlehner

Overview#

The book lays out an opinionated approach to testing Vue applications, organised around two big ideas: what kinds of tests to write and in what proportion, and how to structure tests so refactors don’t break them. The recommended stack is Vitest + Playwright + Testing Library + Mock Service Worker (MSW).

The four types of automated tests#

The book defines four distinct categories, each with its own purpose:

  1. E2E System Tests: exercise the full system, including real backend services and databases. Highest confidence at the highest cost.
  2. Application Tests: exercise the entire Vue SPA from the user’s perspective, with every external HTTP call mocked.
  3. Component Tests: exercise a single Vue component in isolation. Fast and narrow.
  4. Unit Tests: exercise extracted utility functions, classes, or modules. Not composables.

The book’s headline rule: every test that can be automated should be automated. Manual testing covers exploratory work and usability checks. It shouldn’t block deployments.

How the book frames the testing pyramid#

The classical pyramid for a Vue project looks like this:

       ▲  E2E (Cypress / Playwright)        few, slow, full system
      ▲▲  Integration                       some
    ▲▲▲▲  Unit (Jest, vue-test-utils)       lots, fast, narrow

The book rejects this shape. It treats both Pyramid and Trophy as starting points rather than prescriptions, and argues that feedback speed is what matters. The recommended distribution sits closer to Kent C. Dodds’ Testing Trophy, but skewed further toward the user-perspective middle:

        ▲  manual exploratory   (continuous, non-blocking)
       ▲▲  E2E smoke tests      (1–5 critical paths)
     ▲▲▲▲  Application Tests    ← the bulk lives here
    ▲▲▲▲▲  Component + Unit     (only when they earn their place)

Three decisions follow from this:

The book is explicit: don’t test composables, stores, or plugins in isolation. The components or applications that use them cover them.

What the book means by “Application Test”#

Application Test is the book’s most distinctive vocabulary, because the wider community overloads Integration Test and E2E Test with too many meanings. The book’s definition is precise:

An Application Test exercises the entire Vue SPA inside a single repository, from the user’s point of view, while mocking every HTTP call to services outside the SPA.

Two properties separate this from a typical “integration test” or a Cypress E2E:

Visually, the scope looks like this:

What an "Application Test" covers System Application (Git repo) Vue SPA router pages / components stores data-fetching layer REAL HTTP — MOCKED Service A → DB Service B → DB Stripe findByRole('button', …).click() user Application Test scope = the inner box. Everything outside the dashed line is mocked.

A concrete Application Test using Playwright reads like a user story, with page.route() standing in for the backend:

it('should be possible to buy a bike', async ({ page }) => {
  await page.route('/api/product/list', route =>
    route.fulfill({ json: [bestBikeEver, notSoGoodBike] }),
  );
  await page.route('/api/checkout', route =>
    route.fulfill({ json: { success: true } }),
  );

  await page.goto('https://my.bikestore');
  await page.findByText('Best Bike Ever').click();
  await page.findByRole('button', { name: 'Add to cart' }).click();
  await page.findByRole('button', { name: 'Checkout' }).click();

  expect(await page.findByText('Thank you for your order!').isVisible())
    .toBe(true);
});

What’s missing here: no mount(SomeComponent), no stubbing of child components, no assertions on Pinia actions. The test boots the real app, drives it through the UI, and asserts on visible outcomes. Compare that to a Component Test, which has a much narrower brief:

// Component Test — only PostForm, in isolation
it('should warn the user when they enter invalid data', async () => {
  render(PostForm);
  await userEvent.type(await screen.findByLabel('Title'), '');
  await userEvent.click(screen.getByRole('button', { name: 'Save' }));
  expect(await screen.findByText('Please enter a valid title')).toBeInTheDocument();
});

The Application Test can fail because of routing, store wiring, a missing prop on a child component, a broken interceptor, or a real bug in the checkout flow. The Component Test can only fail because PostForm itself is broken. That breadth is why the book puts the bulk of testing effort at the Application layer. One test there replaces a small pile of Component Tests plus their mocking ceremony.

Practical implications for a Vue project:

The book’s rule of thumb for placement: if you can phrase a test as a user goal (“should be possible to buy a bike”), it is an Application Test. If it reads only as a component contract (“should emit submit when the button is clicked”), it is a Component Test. Otherwise it is a Unit Test on an extracted utility.

How the book structures a test setup#

The recommended file layout:

project/
├─ src/
│  └─ components/
│     └─ HelloWorld.spec.ts   ← Component Tests live next to source
├─ test/
│  ├─ utils.ts                ← re-exports + custom setup() helper
│  ├─ driver.ts               ← framework-agnostic Driver interface
│  ├─ drivers/
│  │  ├─ vitest/setup.ts      ← Vitest implementation of the Driver
│  │  └─ playwright/          ← Playwright implementation
│  ├─ dsl/                    ← domain helpers (shop, products, …)
│  └─ specs/                  ← Playwright application tests
├─ vitest.config.ts
└─ playwright.config.ts

The Vitest config merges the existing Vite config (so build and test stay in sync) and points at a setup file:

// vitest.config.ts
export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      clearMocks: true,
      environment: 'jsdom',
      setupFiles: ['./test/drivers/vitest/setup.ts'],
      coverage: { provider: 'v8', all: true, reporter: ['html', 'text'] },
    },
  }),
);

The custom test/utils.ts re-exports everything tests need from a single module. That’s the first step toward decoupling from the framework:

// test/utils.ts
import userEvent from '@testing-library/user-event';
import { render, type RenderOptions } from '@testing-library/vue';

export { screen } from '@testing-library/vue';
export { expect, it } from 'vitest';

export const setup = (component, { renderOptions } = {}) => ({
  user: userEvent.setup(),
  ...render(component, renderOptions),
});

A Component Test then looks like this. Nothing imports from vitest or @testing-library:

// src/components/HelloWorld.spec.ts
import { expect, it, screen, setup } from '../../test/utils';
import HelloWorld from './HelloWorld.vue';

it('should render the greeting', async () => {
  setup(HelloWorld, { renderOptions: { props: { msg: 'Hello!' } } });
  expect(await screen.findByText('Hello!')).toBeVisible();
});

The three decoupling principles#

The book illustrates each principle with a “before” that resembles a typical Vue test, and an “after” that pushes the coupling into one central file.

1. Decouple from the test framework via a driver interface. The interface defines what tests can do (click, type, findByRole, goTo, …) without committing to Playwright or Vitest:

// test/driver.ts
export type Interactions = {
  click: () => Promise<void>;
  type: (text: string) => Promise<void>;
};

export type Assertions = {
  shouldBeVisible: () => Promise<void>;
};

export type Driver = {
  findByRole: (role: 'button' | 'link', opts: { name: string })
    => Interactions & Assertions;
  findByText: (text: string) => Assertions;
  goTo: (path: string) => Promise<void>;
};

Write one driver implementation per framework. The same Application Test then runs under Playwright when you need real-browser confidence, and under Vitest+jsdom when you need sub-second feedback during a refactor.

2. Decouple from implementation details. A Component Test that leaks three different couplings in five lines:

// ❌ couples to Vue Test Utils, axios, AND HTTP
vi.mock('axios');
const wrapper = mount(ProductList);
axios.get.mockResolvedValue({ data: fakeProducts });
expect(axios.get).toHaveBeenCalledWith('/api/products');
expect(wrapper.text()).toContain('Bread');

The same test, decoupled, hides the framework behind test/utils and the data layer behind a hasProducts() helper that owns the mocking strategy:

// ✅ no direct mention of axios, HTTP, or vue-test-utils
import { render, screen, it, expect } from '../test/utils';
import { hasProducts } from '../test/dsl/products';

it('should render a list of products', async () => {
  await hasProducts(['Bread', 'Butter']);
  await render(ProductList);
  expect(await screen.findByText('Bread')).toBeInDocument();
});

Swap REST for GraphQL and only hasProducts changes. Swap Vitest for Jest and only test/utils.ts changes. The book is firm that CSS selectors and wrapper.vm access both count as coupling to internals. Replace them with role-based queries and user-style interactions.

3. Decouple from the UI with a small DSL that mirrors how a domain expert would describe the feature. A checkout test reads like the original user story:

it('should show the correct total after adding items', async ({ driver }) => {
  const shop = makeShop({ driver });
  await shop.visit();
  await shop.addItemsToCart();
  await shop.goToCheckout();
  await shop.expectTotalSum(199.99);
});

Behind the DSL, shop.addItemsToCart() calls the driver’s findByRole('button', { name: 'Add to cart' }).click(). If the UI later changes from a button to a swipe gesture or a voice command, only the DSL implementation changes. The test itself stays identical. The book notes this is overkill for Component Tests, where simplicity beats abstraction.

On code coverage#

The book reads coverage as a diagnostic. Threshold-driven test writing produces high coverage but ties tests to implementation details, which makes refactoring harder. Low coverage signals untested critical paths. Monitor the trend, investigate uncovered lines case-by-case, and don’t gate merges on a percentage. Consistent TDD makes high coverage a by-product.

Further reading from Markus#

The book consolidates ideas Markus Oberlehner has been sharpening on his blog for years. These posts map onto the book’s themes:

Markus also publishes free chapter excerpts and follow-up essays on the Good Vue Tests Substack.

Talks and videos#

Most of Markus Oberlehner’s testing talks live on conference channels rather than a personal channel. The recordings below cover the same arguments as the book. Most include live-coded walkthroughs:

Highlights & Notes

Testing can only prove the presence of bugs, not their absence.
Every test that can be automated should be automated.
What ultimately matters is finding a way to write stable and maintainable tests that provide feedback as quickly as possible on whether our code meets our requirements.
Decoupling from the test framework, from implementation details, and from the UI.