diff --git a/README.md b/README.md
index d5f67795..619870eb 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,8 @@ Become a bronze sponsor and get your logo on our README on GitHub.
- [Testing Select Elements](#testing-select-elements)
- [Mocking Components](#mocking-components)
- [Testing Single Component/Directive Angular Modules](#testing-single-componentdirective-angular-modules)
+ - [Deferrable Views](#deferrable-views)
+ - [Nested Deferrable Views](#nested-deferrable-views)
- [Testing with Host](#testing-with-host)
- [Custom Host Component](#custom-host-component)
- [Testing with Routing](#testing-with-routing)
@@ -154,6 +156,7 @@ const createComponent = createComponentFactory({
declareComponent: false, // Defaults to true
disableAnimations: false, // Defaults to true
shallow: true, // Defaults to false
+ deferBlockBehavior: DeferBlockBehavior // Defaults to DeferBlockBehavior.Playthrough
});
```
@@ -551,7 +554,107 @@ const createDirective = createDirectiveFactory({
});
```
+### Deferrable Views
+The Spectator provides a convenient API to access the deferrable views (`@defer {}`).
+Access the desired defer block using the `spectator.deferBlock(optionalIndex)` method. The `optionalIndex` parameter is optional and allows you to specify the index of the defer block you want to access.
+
+- **Accessing the first defer block**: Simply call `spectator.deferBlock()`.
+- **Accessing subsequent defer blocks**: Use the corresponding index as an argument. For example, `spectator.deferBlock(1)` accesses the second block (zero-based indexing).
+
+The `spectator.deferBlock(optionalIndex)` returns four methods for rendering different states of the specified defer block:
+
+- `renderComplete()` - Renders the **complete** state of the defer block.
+- `renderPlaceholder()` - Renders the **placeholder** state of the defer block.
+- `renderLoading()` - Renders the **loading** state of the defer block.
+- `renderError()` - Renders the **error** state of the defer block.
+
+**Example:**
+
+```ts
+ @Component({
+ selector: 'app-cmp',
+ template: `
+ @defer (on viewport) {
+
Complete state of the first defer block
+ } @placeholder {
+ Placeholder
+ }
+ `,
+ })
+ class DummyComponent {}
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Manual,
+ });
+
+ it('should render the complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('first defer block');
+ });
+```
+
+
+#### Nested Deferrable Views
+
+To access states within nested defer blocks, call the `deferBlock` method **chaining** from the returned block state method.
+
+**Example:** Accessing the nested complete state:
+
+```ts
+// Assuming `spectator.deferBlock(0).renderComplete()` renders the complete state of the parent defer block
+const parentCompleteState = await spectator.deferBlock().renderComplete();
+
+// Access the nested complete state of the parent defer block
+const nestedCompleteState = await parentCompleteState.renderComplete().deferBlock();
+```
+
+**Complete Example**:
+
+```ts
+ @Component({
+ selector: 'app-cmp',
+ template: `
+ @defer (on viewport) {
+ Complete state of the first defer block
+
+ @defer {
+ Complete state of the nested defer block
+ }
+ } @placeholder {
+ Placeholder
+ }
+ `,
+ })
+ class DummyComponent {}
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Manual,
+ });
+
+ it('should render the first nested complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ // Renders the parent complete state
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+
+ // Renders the nested complete state
+ await parentCompleteState.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('nested defer block');
+ });
+```
## Testing with Host
Testing a component with a host component is a more elegant and powerful technique to test your component.
diff --git a/projects/spectator/jest/test/defer-block.spec.ts b/projects/spectator/jest/test/defer-block.spec.ts
new file mode 100644
index 00000000..06d84823
--- /dev/null
+++ b/projects/spectator/jest/test/defer-block.spec.ts
@@ -0,0 +1,259 @@
+import { Component } from '@angular/core';
+import { DeferBlockBehavior, fakeAsync } from '@angular/core/testing';
+import { createComponentFactory } from '@ngneat/spectator/jest';
+
+describe('DeferBlock', () => {
+ describe('Playthrough Behavior', () => {
+ @Component({
+ selector: 'app-root',
+ template: `
+ Toggle
+
+ @defer (when isVisible) {
+ empty defer block
+ } ,
+ `,
+ standalone: true,
+ })
+ class DummyComponent {
+ isVisible = false;
+ }
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Playthrough,
+ });
+
+ it('should render the defer block when isVisible is true', fakeAsync(() => {
+ // Arrange
+ const spectator = createComponent();
+
+ const button = spectator.query('[data-test="button--isVisible"]')!;
+
+ // Act
+ spectator.click(button);
+ spectator.tick();
+ spectator.detectChanges();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('empty defer block');
+ }));
+ });
+
+ describe('Manual Behavior', () => {
+ @Component({
+ selector: 'app-root',
+ template: `
+ @defer (on viewport) {
+ empty defer block
+ } @placeholder {
+ this is the placeholder text
+ } @loading {
+ this is the loading text
+ } @error {
+ this is the error text
+ }
+ `,
+ })
+ class DummyComponent {}
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Manual,
+ });
+
+ it('should render the complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('empty defer block');
+ });
+
+ it('should render the placeholder state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderPlaceholder();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('this is the placeholder text');
+ });
+
+ it('should render the loading state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderLoading();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('this is the loading text');
+ });
+
+ it('should render the error state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderError();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('this is the error text');
+ });
+ });
+
+ describe('Manual Behavior with nested states', () => {
+ @Component({
+ selector: 'app-root',
+ template: `
+ @defer (on viewport) {
+ complete state #1
+
+
+ @defer {
+ complete state #1.1
+
+
+ @defer {
+ complete state #1.1.1
+ } @placeholder {
+ placeholder state #1.1.1
+ }
+
+
+
+ @defer {
+ complete state #1.1.2
+ } @placeholder {
+ placeholder state #1.1.2
+ }
+
+
+ } @placeholder {
+ nested placeholder text
+ } @loading {
+ nested loading text
+ } @error {
+ nested error text
+ }
+
+
+ } @placeholder {
+ placeholder state #1
+ } @loading {
+ loading state #1
+ } @error {
+ error state #1
+ }
+ `,
+ })
+ class DummyComponent {}
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Manual,
+ });
+
+ it('should render the first nested complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ await parentCompleteState.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('complete state #1.1');
+ });
+
+ it('should render the first deep nested complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('complete state #1.1.1');
+ });
+
+ it('should render the first deep nested placeholder state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock().renderPlaceholder();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('placeholder state #1.1.1');
+ });
+
+ it('should render the second nested complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock(1).renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('complete state #1.1.2');
+ });
+
+ it('should render the second nested placeholder state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock(1).renderPlaceholder();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('placeholder state #1.1.2');
+ });
+
+ it('should render the placeholder state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderPlaceholder();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('placeholder state #1');
+ });
+
+ it('should render the loading state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderLoading();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('loading state #1');
+ });
+
+ it('should render the error state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderError();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('error state #1');
+ });
+ });
+});
diff --git a/projects/spectator/src/lib/base/initial-module.ts b/projects/spectator/src/lib/base/initial-module.ts
index 6d5f39c7..42a109ce 100644
--- a/projects/spectator/src/lib/base/initial-module.ts
+++ b/projects/spectator/src/lib/base/initial-module.ts
@@ -1,5 +1,5 @@
import { SchemaMetadata, Type } from '@angular/core';
-import { ModuleTeardownOptions } from '@angular/core/testing';
+import { DeferBlockBehavior, ModuleTeardownOptions } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { getGlobalsInjections } from '../globals-injections';
@@ -16,6 +16,7 @@ export interface ModuleMetadata {
entryComponents: Type[];
schemas?: (SchemaMetadata | any[])[];
teardown?: ModuleTeardownOptions;
+ deferBlockBehavior: DeferBlockBehavior;
errorOnUnknownElements: boolean;
errorOnUnknownProperties: boolean;
}
@@ -36,6 +37,7 @@ export function initialModule(options: Required): ModuleMe
// is always defined. If the user calls `defineGlobalsInjections({ teardown: { ... } })` and we merge it with
// `options.teardown`, then `options.teardown` will always override global options.
{ ...(globals.teardown || options.teardown) },
+ deferBlockBehavior: globals.deferBlockBehavior || options.deferBlockBehavior,
errorOnUnknownElements: globals.errorOnUnknownElements || options.errorOnUnknownElements,
errorOnUnknownProperties: globals.errorOnUnknownProperties || options.errorOnUnknownProperties,
};
diff --git a/projects/spectator/src/lib/base/options.ts b/projects/spectator/src/lib/base/options.ts
index c62d43b4..f3737a4c 100644
--- a/projects/spectator/src/lib/base/options.ts
+++ b/projects/spectator/src/lib/base/options.ts
@@ -1,5 +1,5 @@
import { Component, Directive, NgModule, Pipe, Provider, SchemaMetadata, Type } from '@angular/core';
-import { MetadataOverride, ModuleTeardownOptions } from '@angular/core/testing';
+import { DeferBlockBehavior, MetadataOverride, ModuleTeardownOptions } from '@angular/core/testing';
import { merge } from '../internals/merge';
import { mockProvider, MockProvider } from '../mock';
@@ -22,6 +22,7 @@ export interface BaseSpectatorOptions {
overrideDirectives?: [Type, MetadataOverride][];
overridePipes?: [Type, MetadataOverride][];
teardown?: ModuleTeardownOptions;
+ deferBlockBehavior?: DeferBlockBehavior;
errorOnUnknownElements?: boolean;
errorOnUnknownProperties?: boolean;
}
@@ -49,6 +50,7 @@ const defaultOptions: OptionalsRequired = {
teardown: { destroyAfterEach: false },
errorOnUnknownElements: false,
errorOnUnknownProperties: false,
+ deferBlockBehavior: DeferBlockBehavior.Playthrough,
};
/**
diff --git a/projects/spectator/src/lib/spectator/spectator.ts b/projects/spectator/src/lib/spectator/spectator.ts
index e49fbbbb..db370871 100644
--- a/projects/spectator/src/lib/spectator/spectator.ts
+++ b/projects/spectator/src/lib/spectator/spectator.ts
@@ -1,11 +1,11 @@
import { ChangeDetectorRef, DebugElement } from '@angular/core';
-import { ComponentFixture } from '@angular/core/testing';
+import { ComponentFixture, DeferBlockFixture, DeferBlockState } from '@angular/core/testing';
import { DomSpectator } from '../base/dom-spectator';
import { setProps } from '../internals/query';
import { SpyObject } from '../mock';
import { Token } from '../token';
-import { InferInputSignal, InferInputSignals } from '../types';
+import { DeferBlocks, InferInputSignal, InferInputSignals, NestedDeferBlocks } from '../types';
/**
* @publicApi
@@ -50,4 +50,81 @@ export class Spectator extends DomSpectator {
// Force cd on the host component for cases such as: https://github.com/ngneat/spectator/issues/539
this.detectChanges();
}
+
+ public deferBlock(deferBlockIndex = 0): DeferBlocks {
+ return this._deferBlocksForGivenFixture(deferBlockIndex, this.fixture.getDeferBlocks());
+ }
+
+ /**
+ *
+ * @param deferBlockFixtures Defer block fixture
+ * @returns deferBlock object with methods to access the defer blocks
+ */
+ private _deferBlocksForGivenFixture(deferBlockIndex = 0, deferBlockFixtures: Promise): DeferBlocks {
+ return {
+ renderComplete: async () => {
+ const renderedDeferFixture = await this._renderDeferStateAndGetFixture(
+ DeferBlockState.Complete,
+ deferBlockIndex,
+ deferBlockFixtures
+ );
+
+ return this._childrenDeferFixtures(renderedDeferFixture);
+ },
+ renderPlaceholder: async () => {
+ const renderedDeferFixture = await this._renderDeferStateAndGetFixture(
+ DeferBlockState.Placeholder,
+ deferBlockIndex,
+ deferBlockFixtures
+ );
+
+ return this._childrenDeferFixtures(renderedDeferFixture);
+ },
+ renderLoading: async () => {
+ const renderedDeferFixture = await this._renderDeferStateAndGetFixture(
+ DeferBlockState.Loading,
+ deferBlockIndex,
+ deferBlockFixtures
+ );
+
+ return this._childrenDeferFixtures(renderedDeferFixture);
+ },
+ renderError: async () => {
+ const renderedDeferFixture = await this._renderDeferStateAndGetFixture(DeferBlockState.Error, deferBlockIndex, deferBlockFixtures);
+
+ return this._childrenDeferFixtures(renderedDeferFixture);
+ },
+ };
+ }
+
+ /**
+ * Renders the given defer block state and returns the defer block fixture
+ *
+ * @param deferBlockState complete, placeholder, loading or error
+ * @param deferBlockIndex index of the defer block to render
+ * @param deferBlockFixtures Defer block fixture
+ * @returns Defer block fixture
+ */
+ private async _renderDeferStateAndGetFixture(
+ deferBlockState: DeferBlockState,
+ deferBlockIndex = 0,
+ deferBlockFixtures: Promise
+ ): Promise {
+ const deferFixture = (await deferBlockFixtures)[deferBlockIndex];
+
+ await deferFixture.render(deferBlockState);
+
+ return deferFixture;
+ }
+
+ /**
+ *
+ * @param deferFixture Defer block fixture
+ * @returns deferBlock object with methods to access the nested defer blocks
+ */
+ private _childrenDeferFixtures(deferFixture: DeferBlockFixture): NestedDeferBlocks {
+ return {
+ deferBlock: (deferBlockIndex = 0) => this._deferBlocksForGivenFixture(deferBlockIndex, deferFixture.getDeferBlocks()),
+ };
+ }
}
diff --git a/projects/spectator/src/lib/types.ts b/projects/spectator/src/lib/types.ts
index f074a11f..e83133d1 100644
--- a/projects/spectator/src/lib/types.ts
+++ b/projects/spectator/src/lib/types.ts
@@ -30,6 +30,17 @@ export type KeysMatching = { [K in keyof T]: T[K] extends V ? K : never }[
export type SelectOptions = string | string[] | HTMLOptionElement | HTMLOptionElement[];
+export type NestedDeferBlocks = {
+ deferBlock: (deferBlockIndex?: number) => DeferBlocks;
+};
+
+export interface DeferBlocks {
+ renderComplete(): Promise;
+ renderPlaceholder(): Promise;
+ renderLoading(): Promise;
+ renderError(): Promise;
+}
+
export interface KeyboardEventOptions {
key: string;
keyCode: number;
diff --git a/projects/spectator/test/defer-block.spec.ts b/projects/spectator/test/defer-block.spec.ts
new file mode 100644
index 00000000..2bd8bfd9
--- /dev/null
+++ b/projects/spectator/test/defer-block.spec.ts
@@ -0,0 +1,222 @@
+import { Component } from '@angular/core';
+import { DeferBlockBehavior, fakeAsync } from '@angular/core/testing';
+import { createComponentFactory } from '@ngneat/spectator';
+
+describe('DeferBlock', () => {
+ describe('Playthrough Behavior', () => {
+ @Component({
+ selector: 'app-root',
+ template: `
+ Toggle
+
+ @defer (when isVisible) {
+ empty defer block
+ } ,
+ `,
+ standalone: true,
+ })
+ class DummyComponent {
+ isVisible = false;
+ }
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Playthrough,
+ });
+
+ it('should render the defer block when isVisible is true', fakeAsync(() => {
+ // Arrange
+ const spectator = createComponent();
+
+ const button = spectator.query('[data-test="button--isVisible"]')!;
+
+ // Act
+ spectator.click(button);
+ spectator.tick();
+ spectator.detectChanges();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('empty defer block');
+ }));
+ });
+
+ describe('Manual Behavior', () => {
+ @Component({
+ selector: 'app-root',
+ template: `
+ @defer (on viewport) {
+ empty defer block
+ } @placeholder {
+ placeholder
+ }
+ `,
+ })
+ class DummyComponent {}
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Manual,
+ });
+
+ it('should render the complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('empty defer block');
+ });
+ });
+
+ describe('Manual Behavior with nested states', () => {
+ @Component({
+ selector: 'app-root',
+ template: `
+ @defer (on viewport) {
+ complete state #1
+
+
+ @defer {
+ complete state #1.1
+
+
+ @defer {
+ complete state #1.1.1
+ } @placeholder {
+ placeholder state #1.1.1
+ }
+
+
+
+ @defer {
+ complete state #1.1.2
+ } @placeholder {
+ placeholder state #1.1.2
+ }
+
+
+ } @placeholder {
+ nested placeholder text
+ } @loading {
+ nested loading text
+ } @error {
+ nested error text
+ }
+
+
+ } @placeholder {
+ placeholder state #1
+ } @loading {
+ loading state #1
+ } @error {
+ error state #1
+ }
+ `,
+ })
+ class DummyComponent {}
+
+ const createComponent = createComponentFactory({
+ component: DummyComponent,
+ deferBlockBehavior: DeferBlockBehavior.Manual,
+ });
+
+ it('should render the first nested complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ await parentCompleteState.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('complete state #1.1');
+ });
+
+ it('should render the first deep nested complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock().renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('complete state #1.1.1');
+ });
+
+ it('should render the first deep nested placeholder state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock().renderPlaceholder();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('placeholder state #1.1.1');
+ });
+
+ it('should render the second nested complete state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock(1).renderComplete();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('complete state #1.1.2');
+ });
+
+ it('should render the second nested placeholder state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ const parentCompleteState = await spectator.deferBlock().renderComplete();
+ const childrenCompleteState = await parentCompleteState.deferBlock().renderComplete();
+ await childrenCompleteState.deferBlock(1).renderPlaceholder();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('placeholder state #1.1.2');
+ });
+
+ it('should render the placeholder state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderPlaceholder();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('placeholder state #1');
+ });
+
+ it('should render the loading state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderLoading();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('loading state #1');
+ });
+
+ it('should render the error state', async () => {
+ // Arrange
+ const spectator = createComponent();
+
+ // Act
+ await spectator.deferBlock().renderError();
+
+ // Assert
+ expect(spectator.element.outerHTML).toContain('error state #1');
+ });
+ });
+});