Skip to content

Commit ee3e9ce

Browse files
feat: vitest use client and server side testing (#311)
* feat: `vitest` use client and server side testing for `kit` * improvements * Update packages/addons/vitest-addon/index.ts Co-authored-by: Ben McCann <[email protected]> * enhance * fix file extension * add new code * small fixes * ditch `vitest.workspace.js` and use `vite.config.js` instead for workspaces * remove if --------- Co-authored-by: Ben McCann <[email protected]>
1 parent 746bdd4 commit ee3e9ce

File tree

3 files changed

+110
-51
lines changed

3 files changed

+110
-51
lines changed

.changeset/thirty-ducks-buy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'sv': patch
3+
---
4+
5+
feat: `vitest` use client and server side testing for `kit`

packages/addons/_tests/vitest/test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execSync } from 'node:child_process';
12
import { expect } from '@playwright/test';
23
import { setupTest } from '../_setup/suite.ts';
34
import vitest from '../../vitest-addon/index.ts';
@@ -7,7 +8,10 @@ const { test, variants, prepareServer } = setupTest({ vitest });
78
test.concurrent.for(variants)('core - %s', async (variant, { page, ...ctx }) => {
89
const cwd = await ctx.run(variant, { vitest: {} });
910

10-
const { close } = await prepareServer({ cwd, page });
11+
const { close } = await prepareServer({ cwd, page }, () => {
12+
execSync('npm run test', { cwd, stdio: 'pipe' });
13+
});
14+
1115
// kill server process when we're done
1216
ctx.onTestFinished(async () => await close());
1317

packages/addons/vitest-addon/index.ts

Lines changed: 100 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { dedent, defineAddon, log } from '@sveltejs/cli-core';
2-
import { common, exports, imports, object } from '@sveltejs/cli-core/js';
2+
import { array, common, exports, functions, imports, object } from '@sveltejs/cli-core/js';
33
import { parseJson, parseScript } from '@sveltejs/cli-core/parsers';
44

55
export default defineAddon({
66
id: 'vitest',
77
shortDescription: 'unit testing',
88
homepage: 'https://vitest.dev',
99
options: {},
10-
run: ({ sv, typescript }) => {
10+
run: ({ sv, typescript, kit }) => {
1111
const ext = typescript ? 'ts' : 'js';
1212

1313
sv.devDependency('vitest', '^3.0.0');
14+
sv.devDependency('@testing-library/svelte', '^5.2.4');
15+
sv.devDependency('@testing-library/jest-dom', '^6.6.3');
16+
sv.devDependency('jsdom', '^25.0.1');
1417

1518
sv.file('package.json', (content) => {
1619
const { data, generateCode } = parseJson(content);
@@ -39,62 +42,109 @@ export default defineAddon({
3942
`;
4043
});
4144

45+
if (kit) {
46+
sv.file(`${kit.routesDirectory}/page.svelte.test.${ext}`, (content) => {
47+
if (content) return content;
48+
49+
return dedent`
50+
import { describe, test, expect } from 'vitest';
51+
import '@testing-library/jest-dom/vitest';
52+
import { render, screen } from '@testing-library/svelte';
53+
import Page from './+page.svelte';
54+
55+
describe('/+page.svelte', () => {
56+
test('should render h1', () => {
57+
render(Page);
58+
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
59+
});
60+
});
61+
`;
62+
});
63+
} else {
64+
sv.file(`src/App.svelte.test.${ext}`, (content) => {
65+
if (content) return content;
66+
67+
return dedent`
68+
import { describe, test, expect } from 'vitest';
69+
import '@testing-library/jest-dom/vitest';
70+
import { render, screen } from '@testing-library/svelte';
71+
import App from './App.svelte';
72+
73+
describe('App.svelte', () => {
74+
test('should render h1', () => {
75+
render(App);
76+
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
77+
});
78+
});
79+
`;
80+
});
81+
}
82+
83+
sv.file(`vitest-setup-client.${ext}`, (content) => {
84+
if (content) return content;
85+
86+
return dedent`
87+
import '@testing-library/jest-dom/vitest';
88+
import { vi } from 'vitest';
89+
90+
// required for svelte5 + jsdom as jsdom does not support matchMedia
91+
Object.defineProperty(window, 'matchMedia', {
92+
writable: true,
93+
enumerable: true,
94+
value: vi.fn().mockImplementation(query => ({
95+
matches: false,
96+
media: query,
97+
onchange: null,
98+
addEventListener: vi.fn(),
99+
removeEventListener: vi.fn(),
100+
dispatchEvent: vi.fn(),
101+
})),
102+
})
103+
104+
// add more mocks here if you need them
105+
`;
106+
});
107+
42108
sv.file(`vite.config.${ext}`, (content) => {
43109
const { ast, generateCode } = parseScript(content);
44110

45-
// find `defineConfig` import declaration for "vite"
46-
const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration');
47-
const defineConfigImportDecl = importDecls.find(
48-
(importDecl) =>
49-
(importDecl.source.value === 'vite' || importDecl.source.value === 'vitest/config') &&
50-
importDecl.importKind === 'value' &&
51-
importDecl.specifiers?.some(
52-
(specifier) =>
53-
specifier.type === 'ImportSpecifier' && specifier.imported.name === 'defineConfig'
54-
)
55-
);
56-
57-
// we'll need to replace the "vite" import for a "vitest/config" import.
58-
// if `defineConfig` is the only specifier in that "vite" import, remove the entire import declaration
59-
if (defineConfigImportDecl?.specifiers?.length === 1) {
60-
const idxToRemove = ast.body.indexOf(defineConfigImportDecl);
61-
ast.body.splice(idxToRemove, 1);
62-
} else {
63-
// otherwise, just remove the `defineConfig` specifier
64-
const idxToRemove = defineConfigImportDecl?.specifiers?.findIndex(
65-
(s) => s.type === 'ImportSpecifier' && s.imported.name === 'defineConfig'
66-
);
67-
if (idxToRemove) defineConfigImportDecl?.specifiers?.splice(idxToRemove, 1);
68-
}
69-
70-
const config = common.expressionFromString('defineConfig({})');
71-
const defaultExport = exports.defaultExport(ast, config);
111+
imports.addNamed(ast, '@testing-library/svelte/vite', { svelteTesting: 'svelteTesting' });
72112

73-
const test = object.create({
74-
include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']")
113+
const clientObjectExpression = object.create({
114+
extends: common.createLiteral(`./vite.config.${ext}`),
115+
plugins: common.expressionFromString('[svelteTesting()]'),
116+
test: object.create({
117+
name: common.createLiteral('client'),
118+
environment: common.createLiteral('jsdom'),
119+
clearMocks: common.expressionFromString('true'),
120+
include: common.expressionFromString("['src/**/*.svelte.{test,spec}.{js,ts}']"),
121+
exclude: common.expressionFromString("['src/lib/server/**']"),
122+
setupFiles: common.expressionFromString(`['./vitest-setup-client.${ext}']`)
123+
})
124+
});
125+
const serverObjectExpression = object.create({
126+
extends: common.createLiteral(`./vite.config.${ext}`),
127+
test: object.create({
128+
name: common.createLiteral('server'),
129+
environment: common.createLiteral('node'),
130+
include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']"),
131+
exclude: common.expressionFromString("['src/**/*.svelte.{test,spec}.{js,ts}']")
132+
})
75133
});
76134

77-
// uses the `defineConfig` helper
78-
if (
79-
defaultExport.value.type === 'CallExpression' &&
80-
defaultExport.value.arguments[0]?.type === 'ObjectExpression'
81-
) {
82-
// if the previous `defineConfig` was aliased, reuse the alias for the "vitest/config" import
83-
const importSpecifier = defineConfigImportDecl?.specifiers?.find(
84-
(sp) => sp.type === 'ImportSpecifier' && sp.imported.name === 'defineConfig'
85-
);
86-
const defineConfigAlias = (importSpecifier?.local?.name ?? 'defineConfig') as string;
87-
imports.addNamed(ast, 'vitest/config', { defineConfig: defineConfigAlias });
88-
89-
object.properties(defaultExport.value.arguments[0], { test });
90-
} else if (defaultExport.value.type === 'ObjectExpression') {
91-
// if the config is just an object expression, just add the property
92-
object.properties(defaultExport.value, { test });
93-
} else {
94-
// unexpected config shape
95-
log.warn('Unexpected vite config for vitest add-on. Could not update.');
135+
const defineConfigFallback = functions.call('defineConfig', []);
136+
const { value: defineWorkspaceCall } = exports.defaultExport(ast, defineConfigFallback);
137+
if (defineWorkspaceCall.type !== 'CallExpression') {
138+
log.warn('Unexpected vite config. Could not update.');
96139
}
97140

141+
const vitestConfig = functions.argumentByIndex(defineWorkspaceCall, 0, object.createEmpty());
142+
const testObject = object.property(vitestConfig, 'test', object.createEmpty());
143+
144+
const workspaceArray = object.property(testObject, 'workspace', array.createEmpty());
145+
array.push(workspaceArray, clientObjectExpression);
146+
array.push(workspaceArray, serverObjectExpression);
147+
98148
return generateCode();
99149
});
100150
}

0 commit comments

Comments
 (0)