Skip to content

Commit af08a63

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 c706d50 commit af08a63

File tree

13 files changed

+1145
-3
lines changed

13 files changed

+1145
-3
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
@@ -34,6 +34,11 @@ ts_json_schema(
3434
src = "src/builders/ng-packagr/schema.json",
3535
)
3636

37+
ts_json_schema(
38+
name = "unit_test_schema",
39+
src = "src/builders/unit-test/schema.json",
40+
)
41+
3742
copy_to_bin(
3843
name = "schemas",
3944
srcs = glob(["**/schema.json"]),
@@ -70,6 +75,7 @@ ts_project(
7075
"//packages/angular/build:src/builders/extract-i18n/schema.ts",
7176
"//packages/angular/build:src/builders/karma/schema.ts",
7277
"//packages/angular/build:src/builders/ng-packagr/schema.ts",
78+
"//packages/angular/build:src/builders/unit-test/schema.ts",
7379
],
7480
data = RUNTIME_ASSETS,
7581
module_name = "@angular/build",
@@ -101,6 +107,7 @@ ts_project(
101107
":node_modules/source-map-support",
102108
":node_modules/tinyglobby",
103109
":node_modules/vite",
110+
":node_modules/vitest",
104111
":node_modules/watchpack",
105112
"//:node_modules/@angular/common",
106113
"//:node_modules/@angular/compiler",
@@ -244,6 +251,36 @@ ts_project(
244251
],
245252
)
246253

254+
ts_project(
255+
name = "unit-test_integration_test_lib",
256+
testonly = True,
257+
srcs = glob(include = ["src/builders/unit-test/tests/**/*.ts"]),
258+
deps = [
259+
":build",
260+
"//packages/angular/build/private",
261+
"//modules/testing/builder",
262+
":node_modules/@angular-devkit/architect",
263+
":node_modules/@angular-devkit/core",
264+
"//:node_modules/@types/node",
265+
266+
# unit test specific test deps
267+
":node_modules/vitest",
268+
":node_modules/jsdom",
269+
270+
# Base dependencies for the hello-world-app.
271+
"//:node_modules/@angular/common",
272+
"//:node_modules/@angular/compiler",
273+
"//:node_modules/@angular/compiler-cli",
274+
"//:node_modules/@angular/core",
275+
"//:node_modules/@angular/platform-browser",
276+
"//:node_modules/@angular/router",
277+
":node_modules/rxjs",
278+
"//:node_modules/tslib",
279+
"//:node_modules/typescript",
280+
"//:node_modules/zone.js",
281+
],
282+
)
283+
247284
jasmine_test(
248285
name = "application_integration_tests",
249286
size = "large",
@@ -273,6 +310,13 @@ jasmine_test(
273310
shard_count = 10,
274311
)
275312

313+
jasmine_test(
314+
name = "unit-test_integration_tests",
315+
size = "large",
316+
data = [":unit-test_integration_test_lib"],
317+
shard_count = 10,
318+
)
319+
276320
genrule(
277321
name = "license",
278322
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.6",
5657
"postcss": "8.5.3",
57-
"rxjs": "7.8.2"
58+
"rxjs": "7.8.2",
59+
"vitest": "3.1.1"
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,157 @@
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 { loadEsmModule } from '../../utils/load-esm';
14+
import { buildApplicationInternal } from '../application';
15+
import type {
16+
ApplicationBuilderExtensions,
17+
ApplicationBuilderInternalOptions,
18+
} from '../application/options';
19+
import { ResultKind } from '../application/results';
20+
import { OutputHashing } from '../application/schema';
21+
import { writeTestFiles } from '../karma/application_builder';
22+
import { findTests, getTestEntrypoints } from '../karma/find-tests';
23+
import { normalizeOptions } from './options';
24+
import type { Schema as UnitTestOptions } from './schema';
25+
26+
export type { UnitTestOptions };
27+
28+
/**
29+
* @experimental Direct usage of this function is considered experimental.
30+
*/
31+
export async function* execute(
32+
options: UnitTestOptions,
33+
context: BuilderContext,
34+
extensions: ApplicationBuilderExtensions = {},
35+
): AsyncIterable<BuilderOutput> {
36+
// Determine project name from builder context target
37+
const projectName = context.target?.project;
38+
if (!projectName) {
39+
context.logger.error(
40+
`The "${context.builder.builderName}" builder requires a target to be specified.`,
41+
);
42+
43+
return;
44+
}
45+
46+
context.logger.warn(
47+
`NOTE: The "${context.builder.builderName}" builder is currently EXPERIMENTAL and not ready for production use.`,
48+
);
49+
50+
const normalizedOptions = await normalizeOptions(context, projectName, options);
51+
const { projectSourceRoot, workspaceRoot, runnerName } = normalizedOptions;
52+
53+
if (runnerName !== 'vitest') {
54+
context.logger.error('Unknown test runner: ' + runnerName);
55+
56+
return;
57+
}
58+
59+
// Find test files
60+
const testFiles = await findTests(
61+
normalizedOptions.include,
62+
normalizedOptions.exclude,
63+
workspaceRoot,
64+
projectSourceRoot,
65+
);
66+
67+
if (testFiles.length === 0) {
68+
context.logger.error('No tests found.');
69+
70+
return { success: false };
71+
}
72+
73+
const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot });
74+
entryPoints.set(
75+
'init-testbed',
76+
path.join(__dirname, '..', 'karma', 'polyfills', 'init_test_bed.js'),
77+
);
78+
79+
const { startVitest } = await loadEsmModule<typeof import('vitest/node')>('vitest/node');
80+
81+
// Setup test file build options based on application build target options
82+
const buildTargetOptions = (await context.validateOptions(
83+
await context.getTargetOptions(normalizedOptions.buildTarget),
84+
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
85+
)) as unknown as ApplicationBuilderInternalOptions;
86+
87+
if (buildTargetOptions.polyfills?.includes('zone.js')) {
88+
buildTargetOptions.polyfills.push('zone.js/testing');
89+
}
90+
91+
const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
92+
const buildOptions: ApplicationBuilderInternalOptions = {
93+
...buildTargetOptions,
94+
watch: normalizedOptions.watch,
95+
outputPath,
96+
index: false,
97+
browser: undefined,
98+
server: undefined,
99+
localize: false,
100+
budgets: [],
101+
serviceWorker: false,
102+
appShell: false,
103+
ssr: false,
104+
prerender: false,
105+
sourceMap: { scripts: true, vendor: false, styles: false },
106+
outputHashing: OutputHashing.None,
107+
optimization: false,
108+
tsConfig: normalizedOptions.tsConfig,
109+
entryPoints,
110+
externalDependencies: ['vitest', ...(buildTargetOptions.externalDependencies ?? [])],
111+
};
112+
113+
let instance: import('vitest/node').Vitest | undefined;
114+
115+
for await (const result of buildApplicationInternal(buildOptions, context, extensions)) {
116+
if (result.kind === ResultKind.Failure) {
117+
continue;
118+
} else if (result.kind !== ResultKind.Full) {
119+
assert.fail('A full build result is required from the application builder.');
120+
}
121+
122+
assert(result.files, 'Builder did not provide result files.');
123+
124+
await writeTestFiles(result.files, outputPath);
125+
126+
const setupFiles = ['init-testbed.js'];
127+
if (buildTargetOptions?.polyfills?.length) {
128+
setupFiles.push('polyfills.js');
129+
}
130+
131+
instance ??= await startVitest(
132+
'test',
133+
undefined /* cliFilters */,
134+
undefined /* options */,
135+
{
136+
test: {
137+
root: outputPath,
138+
setupFiles,
139+
environment: 'jsdom',
140+
watch: normalizedOptions.watch,
141+
reporters: normalizedOptions.reporters,
142+
coverage: {
143+
enabled: normalizedOptions.codeCoverage,
144+
exclude: normalizedOptions.codeCoverageExclude,
145+
excludeAfterRemap: true,
146+
},
147+
},
148+
},
149+
{},
150+
);
151+
152+
// Check if all the tests pass to calculate the result
153+
const testModules = instance.state.getTestModules();
154+
155+
yield { success: testModules.every((testModule) => testModule.ok()) };
156+
}
157+
}
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;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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, targetFromTargetString } from '@angular-devkit/architect';
10+
import path from 'node:path';
11+
import { normalizeCacheOptions } from '../../utils/normalize-cache';
12+
import type { Schema as UnitTestOptions } from './schema';
13+
14+
export type NormalizedUnitTestOptions = Awaited<ReturnType<typeof normalizeOptions>>;
15+
16+
export async function normalizeOptions(
17+
context: BuilderContext,
18+
projectName: string,
19+
options: UnitTestOptions,
20+
) {
21+
// Setup base paths based on workspace root and project information
22+
const workspaceRoot = context.workspaceRoot;
23+
const projectMetadata = await context.getProjectMetadata(projectName);
24+
const projectRoot = normalizeDirectoryPath(
25+
path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? ''),
26+
);
27+
const projectSourceRoot = normalizeDirectoryPath(
28+
path.join(workspaceRoot, (projectMetadata.sourceRoot as string | undefined) ?? 'src'),
29+
);
30+
31+
// Gather persistent caching option and provide a project specific cache location
32+
const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot);
33+
cacheOptions.path = path.join(cacheOptions.path, projectName);
34+
35+
// Target specifier defaults to the current project's build target using a development configuration
36+
const buildTargetSpecifier = options.buildTarget ?? `::development`;
37+
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
38+
39+
const { codeCoverage, codeCoverageExclude, tsConfig, runner, runnerConfig, reporters, browsers } =
40+
options;
41+
42+
return {
43+
// Project/workspace information
44+
workspaceRoot,
45+
projectRoot,
46+
projectSourceRoot,
47+
cacheOptions,
48+
// Target/configuration specified options
49+
buildTarget,
50+
include: options.include ?? ['**/*.spec.ts'],
51+
exclude: options.exclude ?? [],
52+
runnerName: runner,
53+
runnerConfig,
54+
codeCoverage,
55+
codeCoverageExclude,
56+
tsConfig,
57+
reporters,
58+
browsers,
59+
// TODO: Implement watch support
60+
watch: false,
61+
};
62+
}
63+
64+
/**
65+
* Normalize a directory path string.
66+
* Currently only removes a trailing slash if present.
67+
* @param path A path string.
68+
* @returns A normalized path string.
69+
*/
70+
function normalizeDirectoryPath(path: string): string {
71+
const last = path[path.length - 1];
72+
if (last === '/' || last === '\\') {
73+
return path.slice(0, -1);
74+
}
75+
76+
return path;
77+
}

0 commit comments

Comments
 (0)