Skip to content

Commit 0e3a21c

Browse files
committed
feat(@angular/build): add experimental vitest unit-testing support
When using the application build system via the `@angular/build` package (default for new projects starting in v20), a new experimental unit-test builder is available that initially uses vitest. This experimental system is intended to provide support for investigation of future unit testing efforts within the Angular CLI. As this is experimental, no SemVer guarantees are provided, the API and behavior may change, and there may be unexpected behavior. Available test runners may be added or removed as well. The setup is somewhat different than the previous unit-testing builders. It uses a similar mechanism to that of the `dev-server` and requires a `buildTarget` option. This allows the code building aspects of the unit- testing process to leverage pre-existing option values that are already defined for development. If differing option values are required for testing, an additional build target configuration specifically for testing can be used. The current vitest support has multiple caveats including but not limited to: * No watch support * `jsdom` based testing only (`jsdom` must be installed in the project) * Custom vitest configuration is not supported An example configuration that would replace the `test` target for a project is as follows: ``` "test": { "builder": "@angular/build:unit-test", "options": { "tsConfig": "tsconfig.spec.json", "buildTarget": "::development", "runner": "vitest" } } ```
1 parent 84e7267 commit 0e3a21c

File tree

14 files changed

+1378
-4
lines changed

14 files changed

+1378
-4
lines changed

modules/testing/builder/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ts_project(
2020
# Needed at runtime by some builder tests relying on SSR being
2121
# resolvable in the test project.
2222
":node_modules/@angular/ssr",
23+
":node_modules/vitest",
2324
] + glob(["projects/**/*"]),
2425
deps = [
2526
":node_modules/@angular-devkit/architect",

modules/testing/builder/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"@angular-devkit/architect": "workspace:*",
55
"@angular/ssr": "workspace:*",
66
"@angular-devkit/build-angular": "workspace:*",
7-
"rxjs": "7.8.2"
7+
"rxjs": "7.8.2",
8+
"vitest": "3.1.1"
89
}
910
}

packages/angular/build/BUILD.bazel

+44
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ ts_json_schema(
3636
src = "src/builders/ng-packagr/schema.json",
3737
)
3838

