Skip to content

feat(@angular/build): add experimental vitest unit-testing support #30130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/testing/builder/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ ts_project(
# Needed at runtime by some builder tests relying on SSR being
# resolvable in the test project.
":node_modules/@angular/ssr",
":node_modules/vitest",
] + glob(["projects/**/*"]),
deps = [
":node_modules/@angular-devkit/architect",
Expand Down
3 changes: 2 additions & 1 deletion modules/testing/builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"@angular-devkit/architect": "workspace:*",
"@angular/ssr": "workspace:*",
"@angular-devkit/build-angular": "workspace:*",
"rxjs": "7.8.2"
"rxjs": "7.8.2",
"vitest": "3.1.1"
}
}
44 changes: 44 additions & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ ts_json_schema(
src = "src/builders/ng-packagr/schema.json",
)

ts_json_schema(
name = "unit_test_schema",
src = "src/builders/unit-test/schema.json",
)

copy_to_bin(
name = "schemas",
srcs = glob(["**/schema.json"]),
Expand Down Expand Up @@ -79,6 +84,7 @@ ts_project(
"//packages/angular/build:src/builders/extract-i18n/schema.ts",
"//packages/angular/build:src/builders/karma/schema.ts",
"//packages/angular/build:src/builders/ng-packagr/schema.ts",
"//packages/angular/build:src/builders/unit-test/schema.ts",
],
data = RUNTIME_ASSETS,
deps = [
Expand Down Expand Up @@ -109,6 +115,7 @@ ts_project(
":node_modules/source-map-support",
":node_modules/tinyglobby",
":node_modules/vite",
":node_modules/vitest",
":node_modules/watchpack",
"//:node_modules/@angular/common",
"//:node_modules/@angular/compiler",
Expand Down Expand Up @@ -252,6 +259,36 @@ ts_project(
],
)

ts_project(
name = "unit-test_integration_test_lib",
testonly = True,
srcs = glob(include = ["src/builders/unit-test/tests/**/*.ts"]),
deps = [
":build",
"//packages/angular/build/private",
"//modules/testing/builder",
":node_modules/@angular-devkit/architect",
":node_modules/@angular-devkit/core",
"//:node_modules/@types/node",

# unit test specific test deps
":node_modules/vitest",
":node_modules/jsdom",

# Base dependencies for the hello-world-app.
"//:node_modules/@angular/common",
"//:node_modules/@angular/compiler",
"//:node_modules/@angular/compiler-cli",
"//:node_modules/@angular/core",
"//:node_modules/@angular/platform-browser",
"//:node_modules/@angular/router",
":node_modules/rxjs",
"//:node_modules/tslib",
"//:node_modules/typescript",
"//:node_modules/zone.js",
],
)

jasmine_test(
name = "application_integration_tests",
size = "large",
Expand Down Expand Up @@ -281,6 +318,13 @@ jasmine_test(
shard_count = 10,
)

jasmine_test(
name = "unit-test_integration_tests",
size = "large",
data = [":unit-test_integration_test_lib"],
shard_count = 10,
)

genrule(
name = "license",
srcs = ["//:LICENSE"],
Expand Down
5 changes: 5 additions & 0 deletions packages/angular/build/builders.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
"implementation": "./src/builders/ng-packagr/index",
"schema": "./src/builders/ng-packagr/schema.json",
"description": "Build a library with ng-packagr."
},
"unit-test": {
"implementation": "./src/builders/unit-test",
"schema": "./src/builders/unit-test/schema.json",
"description": "[EXPERIMENTAL] Run application unit tests."
}
}
}
10 changes: 8 additions & 2 deletions packages/angular/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@
"devDependencies": {
"@angular/ssr": "workspace:*",
"@angular-devkit/core": "workspace:*",
"jsdom": "26.1.0",
"less": "4.3.0",
"ng-packagr": "20.0.0-next.8",
"postcss": "8.5.3",
"rxjs": "7.8.2"
"rxjs": "7.8.2",
"vitest": "3.1.2"
},
"peerDependencies": {
"@angular/core": "0.0.0-ANGULAR-FW-PEER-DEP",
Expand All @@ -71,7 +73,8 @@
"postcss": "^8.4.0",
"tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0",
"tslib": "^2.3.0",
"typescript": ">=5.8 <5.9"
"typescript": ">=5.8 <5.9",
"vitest": "^3.1.1"
},
"peerDependenciesMeta": {
"@angular/core": {
Expand Down Expand Up @@ -106,6 +109,9 @@
},
"tailwindcss": {
"optional": true
},
"vitest": {
"optional": true
}
}
}
171 changes: 171 additions & 0 deletions packages/angular/build/src/builders/unit-test/builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import assert from 'node:assert';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
import { loadEsmModule } from '../../utils/load-esm';
import { buildApplicationInternal } from '../application';
import type {
ApplicationBuilderExtensions,
ApplicationBuilderInternalOptions,
} from '../application/options';
import { ResultKind } from '../application/results';
import { OutputHashing } from '../application/schema';
import { writeTestFiles } from '../karma/application_builder';
import { findTests, getTestEntrypoints } from '../karma/find-tests';
import { normalizeOptions } from './options';
import type { Schema as UnitTestOptions } from './schema';

export type { UnitTestOptions };

/**
* @experimental Direct usage of this function is considered experimental.
*/
export async function* execute(
options: UnitTestOptions,
context: BuilderContext,
extensions: ApplicationBuilderExtensions = {},
): AsyncIterable<BuilderOutput> {
// Determine project name from builder context target
const projectName = context.target?.project;
if (!projectName) {
context.logger.error(
`The "${context.builder.builderName}" builder requires a target to be specified.`,
);

return;
}

context.logger.warn(
`NOTE: The "${context.builder.builderName}" builder is currently EXPERIMENTAL and not ready for production use.`,
);

const normalizedOptions = await normalizeOptions(context, projectName, options);
const { projectSourceRoot, workspaceRoot, runnerName } = normalizedOptions;

if (runnerName !== 'vitest') {
context.logger.error('Unknown test runner: ' + runnerName);

return;
}

// Find test files
const testFiles = await findTests(
normalizedOptions.include,
normalizedOptions.exclude,
workspaceRoot,
projectSourceRoot,
);

if (testFiles.length === 0) {
context.logger.error('No tests found.');

return { success: false };
}

const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot });
entryPoints.set('init-testbed', 'angular:test-bed-init');

const { startVitest } = await loadEsmModule<typeof import('vitest/node')>('vitest/node');

// Setup test file build options based on application build target options
const buildTargetOptions = (await context.validateOptions(
await context.getTargetOptions(normalizedOptions.buildTarget),
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
)) as unknown as ApplicationBuilderInternalOptions;

if (buildTargetOptions.polyfills?.includes('zone.js')) {
buildTargetOptions.polyfills.push('zone.js/testing');
}

const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
const buildOptions: ApplicationBuilderInternalOptions = {
...buildTargetOptions,
watch: normalizedOptions.watch,
outputPath,
index: false,
browser: undefined,
server: undefined,
localize: false,
budgets: [],
serviceWorker: false,
appShell: false,
ssr: false,
prerender: false,
sourceMap: { scripts: true, vendor: false, styles: false },
outputHashing: OutputHashing.None,
optimization: false,
tsConfig: normalizedOptions.tsConfig,
entryPoints,
externalDependencies: ['vitest', ...(buildTargetOptions.externalDependencies ?? [])],
};
extensions ??= {};
extensions.codePlugins ??= [];
const virtualTestBedInit = createVirtualModulePlugin({
namespace: 'angular:test-bed-init',
loadContent: async () => {
const contents: string[] = [
// Initialize the Angular testing environment
`import { getTestBed } from '@angular/core/testing';`,
`import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';`,
`getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {`,
` errorOnUnknownElements: true,`,
` errorOnUnknownProperties: true,`,
'});',
];

return {
contents: contents.join('\n'),
loader: 'js',
resolveDir: projectSourceRoot,
};
},
});
extensions.codePlugins.unshift(virtualTestBedInit);

let instance: import('vitest/node').Vitest | undefined;

for await (const result of buildApplicationInternal(buildOptions, context, extensions)) {
if (result.kind === ResultKind.Failure) {
continue;
} else if (result.kind !== ResultKind.Full) {
assert.fail('A full build result is required from the application builder.');
}

assert(result.files, 'Builder did not provide result files.');

await writeTestFiles(result.files, outputPath);

const setupFiles = ['init-testbed.js'];
if (buildTargetOptions?.polyfills?.length) {
setupFiles.push('polyfills.js');
}

instance ??= await startVitest('test', undefined /* cliFilters */, undefined /* options */, {
test: {
root: outputPath,
setupFiles,
environment: 'jsdom',
watch: normalizedOptions.watch,
coverage: {
enabled: normalizedOptions.codeCoverage,
exclude: normalizedOptions.codeCoverageExclude,
excludeAfterRemap: true,
},
},
});

// Check if all the tests pass to calculate the result
const testModules = instance.state.getTestModules();

yield { success: testModules.every((testModule) => testModule.ok()) };
}
}
16 changes: 16 additions & 0 deletions packages/angular/build/src/builders/unit-test/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { type Builder, createBuilder } from '@angular-devkit/architect';
import { type UnitTestOptions, execute } from './builder';

export { type UnitTestOptions, execute };

const builder: Builder<UnitTestOptions> = createBuilder(execute);

export default builder;
Loading
Loading