Skip to content
Back to skills

Configure Test Stack

ASecurity

Use when setting up tests for a frontend project - Vitest (Node unit and integration, real-browser UI via Playwright), Storybook stories as tests, Playwright e2e and MSW, under tests/ or colocated per the stack profile.

  • 5 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 10, 2026
developmenttypescriptgobashreactvuenodetestingapifrontend

Works with

  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 3 files and shows the line behind each finding

Scanned October 10, 2026

npx -y skills add VelimirMueller/lab-claude-skills --skill configure-test-stack --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Configure Test Stack?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Configure Test Stack
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/velimirmueller-configure-test-stack-lab-claude-skills/badge)](https://www.skillsdirectory.com/skills/velimirmueller-configure-test-stack-lab-claude-skills)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: configure-test-stack
description: Use when setting up tests for a frontend project - Vitest (Node unit and integration, real-browser UI via Playwright), Storybook stories as tests, Playwright e2e and MSW, under tests/ or colocated per the stack profile.
---

# Configure Test Stack

## 1. Audit current state

```bash
grep -E '"(vitest|@playwright/test|msw|@storybook/addon-vitest)"' package.json 2>/dev/null
ls vitest.config.* playwright.config.* tests/ .env.test .claude/stack-profile.md 2>/dev/null
find src -name "*.test.*" -o -name "*.spec.*" 2>/dev/null   # co-located tests
```

Read `.claude/stack-profile.md` if present: `tests.layout` is `tests-dir` (default) or `colocated`; `frontend.framework` picks the render adapter; `package_manager` replaces `pnpm`. With `colocated`, or an established colocated suite, use the globs in `test-layout.md` ("Colocated variant") in step 6, create only `tests/{e2e,mocks,setup}` in step 5, and skip step 9 (no migration).

**Prerequisites:** the `@/` alias (`configure-typescript`) and `validate-env` (`.env.test`, step 5). MSW mocks the `fetcher` seam from `set-up-state-management`.

## 2. Decide what to do

- Nothing → full setup (steps 3–10).
- Partial → add only the missing layer (Vitest present but no browser `ui` project, or no e2e).
- Co-located tests found and the profile says `tests-dir` → migrate (step 9).

## 3. Detect framework

React → `vitest-browser-react`. Vue → `vitest-browser-vue`. Vitest, Playwright and MSW are framework-agnostic.

## 4. Install

```bash
pnpm add -D vitest @vitest/browser-playwright @vitest/coverage-v8 \
  vitest-browser-react @playwright/test msw      # Vue: vitest-browser-vue instead
pnpm exec playwright install chromium
```

No `@testing-library/jest-dom`: Vitest's `expect.element(...)` already has `toBeVisible`, `toBeEnabled`, … (checked: UI tests pass in both frameworks without it). With Storybook installed: `pnpm exec storybook add @storybook/addon-vitest` (not `dlx`: it must run the installed Storybook version).

## 5. Create the tree, the env file and the test tsconfig

```
tests/
├── unit/          # pure logic — Node
├── integration/   # modules + data layer (a hook/query against MSW) — Node
├── ui/            # components — real browser via Playwright
├── e2e/           # full flows — Playwright
├── mocks/         # handlers.ts · node.ts · browser.ts   (no setup code here)
├── public/        # mockServiceWorker.js (generated, served to the ui project only)
├── setup/         # msw-node.ts · ui-msw.ts
└── tsconfig.json
```

`.env.test` (Vitest runs in Vite's `test` mode and loads it; `env.ts` throws without these):
```
VITE_API_URL=http://api.test
```

`tests/tsconfig.json`: test files are outside `src/`, so no tsconfig type-checks them until this exists. It inherits the app config, including `paths`. Add it to the root references and put the config files in `tsconfig.node.json`'s `include`:
```json
{
  "extends": "../tsconfig.app.json",
  "compilerOptions": { "tsBuildInfoFile": "../node_modules/.tmp/tsconfig.tests.tsbuildinfo" },
  "include": ["./**/*.ts", "./**/*.tsx"]
}
```
```json
// tsconfig.json  → "references": [ …, { "path": "./tests" } ]
// tsconfig.node.json → "include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
```
Vue: same, `vue-tsc -b` checks it through `pnpm typecheck`.

## 6. Configure Vitest (one config, several `projects`)

```ts
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import viteConfig from './vite.config.ts';

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      coverage: { provider: 'v8', include: ['src/**'] },
      projects: [
        {
          test: {
            name: 'unit',
            environment: 'node',
            include: ['tests/unit/**/*.{test,spec}.ts'],
            setupFiles: ['tests/setup/msw-node.ts'],
          },
        },
        {
          test: {
            name: 'integration',
            environment: 'node',
            include: ['tests/integration/**/*.{test,spec}.ts'],
            setupFiles: ['tests/setup/msw-node.ts'],
          },
        },
        {
          publicDir: 'tests/public',
          test: {
            name: 'ui',
            include: ['tests/ui/**/*.{test,spec}.{ts,tsx}'],
            setupFiles: ['tests/setup/ui-msw.ts'],
            browser: {
              enabled: true,
              provider: playwright(),
              headless: true,
              instances: [{ browser: 'chromium' }],
            },
          },
        },
      ],
    },
  }),
);
```

- Vitest 5: inline `projects` inherit the root config (alias, plugins) because `extends: true` is the default; on Vitest 3/4 add `extends: true` to each project. The import ends in `.ts`: Vite 8 warns about extension-less config imports.
- `ui` runs in **real Chromium**, not jsdom (`test-stack.md`). Its `include` covers `.ts` and `.tsx`: Vue tests are `.ts`.
- `publicDir: 'tests/public'` serves MSW's worker to the `ui` project only, so it never lands in the production `public/` folder.
- `coverage.include: ['src/**']` keeps test helpers out of the report and shows untested source files at 0%.
- Cold cache: browser mode may print "Vite unexpectedly reloaded a test" and fail the first run after a new dependency is discovered; rerun (it passed on the second run here). Persistent flake: add the named dependency to the project's `optimizeDeps.include`.

## 7. Playwright (e2e)

```ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry' },
  webServer: {
    command: 'pnpm dev --port 5173 --strictPort',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
```
`webServer` starts the dev server and reuses a running one locally; `--strictPort` fails loudly instead of moving to 5174 and missing `baseURL`. Playwright reads `paths` from the tsconfig itself. One smoke spec keeps the setup honest:
```ts
// tests/e2e/smoke.spec.ts
import { expect, test } from '@playwright/test';

test('the app boots without console errors', async ({ page }) => {
  const errors: string[] = [];
  page.on('pageerror', (e) => errors.push(e.message));
  await page.goto('/');
  await expect(page.locator('#root, #app')).not.toBeEmpty(); // React mounts to #root, Vue to #app
  expect(errors).toEqual([]);
});
```

## 8. MSW: mock the network, not modules

```ts
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('*/todos', () => HttpResponse.json([{ id: '1', text: 'Demo', done: false }])),
];
```
`*/todos` matches whatever base `VITE_API_URL` has; a relative `/todos` handler never matches in Node (`fetch` rejects relative URLs) and `http.get('/todos')` ignores `http://api.test/todos` (both reproduced).
```ts
// tests/mocks/node.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
```
```ts
// tests/setup/msw-node.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from '../mocks/node';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
Browser (`ui` project), same handlers:
```ts
// tests/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);
```
```ts
// tests/setup/ui-msw.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { worker } from '../mocks/browser';

beforeAll(() => worker.start({ onUnhandledRequest: 'error' }));
afterEach(() => worker.resetHandlers());
afterAll(() => worker.stop());
```
```bash
pnpm exec msw init tests/public --no-save     # writes tests/public/mockServiceWorker.js; without it the ui project fails to start the worker
```
Tests import the worker from `tests/mocks/browser.ts`, **never** from the setup file: importing `ui-msw.ts` creates a second worker whose handlers the real, started worker never sees (a `ws` test found no handler until this was fixed). Biome lints the generated worker: in `biome.json` use `"files": { "includes": ["**", "!**/mockServiceWorker.js"] }`.

## 9. Storybook stories as tests; migrate co-located tests

With Storybook installed, the addon (step 4) registers its own Vitest project; do not copy it into `vitest.config.ts`. Stories stay beside their components.

If the profile says `tests-dir` and `src/**/*.test.*` exist, move them to `tests/ui/` (component tests) or `tests/unit/` (pure logic) and switch imports to `@/`.

## 10. Verify

```json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest run --project ui",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test"
  }
}
```
One passing example per project proves the wiring (all three pass):
```tsx
// tests/ui/Button.test.tsx  (React; Vue: vitest-browser-vue, same shape)
import { render } from 'vitest-browser-react';
import { expect, test, vi } from 'vitest';
import { Button } from '@/components/atoms/Button';