39+
ts_json_schema(
40+
name = "unit_test_schema",
41+
src = "src/builders/unit-test/schema.json",
42+
)
43+
3944
copy_to_bin(
4045
name = "schemas",
4146
srcs = glob(["**/schema.json"]),
@@ -79,6 +84,7 @@ ts_project(
7984
"//packages/angular/build:src/builders/extract-i18n/schema.ts",
8085
"//packages/angular/build:src/builders/karma/schema.ts",
8186
"//packages/angular/build:src/builders/ng-packagr/schema.ts",
87+
"//packages/angular/build:src/builders/unit-test/schema.ts",
8288
],
8389
data = RUNTIME_ASSETS,
8490
deps = [
@@ -109,6 +115,7 @@ ts_project(
109115
":node_modules/source-map-support",
110116
":node_modules/tinyglobby",
111117
":node_modules/vite",
118+
":node_modules/vitest",
112119
":node_modules/watchpack",
113120
"//:node_modules/@angular/common",
114121
"//:node_modules/@angular/compiler",
@@ -252,6 +259,36 @@ ts_project(
252259
],
253260
)
254261

262+
ts_project(
263+
name = "unit-test_integration_test_lib",
264+
testonly = True,
265+
srcs = glob(include = ["src/builders/unit-test/tests/**/*.ts"]),
266+
deps = [
267+
":build",
268+
"//packages/angular/build/private",
269+
"//modules/testing/builder",
270+
":node_modules/@angular-devkit/architect",
271+
":node_modules/@angular-devkit/core",
272+
"//:node_modules/@types/node",
273+
274+
# unit test specific test deps
275+
":node_modules/vitest",
276+
":node_modules/jsdom",
277+
278+
# Base dependencies for the hello-world-app.
279+
"//:node_modules/@angular/common",
280+
"//:node_modules/@angular/compiler",
281+
"//:node_modules/@angular/compiler-cli",
282+
"//:node_modules/@angular/core",
283+
"//:node_modules/@angular/platform-browser",
284+
"//:node_modules/@angular/router",
285+
":node_modules/rxjs",
286+
"//:node_modules/tslib",
287+
"//:node_modules/typescript",
288+
"//:node_modules/zone.js",
289+
],
290+
)
291+
255292
jasmine_test(
256293
name = "application_integration_tests",
257294
size = "large",
@@ -281,6 +318,13 @@ jasmine_test(
281318
shard_count = 10,
282319
)
283320

321+
jasmine_test(
322+
name = "unit-test_integration_tests",
323+
size = "large",
324+
data = [":unit-test_integration_test_lib"],
325+
shard_count = 10,
326+
)
327+
284328
genrule(
285329
name = "license",
286330
srcs = ["//:LICENSE"],

packages/angular/build/builders.json

+5
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
"implementation": "./src/builders/ng-packagr/index",
2525
"schema": "./src/builders/ng-packagr/schema.json",
2626
"description": "Build a library with ng-packagr."
27+
},
28+
"unit-test": {
29+
"implementation": "./src/builders/unit-test",
30+
"schema": "./src/builders/unit-test/schema.json",
31+
"description": "[EXPERIMENTAL] Run application unit tests."
2732
}
2833
}
2934
}

packages/angular/build/package.json

+8-2
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@
5151
"devDependencies": {
5252
"@angular/ssr": "workspace:*",
5353
"@angular-devkit/core": "workspace:*",
54+
"jsdom": "26.1.0",
5455
"less": "4.3.0",
5556
"ng-packagr": "20.0.0-next.8",
5657
"postcss": "8.5.3",
57-
"rxjs": "7.8.2"
58+
"rxjs": "7.8.2",
59+
"vitest": "3.1.2"
5860
},
5961
"peerDependencies": {
6062
"@angular/core": "0.0.0-ANGULAR-FW-PEER-DEP",
@@ -71,7 +73,8 @@
7173
"postcss": "^8.4.0",
7274
"tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0",
7375
"tslib": "^2.3.0",
74-
"typescript": ">=5.8 <5.9"
76+
"typescript": ">=5.8 <5.9",
77+
"vitest": "^3.1.1"
7578
},
7679
"peerDependenciesMeta": {
7780
"@angular/core": {
@@ -106,6 +109,9 @@
106109
},
107110
"tailwindcss": {
108111
"optional": true
112+
},
113+
"vitest": {
114+
"optional": true
109115
}
110116
}
111117
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
10+
import assert from 'node:assert';
11+
import { randomUUID } from 'node:crypto';
12+
import path from 'node:path';
13+
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
14+
import { loadEsmModule } from '../../utils/load-esm';
15+
import { buildApplicationInternal } from '../application';
16+
import type {
17+
ApplicationBuilderExtensions,
18+
ApplicationBuilderInternalOptions,
19+
} from '../application/options';
20+
import { ResultKind } from '../application/results';
21+
import { OutputHashing } from '../application/schema';
22+
import { writeTestFiles } from '../karma/application_builder';
23+
import { findTests, getTestEntrypoints } from '../karma/find-tests';
24+
import { normalizeOptions } from './options';
25+
import type { Schema as UnitTestOptions } from './schema';
26+
27+
export type { UnitTestOptions };
28+
29+
/**
30+
* @experimental Direct usage of this function is considered experimental.
31+
*/
32+
export async function* execute(
33+
options: UnitTestOptions,
34+
context: BuilderContext,
35+
extensions: ApplicationBuilderExtensions = {},
36+
): AsyncIterable<BuilderOutput> {
37+
// Determine project name from builder context target
38+
const projectName = context.target?.project;
39+
if (!projectName) {
40+
context.logger.error(
41+
`The "${context.builder.builderName}" builder requires a target to be specified.`,
42+
);
43+
44+
return;
45+
}
46+
47+
context.logger.warn(
48+
`NOTE: The "${context.builder.builderName}" builder is currently EXPERIMENTAL and not ready for production use.`,
49+
);
50+
51+
const normalizedOptions = await normalizeOptions(context, projectName, options);
52+
const { projectSourceRoot, workspaceRoot, runnerName } = normalizedOptions;
53+
54+
if (runnerName !== 'vitest') {
55+
context.logger.error('Unknown test runner: ' + runnerName);
56+
57+
return;
58+
}
59+
60+
// Find test files
61+
const testFiles = await findTests(
62+
normalizedOptions.include,
63+
normalizedOptions.exclude,
64+
workspaceRoot,
65+
projectSourceRoot,
66+
);
67+
68+
if (testFiles.length === 0) {
69+
context.logger.error('No tests found.');
70+
71+
return { success: false };
72+
}
73+
74+
const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot });
75+
entryPoints.set('init-testbed', 'angular:test-bed-init');
76+
77+
const { startVitest } = await loadEsmModule<typeof import('vitest/node')>('vitest/node');
78+
79+
// Setup test file build options based on application build target options
80+
const buildTargetOptions = (await context.validateOptions(
81+
await context.getTargetOptions(normalizedOptions.buildTarget),
82+
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
83+
)) as unknown as ApplicationBuilderInternalOptions;
84+
85+
if (buildTargetOptions.polyfills?.includes('zone.js')) {
86+
buildTargetOptions.polyfills.push('zone.js/testing');
87+
}
88+
89+
const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
90+
const buildOptions: ApplicationBuilderInternalOptions = {
91+
...buildTargetOptions,
92+
watch: normalizedOptions.watch,
93+
outputPath,
94+
index: false,
95+
browser: undefined,
96+
server: undefined,
97+
localize: false,
98+
budgets: [],
99+
serviceWorker: false,
100+
appShell: false,
101+
ssr: false,
102+
prerender: false,
103+
sourceMap: { scripts: true, vendor: false, styles: false },
104+
outputHashing: OutputHashing.None,
105+
optimization: false,
106+
tsConfig: normalizedOptions.tsConfig,
107+
entryPoints,
108+
externalDependencies: ['vitest', ...(buildTargetOptions.externalDependencies ?? [])],
109+
};
110+
extensions ??= {};
111+
extensions.codePlugins ??= [];
112+
const virtualTestBedInit = createVirtualModulePlugin({
113+
namespace: 'angular:test-bed-init',
114+
loadContent: async () => {
115+
const contents: string[] = [
116+
// Initialize the Angular testing environment
117+
`import { getTestBed } from '@angular/core/testing';`,
118+
`import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';`,
119+
`getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {`,
120+
` errorOnUnknownElements: true,`,
121+
` errorOnUnknownProperties: true,`,
122+
'});',
123+
];
124+
125+
return {
126+
contents: contents.join('\n'),
127+
loader: 'js',
128+
resolveDir: projectSourceRoot,
129+
};
130+
},
131+
});
132+
extensions.codePlugins.unshift(virtualTestBedInit);
133+
134+
let instance: import('vitest/node').Vitest | undefined;
135+
136+
for await (const result of buildApplicationInternal(buildOptions, context, extensions)) {
137+
if (result.kind === ResultKind.Failure) {
138+
continue;
139+
} else if (result.kind !== ResultKind.Full) {
140+
assert.fail('A full build result is required from the application builder.');
141+
}
142+
143+
assert(result.files, 'Builder did not provide result files.');
144+
145+
await writeTestFiles(result.files, outputPath);
146+
147+
const setupFiles = ['init-testbed.js'];
148+
if (buildTargetOptions?.polyfills?.length) {
149+
setupFiles.push('polyfills.js');
150+
}
151+
152+
instance ??= await startVitest('test', undefined /* cliFilters */, undefined /* options */, {
153+
test: {
154+
root: outputPath,
155+
setupFiles,
156+
environment: 'jsdom',
157+
watch: normalizedOptions.watch,
158+
coverage: {
159+
enabled: normalizedOptions.codeCoverage,
160+
exclude: normalizedOptions.codeCoverageExclude,
161+
excludeAfterRemap: true,
162+
},
163+
},
164+
});
165+
166+
// Check if all the tests pass to calculate the result
167+
const testModules = instance.state.getTestModules();
168+
169+
yield { success: testModules.every((testModule) => testModule.ok()) };
170+
}
171+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { type Builder, createBuilder } from '@angular-devkit/architect';
10+
import { type UnitTestOptions, execute } from './builder';
11+
12+
export { type UnitTestOptions, execute };
13+
14+
const builder: Builder<UnitTestOptions> = createBuilder(execute);
15+
16+
export default builder;

0 commit comments

Comments
 (0)