Skip to content

Commit a90bea5

Browse files
committed
feat(@angular/build): support .test.ts files by default in unit test builder
The unit test discovery logic previously had hardcoded conventions for `.spec.ts` files. This made it inflexible for projects that use other common patterns, such as `.test.ts`. This change introduces support for `.test.ts` files by default and refactors the discovery logic to be more flexible and maintainable. Key changes: - The `unit-test` builder schema now includes both `**/*.spec.ts` and `**/*.test.ts` in its default `include` globs. - The internal test discovery logic in `test-discovery.ts` is refactored to use a configurable array of test file infixes (`.spec`, `.test`). - This allows the smart-handling of static paths (e.g., `ng test --include src/app/app.component.ts`) to correctly resolve the corresponding test file for both conventions. - JSDoc comments and variable names have been updated to improve clarity and reflect the new, more flexible approach.
1 parent 8f0f6a5 commit a90bea5

File tree

2 files changed

+80
-68
lines changed

2 files changed

+80
-68
lines changed

packages/angular/build/src/builders/unit-test/schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
"items": {
3737
"type": "string"
3838
},
39-
"default": ["**/*.spec.ts"],
40-
"description": "Specifies glob patterns of files to include for testing, relative to the project root. This option also has special handling for directory paths (includes all `.spec.ts` files within) and file paths (includes the corresponding `.spec` file if one exists)."
39+
"default": ["**/*.spec.ts", "**/*.test.ts"],
40+
"description": "Specifies glob patterns of files to include for testing, relative to the project root. This option also has special handling for directory paths (includes all test files within) and file paths (includes the corresponding test file if one exists)."
4141
},
4242
"exclude": {
4343
"type": "array",

packages/angular/build/src/builders/unit-test/test-discovery.ts

Lines changed: 78 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,23 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { PathLike, constants, promises as fs } from 'node:fs';
10-
import { basename, dirname, extname, join, relative } from 'node:path';
9+
import { type PathLike, constants, promises as fs } from 'node:fs';
10+
import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path';
1111
import { glob, isDynamicPattern } from 'tinyglobby';
1212
import { toPosixPath } from '../../utils/path';
1313

1414
/**
15-
* Finds all test files in the project.
15+
* An array of file infix notations that identify a file as a test file.
16+
* For example, `.spec` in `app.component.spec.ts`.
17+
*/
18+
const TEST_FILE_INFIXES = ['.spec', '.test'];
19+
20+
/**
21+
* Finds all test files in the project. This function implements a special handling
22+
* for static paths (non-globs) to improve developer experience. For example, if a
23+
* user provides a path to a component, this function will find the corresponding
24+
* test file. If a user provides a path to a directory, it will find all test
25+
* files within that directory.
1626
*
1727
* @param include Glob patterns of files to include.
1828
* @param exclude Glob patterns of files to exclude.
@@ -26,26 +36,21 @@ export async function findTests(
2636
workspaceRoot: string,
2737
projectSourceRoot: string,
2838
): Promise<string[]> {
29-
const staticMatches = new Set<string>();
39+
const resolvedTestFiles = new Set<string>();
3040
const dynamicPatterns: string[] = [];
3141

32-
const normalizedExcludes = exclude.map((p) =>
33-
normalizePattern(p, workspaceRoot, projectSourceRoot),
34-
);
42+
const projectRootPrefix = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/');
43+
const normalizedExcludes = exclude.map((p) => normalizePattern(p, projectRootPrefix));
3544

3645
// 1. Separate static and dynamic patterns
3746
for (const pattern of include) {
38-
const normalized = normalizePattern(pattern, workspaceRoot, projectSourceRoot);
39-
if (isDynamicPattern(normalized)) {
47+
const normalized = normalizePattern(pattern, projectRootPrefix);
48+
if (isDynamicPattern(pattern)) {
4049
dynamicPatterns.push(normalized);
4150
} else {
42-
const result = await handleStaticPattern(normalized, projectSourceRoot);
43-
if (Array.isArray(result)) {
44-
result.forEach((file) => staticMatches.add(file));
45-
} else {
46-
// It was a static path that didn't resolve to a spec, treat as dynamic
47-
dynamicPatterns.push(result);
48-
}
51+
const { resolved, unresolved } = await resolveStaticPattern(normalized, projectSourceRoot);
52+
resolved.forEach((file) => resolvedTestFiles.add(file));
53+
unresolved.forEach((p) => dynamicPatterns.push(p));
4954
}
5055
}
5156

@@ -59,12 +64,12 @@ export async function findTests(
5964
});
6065

6166
for (const match of globMatches) {
62-
staticMatches.add(match);
67+
resolvedTestFiles.add(match);
6368
}
6469
}
6570

6671
// 3. Combine and de-duplicate results
67-
return [...staticMatches];
72+
return [...resolvedTestFiles];
6873
}
6974

7075
interface TestEntrypointsOptions {
@@ -106,11 +111,12 @@ export function getTestEntrypoints(
106111
}
107112

108113
/**
109-
* Generates a unique, dash-delimited name from a file path.
110-
* This is used to create a consistent and readable bundle name for a given test file.
114+
* Generates a unique, dash-delimited name from a file path. This is used to
115+
* create a consistent and readable bundle name for a given test file.
116+
*
111117
* @param testFile The absolute path to the test file.
112118
* @param roots An array of root paths to remove from the beginning of the test file path.
113-
* @param removeTestExtension Whether to remove the `.spec` or `.test` extension from the result.
119+
* @param removeTestExtension Whether to remove the test file infix and extension from the result.
114120
* @returns A dash-cased name derived from the relative path of the test file.
115121
*/
116122
function generateNameFromPath(
@@ -128,7 +134,9 @@ function generateNameFromPath(
128134

129135
let endIndex = relativePath.length;
130136
if (removeTestExtension) {
131-
const match = relativePath.match(/\.(spec|test)\.[^.]+$/);
137+
const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|');
138+
const match = relativePath.match(new RegExp(`\\.(${infixes})\\.[^.]+$`));
139+
132140
if (match?.index) {
133141
endIndex = match.index;
134142
}
@@ -149,25 +157,23 @@ function generateNameFromPath(
149157
return result;
150158
}
151159

152-
const removeLeadingSlash = (pattern: string): string => {
153-
if (pattern.charAt(0) === '/') {
154-
return pattern.substring(1);
155-
}
156-
157-
return pattern;
160+
/** Removes a leading slash from a path. */
161+
const removeLeadingSlash = (path: string): string => {
162+
return path.startsWith('/') ? path.substring(1) : path;
158163
};
159164

160-
const removeRelativeRoot = (path: string, root: string): string => {
161-
if (path.startsWith(root)) {
162-
return path.substring(root.length);
163-
}
164-
165-
return path;
165+
/** Removes a prefix from the beginning of a string. */
166+
const removePrefix = (str: string, prefix: string): string => {
167+
return str.startsWith(prefix) ? str.substring(prefix.length) : str;
166168
};
167169

168170
/**
169171
* Removes potential root paths from a file path, returning a relative path.
170172
* If no root path matches, it returns the file's basename.
173+
*
174+
* @param path The file path to process.
175+
* @param roots An array of root paths to attempt to remove.
176+
* @returns A relative path.
171177
*/
172178
function removeRoots(path: string, roots: string[]): string {
173179
for (const root of roots) {
@@ -180,61 +186,67 @@ function removeRoots(path: string, roots: string[]): string {
180186
}
181187

182188
/**
183-
* Normalizes a glob pattern by converting it to a POSIX path, removing leading slashes,
184-
* and making it relative to the project source root.
189+
* Normalizes a glob pattern by converting it to a POSIX path, removing leading
190+
* slashes, and making it relative to the project source root.
185191
*
186192
* @param pattern The glob pattern to normalize.
187-
* @param workspaceRoot The absolute path to the workspace root.
188-
* @param projectSourceRoot The absolute path to the project's source root.
193+
* @param projectRootPrefix The POSIX-formatted prefix of the project's source root relative to the workspace root.
189194
* @returns A normalized glob pattern.
190195
*/
191-
function normalizePattern(
192-
pattern: string,
193-
workspaceRoot: string,
194-
projectSourceRoot: string,
195-
): string {
196-
// normalize pattern, glob lib only accepts forward slashes
196+
function normalizePattern(pattern: string, projectRootPrefix: string): string {
197197
let normalizedPattern = toPosixPath(pattern);
198198
normalizedPattern = removeLeadingSlash(normalizedPattern);
199199

200-
const relativeProjectRoot = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/');
200+
// Some IDEs and tools may provide patterns relative to the workspace root.
201+
// To ensure the glob operates correctly within the project's source root,
202+
// we remove the project's relative path from the front of the pattern.
203+
normalizedPattern = removePrefix(normalizedPattern, projectRootPrefix);
201204

202-
// remove relativeProjectRoot to support relative paths from root
203-
// such paths are easy to get when running scripts via IDEs
204-
return removeRelativeRoot(normalizedPattern, relativeProjectRoot);
205+
return normalizedPattern;
205206
}
206207

207208
/**
208-
* Handles static (non-glob) patterns by attempting to resolve them to a directory
209-
* of spec files or a corresponding `.spec` file.
209+
* Resolves a static (non-glob) path.
210+
*
211+
* If the path is a directory, it returns a glob pattern to find all test files
212+
* within that directory.
213+
*
214+
* If the path is a file, it attempts to find a corresponding test file by
215+
* checking for files with the same name and a test infix (e.g., `.spec.ts`).
216+
*
217+
* If no corresponding test file is found, the original path is returned as an
218+
* unresolved pattern.
210219
*
211220
* @param pattern The static path pattern.
212221
* @param projectSourceRoot The absolute path to the project's source root.
213-
* @returns A promise that resolves to either an array of found spec files, a new glob pattern,
214-
* or the original pattern if no special handling was applied.
222+
* @returns A promise that resolves to an object containing resolved spec files and unresolved patterns.
215223
*/
216-
async function handleStaticPattern(
224+
async function resolveStaticPattern(
217225
pattern: string,
218226
projectSourceRoot: string,
219-
): Promise<string[] | string> {
220-
const fullPath = join(projectSourceRoot, pattern);
227+
): Promise<{ resolved: string[]; unresolved: string[] }> {
228+
const fullPath = isAbsolute(pattern) ? pattern : join(projectSourceRoot, pattern);
221229
if (await isDirectory(fullPath)) {
222-
return `${pattern}/**/*.spec.@(ts|tsx)`;
230+
const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|');
231+
232+
return { resolved: [], unresolved: [`${pattern}/**/*.@(${infixes}).@(ts|tsx)`] };
223233
}
224234

225235
const fileExt = extname(pattern);
226-
// Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts`
227-
const potentialSpec = join(
228-
projectSourceRoot,
229-
dirname(pattern),
230-
`${basename(pattern, fileExt)}.spec${fileExt}`,
231-
);
232-
233-
if (await exists(potentialSpec)) {
234-
return [potentialSpec];
236+
const baseName = basename(pattern, fileExt);
237+
238+
for (const infix of TEST_FILE_INFIXES) {
239+
const potentialSpec = join(
240+
projectSourceRoot,
241+
dirname(pattern),
242+
`${baseName}${infix}${fileExt}`,
243+
);
244+
if (await exists(potentialSpec)) {
245+
return { resolved: [potentialSpec], unresolved: [] };
246+
}
235247
}
236248

237-
return pattern;
249+
return { resolved: [], unresolved: [pattern] };
238250
}
239251

240252
/** Checks if a path exists and is a directory. */

0 commit comments

Comments
 (0)