test('calls onClick when pressed', async () => {
  const onClick = vi.fn();
  const screen = await render(<Button onClick={onClick}>Save</Button>);
  await screen.getByRole('button', { name: 'Save' }).click();
  expect(onClick).toHaveBeenCalledOnce();
});
```
```ts
// tests/integration/fetcher.test.ts
import { expect, test } from 'vitest';
import { fetcher } from '@/libs/fetcher';
import type { Todo } from '@/libs/queryKeys';

test('fetcher hits msw', async () => {
  const todos = await fetcher<Todo[]>('/todos?status=all');
  expect(todos[0]?.text).toBe('Demo');
});
```
```bash
pnpm typecheck && pnpm test && pnpm test:e2e
```
`render` of both adapters is async: without `await`, `screen.getByRole` is not a function (reproduced). CI runs `pnpm test` and `pnpm test:e2e` as separate steps; e2e is slower and starts a server.

## References
- ./test-layout.md — folders, what each type holds, the colocated variant, the trophy.
- ./test-stack.md — real browser over jsdom, stories as tests, MSW at the network, projects, e2e, coverage; deviations.
- ../_shared/stack-versions.md — Vitest, Playwright, MSW, Storybook lines.
- ../_shared/conventions.md — `@/` alias, the `typecheck` rule.

Files in this skill

  • SKILL.md10.4 KB
  • test-layout.md3.6 KB
  • test-stack.md4.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.