diff --git a/projects/testing-library/schematics/collection.json b/projects/testing-library/schematics/collection.json
index 0a435eb0..b8e6f33a 100644
--- a/projects/testing-library/schematics/collection.json
+++ b/projects/testing-library/schematics/collection.json
@@ -5,6 +5,11 @@
"factory": "./ng-add",
"schema": "./ng-add/schema.json",
"description": "Add @testing-library/angular to your application"
+ },
+ "migrate-to-zoneless": {
+ "factory": "./migrate-to-zoneless",
+ "schema": "./migrate-to-zoneless/schema.json",
+ "description": "Migrate imports from @testing-library/angular to @testing-library/angular/zoneless"
}
}
}
diff --git a/projects/testing-library/schematics/migrate-to-zoneless/index.spec.ts b/projects/testing-library/schematics/migrate-to-zoneless/index.spec.ts
new file mode 100644
index 00000000..6a3fcba4
--- /dev/null
+++ b/projects/testing-library/schematics/migrate-to-zoneless/index.spec.ts
@@ -0,0 +1,130 @@
+import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
+import * as path from 'path';
+import { EmptyTree } from '@angular-devkit/schematics';
+import { test, expect } from 'vitest';
+
+test('migrates imports from @testing-library/angular to @testing-library/angular/zoneless', async () => {
+ const before = `
+import { render, screen } from '@testing-library/angular';
+import { AppComponent } from './app.component';
+
+describe('AppComponent', () => {
+ it('should render', async () => {
+ await render(AppComponent);
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+ });
+});
+`;
+
+ const after = `
+import { render, screen } from '@testing-library/angular/zoneless';
+import { AppComponent } from './app.component';
+
+describe('AppComponent', () => {
+ it('should render', async () => {
+ await render(AppComponent);
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+ });
+});
+`;
+
+ const tree = await setup({
+ 'src/app.spec.ts': before,
+ });
+
+ expect(tree.readContent('src/app.spec.ts')).toBe(after);
+});
+
+test('migrates imports with double quotes', async () => {
+ const before = `import { render } from "@testing-library/angular";`;
+ const after = `import { render } from "@testing-library/angular/zoneless";`;
+
+ const tree = await setup({
+ 'src/test.spec.ts': before,
+ });
+
+ expect(tree.readContent('src/test.spec.ts')).toBe(after);
+});
+
+test('migrates multiple imports in the same file', async () => {
+ const before = `
+import { render, screen } from '@testing-library/angular';
+import { fireEvent } from '@testing-library/angular';
+`;
+
+ const after = `
+import { render, screen } from '@testing-library/angular/zoneless';
+import { fireEvent } from '@testing-library/angular/zoneless';
+`;
+
+ const tree = await setup({
+ 'src/multi.spec.ts': before,
+ });
+
+ expect(tree.readContent('src/multi.spec.ts')).toBe(after);
+});
+
+test('does not modify imports from other packages', async () => {
+ const before = `
+import { render } from '@testing-library/angular';
+import { screen } from '@testing-library/dom';
+import { Component } from '@angular/core';
+`;
+
+ const after = `
+import { render } from '@testing-library/angular/zoneless';
+import { screen } from '@testing-library/dom';
+import { Component } from '@angular/core';
+`;
+
+ const tree = await setup({
+ 'src/other.spec.ts': before,
+ });
+
+ expect(tree.readContent('src/other.spec.ts')).toBe(after);
+});
+
+test('handles files without @testing-library/angular imports', async () => {
+ const content = `
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-root',
+ template: '
Hello
'
+})
+export class AppComponent {}
+`;
+
+ const tree = await setup({
+ 'src/regular.ts': content,
+ });
+
+ expect(tree.readContent('src/regular.ts')).toBe(content);
+});
+
+test('migrates multiple files', async () => {
+ const tree = await setup({
+ 'src/file1.spec.ts': `import { render } from '@testing-library/angular';`,
+ 'src/file2.spec.ts': `import { screen } from '@testing-library/angular';`,
+ 'src/file3.spec.ts': `import { fireEvent } from '@testing-library/angular';`,
+ });
+
+ expect(tree.readContent('src/file1.spec.ts')).toBe(`import { render } from '@testing-library/angular/zoneless';`);
+ expect(tree.readContent('src/file2.spec.ts')).toBe(`import { screen } from '@testing-library/angular/zoneless';`);
+ expect(tree.readContent('src/file3.spec.ts')).toBe(`import { fireEvent } from '@testing-library/angular/zoneless';`);
+});
+
+async function setup(files: Record) {
+ const collectionPath = path.join(__dirname, '../../../../dist/@testing-library/angular/schematics/collection.json');
+ const schematicRunner = new SchematicTestRunner('schematics', collectionPath);
+
+ const tree = new UnitTestTree(new EmptyTree());
+
+ for (const [filePath, content] of Object.entries(files)) {
+ tree.create(filePath, content);
+ }
+
+ await schematicRunner.runSchematic('migrate-to-zoneless', {}, tree);
+
+ return tree;
+}
diff --git a/projects/testing-library/schematics/migrate-to-zoneless/index.ts b/projects/testing-library/schematics/migrate-to-zoneless/index.ts
new file mode 100644
index 00000000..c19cad32
--- /dev/null
+++ b/projects/testing-library/schematics/migrate-to-zoneless/index.ts
@@ -0,0 +1,73 @@
+import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
+import * as ts from 'typescript';
+
+export default function (): Rule {
+ return async (tree: Tree, context: SchematicContext) => {
+ context.logger.info('Migrating imports from @testing-library/angular to @testing-library/angular/zoneless...');
+
+ let filesUpdated = 0;
+
+ tree.visit((path) => {
+ if (!path.endsWith('.ts') || path.includes('node_modules')) {
+ return;
+ }
+
+ const content = tree.read(path);
+ if (!content) {
+ return;
+ }
+
+ const text = content.toString('utf-8');
+
+ if (!text.includes('@testing-library/angular')) {
+ return;
+ }
+
+ const sourceFile = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true);
+
+ const changes: { start: number; end: number; newText: string }[] = [];
+
+ function visit(node: ts.Node) {
+ if (ts.isImportDeclaration(node)) {
+ const moduleSpecifier = node.moduleSpecifier;
+
+ if (ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === '@testing-library/angular') {
+ const fullText = moduleSpecifier.getFullText(sourceFile);
+ const quoteChar = fullText.trim()[0]; // ' or "
+
+ changes.push({
+ start: moduleSpecifier.getStart(sourceFile),
+ end: moduleSpecifier.getEnd(),
+ newText: `${quoteChar}@testing-library/angular/zoneless${quoteChar}`,
+ });
+ }
+ }
+
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+
+ if (changes.length > 0) {
+ changes.sort((a, b) => b.start - a.start);
+
+ let updatedText = text;
+ for (const change of changes) {
+ updatedText = updatedText.slice(0, change.start) + change.newText + updatedText.slice(change.end);
+ }
+
+ tree.overwrite(path, updatedText);
+ filesUpdated++;
+ context.logger.info(`Updated: ${path}`);
+ }
+ });
+
+ if (filesUpdated > 0) {
+ context.logger.info(`✓ Successfully migrated ${filesUpdated} file(s) to use @testing-library/angular/zoneless`);
+ } else {
+ context.logger.warn('No files found with @testing-library/angular imports.');
+ }
+
+ return tree;
+ };
+}
diff --git a/projects/testing-library/schematics/migrate-to-zoneless/schema.json b/projects/testing-library/schematics/migrate-to-zoneless/schema.json
new file mode 100644
index 00000000..03250110
--- /dev/null
+++ b/projects/testing-library/schematics/migrate-to-zoneless/schema.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "MigrateToZonelessSchema",
+ "title": "Migrate to Zoneless Schema",
+ "type": "object",
+ "description": "Migrate imports from @testing-library/angular to @testing-library/angular/zoneless",
+ "properties": {},
+ "required": []
+}
diff --git a/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts
index 98b7acc6..9b224142 100644
--- a/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts
+++ b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts
@@ -1,6 +1,7 @@
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import * as path from 'path';
import { EmptyTree } from '@angular-devkit/schematics';
+import { test, expect } from 'vitest';
test('adds DTL to devDependencies', async () => {
const tree = await setup({});
diff --git a/projects/testing-library/src/tests/zoneless.spec.ts b/projects/testing-library/src/tests/zoneless.spec.ts
new file mode 100644
index 00000000..2c0fc6ec
--- /dev/null
+++ b/projects/testing-library/src/tests/zoneless.spec.ts
@@ -0,0 +1,177 @@
+import { Component, inject, Injectable, model, output, outputBinding, signal, twoWayBinding } from '@angular/core';
+import { test, expect, vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '../../zoneless';
+
+@Injectable()
+class CounterService {
+ private count = signal(0);
+
+ getCount() {
+ return this.count();
+ }
+
+ increment() {
+ this.count.set(this.count() + 1);
+ }
+
+ decrement() {
+ this.count.set(this.count() - 1);
+ }
+}
+
+@Component({
+ selector: 'atl-service-fixture',
+ template: `
+
+ {{ count }}
+
+ `,
+})
+class ServiceFixtureComponent {
+ private counterService = inject(CounterService);
+ count = this.counterService.getCount();
+
+ increment() {
+ this.counterService.increment();
+ this.count = this.counterService.getCount();
+ }
+
+ decrement() {
+ this.counterService.decrement();
+ this.count = this.counterService.getCount();
+ }
+}
+
+@Component({
+ selector: 'atl-fixture',
+ template: `
+
+ {{ value() }}
+
+ `,
+})
+class FixtureComponent {
+ value = model(5);
+ valueUpdated = output();
+
+ decrement() {
+ this.value.set(this.value() - 1);
+ this.valueUpdated.emit(this.value());
+ }
+
+ increment() {
+ this.value.set(this.value() + 1);
+ this.valueUpdated.emit(this.value());
+ }
+}
+
+test('renders and interacts with the component', async () => {
+ const user = userEvent.setup();
+ await render(FixtureComponent);
+
+ const incrementControl = screen.getByRole('button', { name: '+' });
+ const decrementControl = screen.getByRole('button', { name: '-' });
+ const valueControl = screen.getByTestId('value');
+
+ expect(valueControl).toHaveTextContent('5');
+
+ await user.click(incrementControl);
+ await user.click(incrementControl);
+
+ expect(valueControl).toHaveTextContent('7');
+
+ await user.click(decrementControl);
+ expect(valueControl).toHaveTextContent('6');
+});
+
+test('renders and interacts with the component with detectChangesOnRender set to false', async () => {
+ const user = userEvent.setup();
+ await render(FixtureComponent, { detectChangesOnRender: false });
+
+ const incrementControl = screen.getByRole('button', { name: '+' });
+ const decrementControl = screen.getByRole('button', { name: '-' });
+ const valueControl = screen.getByTestId('value');
+
+ // The initial value is not rendered until the first change detection runs, so we need to wait for it.
+ expect(valueControl).not.toHaveTextContent('5');
+ await vi.waitFor(() => expect(valueControl).toHaveTextContent('5'));
+
+ await user.click(incrementControl);
+ await user.click(incrementControl);
+
+ expect(valueControl).toHaveTextContent('7');
+
+ await user.click(decrementControl);
+ expect(valueControl).toHaveTextContent('6');
+});
+
+test('can set properties', async () => {
+ const user = userEvent.setup();
+ const spy = vi.fn();
+ await render(FixtureComponent, {
+ bindings: [twoWayBinding('value', signal(3)), outputBinding('valueUpdated', spy)],
+ });
+
+ const valueControl = screen.getByTestId('value');
+ const incrementControl = screen.getByRole('button', { name: '+' });
+
+ expect(valueControl).toHaveTextContent('3');
+
+ await user.click(incrementControl);
+ expect(spy).toHaveBeenCalledWith(4);
+});
+
+test('renders and interacts with the component using a template', async () => {
+ const user = userEvent.setup();
+ const wrapperValue = signal(10);
+ const wrapperOutput = vi.fn();
+ await render(``, {
+ imports: [FixtureComponent],
+ wrapperProperties: {
+ wrapperValue: wrapperValue,
+ valueUpdated: wrapperOutput,
+ },
+ });
+
+ expect(screen.getByTestId('value')).toHaveTextContent('10');
+
+ const incrementControl = screen.getByRole('button', { name: '+' });
+ const decrementControl = screen.getByRole('button', { name: '-' });
+ const valueControl = screen.getByTestId('value');
+
+ await user.click(incrementControl);
+ expect(wrapperOutput).toHaveBeenCalled();
+
+ await user.click(incrementControl);
+ expect(valueControl).toHaveTextContent('12');
+
+ await user.click(decrementControl);
+ expect(valueControl).toHaveTextContent('11');
+
+ wrapperValue.set(20);
+ // Wait for the component to update after changing the signal value
+ await vi.waitFor(() => expect(valueControl).toHaveTextContent('20'));
+});
+
+test('can provide custom service providers', async () => {
+ const user = userEvent.setup();
+ await render(ServiceFixtureComponent, {
+ providers: [CounterService],
+ });
+
+ const incrementControl = screen.getByRole('button', { name: '+' });
+ const decrementControl = screen.getByRole('button', { name: '-' });
+ const counterControl = screen.getByTestId('counter');
+
+ expect(counterControl).toHaveTextContent('0');
+
+ await user.click(incrementControl);
+ expect(counterControl).toHaveTextContent('1');
+
+ await user.click(incrementControl);
+ expect(counterControl).toHaveTextContent('2');
+
+ await user.click(decrementControl);
+ expect(counterControl).toHaveTextContent('1');
+});
diff --git a/projects/testing-library/zoneless/index.ts b/projects/testing-library/zoneless/index.ts
new file mode 100644
index 00000000..decc72d8
--- /dev/null
+++ b/projects/testing-library/zoneless/index.ts
@@ -0,0 +1 @@
+export * from './src/public_api';
diff --git a/projects/testing-library/zoneless/ng-package.json b/projects/testing-library/zoneless/ng-package.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/projects/testing-library/zoneless/ng-package.json
@@ -0,0 +1 @@
+{}
diff --git a/projects/testing-library/zoneless/src/public_api.ts b/projects/testing-library/zoneless/src/public_api.ts
new file mode 100644
index 00000000..45f4e745
--- /dev/null
+++ b/projects/testing-library/zoneless/src/public_api.ts
@@ -0,0 +1,260 @@
+import { Component, type Type, type Binding, type Provider, type EnvironmentProviders } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+ getQueriesForElement,
+ prettyDOM,
+ type BoundFunctions,
+ type PrettyDOMOptions,
+ type queries,
+ type Queries,
+} from '@testing-library/dom';
+
+export type RenderResultQueries = BoundFunctions;
+
+export interface RenderResult extends RenderResultQueries {
+ /**
+ * @description
+ * The containing DOM node of your rendered Angular Component.
+ * This is a regular DOM node, so you can call container.querySelector etc. to inspect the children.
+ */
+ container: HTMLElement;
+
+ /**
+ * @description
+ * Prints out the component's DOM with syntax highlighting.
+ * Accepts an optional parameter, to print out a specific DOM node.
+ *
+ * @param
+ * element: The to be printed HTML element, if not provided it will log the whole component's DOM
+ */
+ debug: (
+ element?: Element | Document | (Element | Document)[],
+ maxLength?: number,
+ options?: PrettyDOMOptions,
+ ) => void;
+
+ /**
+ * @description
+ * The Angular `ComponentFixture` of the component or the wrapper.
+ * If a template is provided, it will be the fixture of the wrapper.
+ *
+ * For more info see https://angular.io/api/core/testing/ComponentFixture
+ */
+ fixture: ComponentFixture;
+}
+
+export interface RenderOptions {
+ /**
+ * @description
+ * Queries to bind. Overrides the default set from DOM Testing Library unless merged.
+ *
+ * @default
+ * undefined
+ *
+ * @example
+ * import * as customQueries from 'custom-queries'
+ * import { queries } from '@testing-library/angular'
+ *
+ * await render(AppComponent, {
+ * queries: { ...queries, ...customQueries }
+ * })
+ */
+ queries?: Q;
+
+ /**
+ * @description
+ * Callback to configure the testbed before the compilation.
+ *
+ * @default
+ * () => {}
+ *
+ * @example
+ * await render(AppComponent, {
+ * configureTestBed: (testBed) => { }
+ * })
+ */
+ configureTestBed?: (testbed: TestBed) => void;
+
+ /**
+ * @description
+ * Determines whether `fixture.detectChanges()` is called after the component is rendered.
+ *
+ * @default
+ * true
+ *
+ * @example
+ * await render(AppComponent, {
+ * detectChangesOnRender: false
+ * })
+ */
+ detectChangesOnRender?: boolean;
+
+ /**
+ * @description
+ * An array of providers to be added to the testbed.
+ */
+ providers?: (Provider | EnvironmentProviders)[];
+}
+
+export interface RenderComponentOptions extends RenderOptions {
+ /**
+ * @description
+ * An array of bindings to apply to the component using Angular's native bindings API.
+ * This provides a more direct way to bind inputs and outputs compared to the `inputs` and `on` options.
+ *
+ * @default
+ * []
+ *
+ * @example
+ * import { inputBinding, outputBinding, twoWayBinding } from '@angular/core';
+ * import { signal } from '@angular/core';
+ *
+ * await render(AppComponent, {
+ * bindings: [
+ * inputBinding('value', () => 'test value'),
+ * outputBinding('click', (event) => console.log(event)),
+ * twoWayBinding('name', signal('initial value'))
+ * ]
+ * })
+ */
+ bindings?: Binding[];
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+export interface RenderTemplateOptions
+ extends RenderOptions {
+ /**
+ * @description
+ * An Angular component to wrap the component in.
+ * The template will be overridden with the `template` option.
+ * NOTE: A standalone component cannot be used as a wrapper.
+ *
+ * @default
+ * `WrapperComponent`, a basic empty component.
+ *
+ * @example
+ * await render(``, {
+ * declarations: [SpoilerDirective]
+ * wrapper: CustomWrapperComponent
+ * })
+ */
+ wrapper?: Type;
+
+ /**
+ * @description
+ * An object of properties to set on the wrapper component instance.
+ * This allows you to set input properties on the wrapper component, which can be useful for testing content projection or other wrapper-related functionality.
+ *
+ * @default
+ * undefined
+ *
+ * @example
+ * await render(`{{ message }}
`, {
+ * wrapperProperties: { message: 'Hello World' }
+ * })
+ */
+ wrapperProperties?: Partial;
+
+ /**
+ * @description
+ * An array of modules to be imported into the testbed.
+ */
+ imports?: any[];
+}
+
+export async function render(
+ component: Type,
+ renderOptions?: RenderComponentOptions,
+): Promise>;
+export async function render(
+ template: string,
+ renderOptions?: RenderTemplateOptions,
+): Promise>;
+export async function render(
+ componentOrTemplate: Type | string,
+ renderOptions: RenderComponentOptions | RenderTemplateOptions = {},
+): Promise> {
+ TestBed.configureTestingModule({
+ declarations: [WrapperComponent],
+ imports: 'imports' in renderOptions ? renderOptions.imports : [],
+ providers: renderOptions.providers ?? [],
+ });
+
+ renderOptions.configureTestBed?.(TestBed);
+ await TestBed.compileComponents();
+
+ const fixture =
+ typeof componentOrTemplate === 'string'
+ ? await createWrapperFixture(componentOrTemplate, (renderOptions ?? {}) as RenderTemplateOptions)
+ : await createComponentFixture(componentOrTemplate, (renderOptions ?? {}) as RenderComponentOptions);
+
+ if (renderOptions.detectChangesOnRender !== false) {
+ fixture.detectChanges();
+ }
+
+ return {
+ fixture,
+ container: fixture.nativeElement,
+ debug: (element = fixture.nativeElement, maxLength?: number, options?: PrettyDOMOptions) => {
+ if (Array.isArray(element)) {
+ for (const e of element) {
+ console.log(prettyDOM(e, maxLength, options));
+ }
+ } else {
+ console.log(prettyDOM(element, maxLength, options));
+ }
+ },
+ ...getQueriesForElement(fixture.nativeElement, renderOptions?.queries),
+ };
+}
+
+async function createComponentFixture(
+ sut: Type,
+ options: RenderComponentOptions,
+): Promise> {
+ return TestBed.createComponent(sut, { bindings: options.bindings || [] });
+}
+
+async function createWrapperFixture(
+ sut: string,
+ options: RenderTemplateOptions,
+): Promise> {
+ const wrapper = options.wrapper ?? (WrapperComponent as Type);
+ TestBed.overrideTemplate(wrapper, sut);
+ const fixture = TestBed.createComponent(wrapper);
+ setWrapperProperties(fixture, options.wrapperProperties);
+ return fixture;
+}
+
+function setWrapperProperties(
+ fixture: ComponentFixture,
+ wrapperProperties: RenderTemplateOptions['wrapperProperties'] = {},
+) {
+ for (const key of Object.keys(wrapperProperties)) {
+ const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key);
+ let _value = wrapperProperties[key];
+ const defaultGetter = () => _value;
+ const extendedSetter = (value: any) => {
+ _value = value;
+ descriptor?.set?.call(fixture.componentInstance, _value);
+ fixture.changeDetectorRef.detectChanges();
+ };
+
+ Object.defineProperty(fixture.componentInstance, key, {
+ get: descriptor?.get || defaultGetter,
+ set: extendedSetter,
+ // Allow the property to be defined again later.
+ // This happens when the component properties are updated after initial render.
+ // For Jest this is `true` by default, for Karma and a real browser the default is `false`
+ configurable: true,
+ });
+
+ descriptor?.set?.call(fixture.componentInstance, _value);
+ }
+ return fixture;
+}
+
+@Component({ selector: 'atl-wrapper-component', template: '', standalone: false })
+class WrapperComponent {}
+
+export * from '@testing-library/dom';