Enhance Your Project with Angular 19 Download a free ebook!
10 Jun 2024
8 min

Learn How to Unit Test the Deferrable Views

The deferrable views (a.k.a @defer block) is a new Angular feature where we can declaratively lazy load a part of our HTML. In other words, we can select which part of our component tree should be in a different chunk and lazy load on a specific action. If you want to read more about the deferrable views, you should definitely read this one.

Before we dive into coding how to test the deferrable views, let's take a step back and understand how deferrable views can load content in general.

There are different ways to load a view and some of them require a user interaction, while others do not.

In the table below, you can see more details:

Q1: Why is it important to know if it requires a user interaction? 

A1: Well, if it requires a user interaction it is fairly straightforward how to write our unit test. We simply have to mimic that action and then assert.

Q2: How about the cases where we do not have a user interaction?

A2: One could say that we can mock the underlying implementation. After all, this is what we normally do while unit testing, right? However, it would be hard to mock the requestIdleCallback (the underlying implementation of the idle) and the IntersectionObserver (the underlying implementation of the viewport)

Q3: Is there a way to avoid mocking the requestIdleCallback and IntersectionObserver?

A3: Angular provides a test harness where we can explicitly load which part of the deferrable view needs to be rendered.

The Test Harness

With the term “Test Harness” we mean the testing API that helps test the components. It provides a way to interact and verify the behavior of the components in the testing environment. If you want to learn more about this, I encourage you to check this YouTube video from my colleague Mateusz Stefańczyk "Testing with component harnesses" – Mateusz Stefańczyk | #8 Angular Meetup

Speaking of the deferrable views, there are two behaviors to test them:

  1. Manual
    With this option we can select which defer block we need to be loaded and which state.
  2. Playthrough
    This is the default behavior and our unit test behaves like the code is being rendered in the browser.

We define the behavior in the configureTestingModule like so:

TestBed.configureTestingModule({
   deferBlockBehavior: DeferBlockBehavior.Manual,
});

Other than the behavior, we also have the option to select which state to render. The available states are depicted in the following enum file:

export declare enum ɵDeferBlockState {
    /** The placeholder block content is rendered */
    Placeholder = 0,
    /** The loading block content is rendered */
    Loading = 1,
    /** The main content block content is rendered */
    Complete = 2,
    /** The error block content is rendered */
    Error = 3
}

While Playthrough seems straightforward, we might need to understand the Manual a bit more.

With the Manual, we select which defer block to work with, and we also select the state we want to render. Let’s say, in the example below, that we need to render the second defer block

The required code would be:

// Get the second defer block fixture
const deferBlock = (await fixture.getDeferBlocks())[1]; //note the index number

// Render the complete state
await deferBlock.render(DeferBlockState.Complete);

Let’s now see what the states are:

If we need to render the loading state, the code will become:

// Get the second defer block fixture
const deferBlock = (await fixture.getDeferBlocks())[1]; //note the index number

// Render the loading state
await deferBlock.render(ɵDeferBlockState.Loading);

Since we now have a better understanding of the defer block testing tools, let’s get started and see how to unit test each individual trigger.

Testing Trigger Points

For each unit test, we will load this component:

Lazy Component:

@Component({
  selector: 'app-lazy',
  standalone: true,
  template: ` <p>lazy works!</p> `,
})
export class LazyComponent {}

When (Playthrough)

Because users need to do something to trigger the "when" feature, we'll use the Playthrough option. This is actually the default setting, so it’s ok not to define it in the configureTestingModule!

Let’s assume the component we need to test is the following:

Dummy Component for testing: 

@Component({
    selector: 'app-root',
    template: `
      <button data-test="button--isVisible" 
              (click)="isVisible = !isVisible">
        Toggle
      </button>

      @defer (when isVisible) {
        <app-lazy />
      }
      ,
    `,
    standalone: true,
    imports: [LazyComponent],
  })
  class DummyComponent {
    isVisible = false;
  }

To load the <app-lazy/>, we should first click the button, and then assert.

The unit test will be:

it('should render the defer block on button click, fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    const button = fixture.debugElement.query(
      By.css('[data-test="button--isVisible"]'),
    );

    // Act
    button.triggerEventHandler('click', null);
    fixture.detectChanges();
    tick();

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

on interaction – explicit (Playthrough)

Since the “on interaction” requires user interaction, we will use the Playthrough behavior. 

> With the “explicit interaction” I mean that, to load the deferrable view, we need to explicitly interact with an element.

Let’s assume the template we need to test is the following:

<button #toggleButton data-test="button--isVisible">Toggle</button>

@defer (on interaction(toggleButton)) {
    <app-lazy />
}

To load the <app-lazy/>, we should first click the button, and then assert.

The unit test will be:

it('should render the defer block on explicit interaction', fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    const button = fixture.debugElement.query(
      By.css('[data-test="button--isVisible"]'),
    );

    // Act
    button.nativeElement.click();
    fixture.detectChanges();
    tick();

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

Note: In this unit test we are not using the triggerEventHandler method here since the native element does not have an event handler on the click event 

on interaction – implicit (Playthrough)

Since the “on interaction” requires a user interaction, we will use the Playthrough behavior. 

> With the “implicit interaction” I mean that, to load the deferrable view, we need to interact with the deferrable view. 

Let’s assume the template we need to test is the following:

@defer (on interaction) {
    <app-lazy />
} @placeholder {
    <div data-test="el--placeholder">
        click here to load the complete state
    </div>
}

To load the <app-lazy/>, we should first interact (click) the placeholder, and then assert.

The unit test will be:

it('should render the defer block on implicit interaction', fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    const placeholderElement = fixture.debugElement.query(
      By.css('[data-test="el--placeholder"]'),
    );

    // Act
    placeholderElement.nativeElement.click();
    fixture.detectChanges();
    tick();

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

on timer (Playthrough)

The “on timer” doesn’t require user interaction, but since the underlying implementation is the setTimeout, we will use the Playthrough behavior. 

Let’s assume the template we need to test is the following:

@defer (on timer(1000)) {
  <app-lazy />
}

The unit test will be:

 it('should render the defer block on timer', fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    fixture.detectChanges();
    tick(1000);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

Note that in this unit test we wait (tick(1000)) for the same time as we have in timer(1000)

default (Manual)

By default, things happen when the system isn't busy (on idle). So, using @defer is the same as saying @defer (on idle).  This "on idle" stuff relies on requestIdleCallback which can be tricky to test on its own.  That's why we use the Manual behavior for testing.  Later, we'll learn how to render each different state.

Let’s assume the template we need to test is the following:

@defer {
  <app-lazy />
} @placeholder {
  <div>Placeholder text</div>
} @loading {
  <div>Loading text</div>
} @error {
  <div>Error text</div>
}

Load the placeholder state and unit test that:

  it('should render the defer block on idle - placeholder', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('Placeholder text');
  });

Note: in the case of the placeholder there is no need to specify the rendered state since the placeholder is being rendered by default.

Load the loading state and unit test that:

it('should render the defer block on idle - loading', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Loading);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('Loading text');
  });

Load the error state and unit test that:

it('should render the defer block on idle - error', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Error);
    
    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('Error text');
  });

Load the complete state and unit test that:

  it('should render the defer block on idle - complete', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  });

on viewport (Manual)

>  same with idle

The underlying implementation of the “on viewport” is the intersectionObserver API, which is hard to mock. That’s why we use the Manual behavior.

Let’s assume the template we need to test is the following:

@defer (on viewport) {
  <app-lazy />
} @placeholder {
  <div>on viewport the complete state will be loaded</div>
}

The unit test will be:

  it('should render the defer block on viewport', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  });

on immediate (Manual)

>  same with viewport

Let’s assume the template we need to test is the following:

@defer (on immediate) {
  <app-lazy />
}

The unit test will be:

 it('should render the defer block on immediate', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  });

on immediate with nested blocks

Let's break down testing these nested blocks step-by-step. Imagine the nested block lives inside another block, like a box within a box.

To test the inner box, we first need to build the outer box. Then, we can build the inner box itself.

In the picture, the "nested defer block" is tucked inside a bigger block. So, to test it, we need to build the whole big block first, and then build the little nested block inside.

Let’s assume the template we need to test is the following:

@defer (on immediate) {
  <app-lazy />

  @defer {
    <div>nested complete state</div>
  }
}

The unit test will be:

it('should render the nested defer block', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    const secondDeferBLock = (await firstDeferBlock.getDeferBlocks())[0];
    await secondDeferBLock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('nested complete state');
  });

Let’s break down the code a bit. We first render the outer defer block (firstDeferBlock), and then we are accessing all the inner defer blocks (firstDeferBlock.getDeferBlocks).

Conclusion

The way we test defer blocks in Angular is pretty good, thanks to a testing tool created by the Angular team. But there's a small catch. Right now, you need to tell the test exactly which defer block to check, by providing an index number. This can be a problem if you add more defer blocks later because your test might accidentally break.

The good news is that the Angular team is aware of this, and future updates will give us more ways to write defer block tests.

Thanks for reading my article! 🙂 

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.