Testing Defer Blocks in Angular with Cypress

Jordan Powell - Dec 21 '23 - - Dev Community

What is Defer anyways?

Deferrable views are one of the brand new exciting features that shipped as part of Angular 17 last month as part of the new "Angular Renaissance". This feature allows users to defer the loading of select dependencies within an angular component template.

This is important because it allows us to "defer" large components or sections of our code from the initial render of our application. This can improve your application Core Web Vital (CWV) results improving the initial load size and time to paint.

You can see a super trivial example of this in action below:

@defer() {
  <p>inside defer</p>
}
Enter fullscreen mode Exit fullscreen mode

The Background

Recently I came across this issue while triaging some issues at Cypress. (Shout out to MattiaMalandrone for creating an issue with clear instructions for how to reproduce). After quickly replicating the issue I sought after a solution which ultimately inspired me to write this article.

You can download and follow along using this example repo

Get Started

Let's assume we want to test the AppComponent which has the following html:

<p>outside defer</p>

@defer() {
<p>inside defer</p>
} @defer(on timer(1000ms)) {
<p>inside defer with condition</p>
}
Enter fullscreen mode Exit fullscreen mode

To begin we need to create a new file at src/app/app.component.cy.ts where we can write our first test.

import { AppComponent } from './app.component'

describe('AppComponent', () => {
  it('can mount', () => {
    cy.mount(AppComponent)
  })
})
Enter fullscreen mode Exit fullscreen mode

Initial App Component Mount

Though writing our first test was super simple you may notice that none of the blocks of code inside of our @defer() blocks are rendered in the DOM. Thankfully the Angular 17 shipped with some testing utilities that we can utilize to not only gain access to those deferred blocks but also to render them in the DOM.

Next we will add support for both accessing the array of defer blocks and rendering the block in the DOM. Let's open our cypress/support/component.ts file and add the following code:

import { DeferBlockFixture, DeferBlockState } from '@angular/core/testing'

import { MountResponse, mount } from 'cypress/angular';

declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount;
      defer(): Cypress.Chainable<DeferBlockFixture[]>;
      render(state: DeferBlockState): Cypress.Chainable<void>
    }
  }
}

type MountParams = Parameters<typeof mount>;

Cypress.Commands.add('mount', mount);
Cypress.Commands.add(
  'defer',
  { prevSubject: true },
  (subject: MountResponse<MountParams>) => {
    const { fixture } = subject;
    return cy.wrap(fixture.getDeferBlocks());
  }
);

Cypress.Commands.add(
  'render',
  { prevSubject: true },
  (subject: DeferBlockFixture, state: DeferBlockState) => {
    cy.wrap(subject.render(state));
  }
);
Enter fullscreen mode Exit fullscreen mode

Here we added 2 new Cypress Custom Commands defer and render which will allow us to test our blocks of code that use defer(). Now that we have our new Cypress global commands setup we can revisit our spec file to finish adding tests for the uncovered scenarios.

Let's first update our first test to validate that we see the outside defer by default and that we do NOT see the other 2 paragraphs wrapped inside the defer blocks.

it('can mount', () => {
    cy.mount(AppComponent);
    cy.contains('p', 'outside defer');
    cy.contains('p', 'inside defer').should('not.exist');
    cy.contains('p', 'inside defer with condition').should('not.exist');
  });
Enter fullscreen mode Exit fullscreen mode

Finished First Spec

Now let's add tests for the other 2 scenarios using our new custom commands.

it('renders inside the defer block', () => {
    cy.mount(AppComponent).defer().its(0).render(DeferBlockState.Complete);
    cy.contains('p', 'outside defer');
    cy.contains('p', 'inside defer');
  });
Enter fullscreen mode Exit fullscreen mode

In this example we can chain off our mount command and call our new defer command which returns an array of DeferBlockFixtures. We can then use .its to select a specific item from the array and call our second command render with the appropriate DeferBlockState we want to trigger. In our use-case we will want to use DeferBlockState.Complete.

You should now see both the "outside defer" paragraph and the "inside defer" paragraph in our test.

First 2 Specs

Now let's add a test for the second defer block that is tied to a timer.

it('renders inside the defer block with a condition', () => {
  cy.mount(AppComponent).defer().its(1).render(DeferBlockState.Complete);
    cy.contains('p', 'outside defer');
    cy.contains('p', 'inside defer with condition');
  });
Enter fullscreen mode Exit fullscreen mode

Notice the only real difference here is that we are grabbing the second item from the list of deferred views and running the checks on it.

First 3 Specs

Finally let's create one more command in our support file that will render all the defer blocks automatically so we don't have to manually trigger a render for each defer block.


...

declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount;
      defer(): Cypress.Chainable<DeferBlockFixture[]>;
      render(state: DeferBlockState): Cypress.Chainable<void>;
      renderAll(state: DeferBlockState): Cypress.Chainable<DeferBlockFixture[]>;
    }
  }
}

...

Cypress.Commands.add(
  'renderAll',
  { prevSubject: true },
  (subject: DeferBlockFixture[], state: DeferBlockState) => {
    subject.forEach((deferBlock: DeferBlockFixture) => {
      deferBlock.render(state);
    });

    cy.wrap(subject);
  }
);
Enter fullscreen mode Exit fullscreen mode

Now let's add a final test case which validates that all the content in our component is rendered including all defer blocks.

it('renders all defer blocks using renderAll()', () => {
    cy.mount(AppComponent).defer().renderAll(DeferBlockState.Complete);
    cy.contains('p', 'outside defer');
    cy.contains('p', 'inside defer');
    cy.contains('p', 'inside defer with condition');
  });
Enter fullscreen mode Exit fullscreen mode

Now we will see our final test validating that the content outside of the defer blocks AND BOTH defer blocks is rendered successfully in the DOM!

Final State

Conclusion

As you can see that testing defer with Cypress Component Testing is super simple with just a few simple commands. I am really just scratching the surface in this example but feel free to view the official documentation from Angular on how to test defer blocks for more details.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .