Jordan Powell
Posted on December 21, 2023
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>
}
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>
}
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)
})
})
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));
}
);
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');
});
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');
});
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.
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');
});
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.
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);
}
);
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');
});
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!
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.
Posted on December 21, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.