Shallow Component Tests in Cypress with Angular Standalone Components

Shallow Component Tests in Cypress with Angular Standalone Components

Dynamically Overriding and Mocking Standalone Component Imports

We're all familiar with the testing pyramid principle, advocating for an abundance of unit tests and only a handful of e2e tests. In frontend applications, the emphasis lies on numerous tests validating our component classes and services, with only a sparse selection of e2e tests directly engaging with the UI. However, a component is more than just its class. While class-only tests confirm behavior, they may fall short in assessing interactions with the DOM, and even fail to guarantee accurate rendering.

Angular 17 releases new built-in control flows transferring more logic to the component templates. Consequently, the urge to validate component templates and interactions is on the rise, potentially surpassing the significance of unit tests.

Fortunately, Cypress offers a solution in the form of "component tests" applicable to Angular, React, Vue, and Svelte. For the sake of simplicity, we will delve into Angular component tests in this article.

Cypress Component Tests

The objective of component tests is to validate and interact with the rendered component in the browser. Unlike conventional e2e tests, these tests operate in isolation and are thus "shallow", just like standard unit test.

To achieve this, certain providers and imports of the component need to be mocked. Cypress facilitates this through cy.mount with an optional MountConfig parameter. This MountConfig empowers the definition and override of component imports, providers, as well as input and output properties.

beforeEach(() => {
  cy.mount(MyComponent, {
      imports: [
          MyChildComponent, 
          RouterTestingModule,
          HttpClientTestingModule,
      ], 
      providers: [{ provide: MyService, useClass: MyMockService }]
  });
});

By leveraging the MountConfig, mocking providers and components becomes straightforward using your own MockClass. However, in the context of Angular standalone components, where imports and providers are integral part of the component itself, overriding child components via MountConfig encounters limitations.

Overriding Standalone Child Component

The easiest solution involves mocking components with third-party libraries like ng-mocks. Thereby, you just need to call the MockComponent function providing the child component to be mocked.

beforeEach(() => {
  cy.mount(MyComponent, {
      imports: [
          MockComponent(MyChildComponent),
          RouterTestingModule,
          HttpClientTestingModule,
      ], 
      providers: [{ provide: MyService, useClass: MyMockService }]
  });
});

Alternatively, a native approach is viable if third-party solutions are not an option in your project.

First, we need to understand that the cy.mount is essentially just a wrapper around Angular's TestBed.configureTestingModule. Secondly, full access to the TestBed is available within the Cypress component test. And lastly, TestBed.overrideComponent() already allows us to override imported components in standalone components.

The idea is to access TestBed and override the imported child components. Creating a custom Cypress commands to do so, e.g. cy.mockStandaloneChildComponents(...), simplifies mocking from within the test. The implementation of this command essentially wraps the overrideComponent function, requiring the standalone component under test, a list of imported components to mock, and the corresponding mock class.

declare global {
    namespace Cypress {
        interface Chainable {
            mockStandaloneChildComponents<T>(
                standaloneComponent: Type<T>,
                childComponentsToMock: any[] | Type<any>[]
            ): void;
        }
    }
}

function mockStandaloneChildComponents<T>(
    standaloneComponent: Type<T>,
    childComponentsToMock: any[] | Type<any>[]
): void {
    TestBed.overrideComponent(standaloneComponent, {
        remove: { imports: childComponentsToMock },
        add: {
            imports: childComponentsToMock.map(comp => {
                return createDynamicMockComponent(comp);
            }),
        },
    });
}

Cypress.Commands.add(
    'mockStandaloneChildComponents',
    mockStandaloneChildComponents
);

Dynamic Components Mocking with Standalone Components

We don't want to create mock classes within our tests for all child components that we're mocking. Instead, the list of child components to be mocked is passed to the custom override command, aiming for dynamic creation of component mocks. To that end, we create a new createDynamicMockComponent function.

We need to create a dummy component with the same selector and the same inputs as our source component. Reflection allows us to gather the component selector from the component's metadata. Extending the source component grants us access to the same inputs, without explicit declaration in our dummy component.

Now, we just have to plug everything together.

import { Component, reflectComponentType, Type } from '@angular/core';

export function createDynamicMockComponent<T>(component: Type<T>) {
  const componentMetadata = reflectComponentType(component);

  @Component({
    selector: componentMetadata?.selector,
    template:
      '<div class="mocked" [attr.data-cy]="cypressSelector">Mocked Component: {{mockedComponentName}}</div>',
    styles: [
      '.mocked { padding: 8px; margin: 16px; background-color: lightgrey }',
    ],
    standalone: true,
  })
  class DynamicMockComponent extends (component as any) {
    mockedComponentName = componentMetadata?.selector;
    cypressSelector = 'mocked-' + this.mockedComponentName;
  }

  return DynamicMockComponent;
}

Additionally, we provide a small template, aiding in visualizing the mocked component in rendered component tests. This proves beneficial when capturing screenshots and videos of test failures, and to better understand what is being tested and what is being mocked.

Additionally, a data-cy test selector enables precise targeting of the mocked component in tests for visibility assertions or input validation.

Summary

That's all. Now, we just need to call the mocking function before mounting the component.

beforeEach(() => {
    cy.mockStandaloneChildComponents(MyComponent, [MyChildComponent]);
    cy.mount(MyComponent, {
      imports: [
          RouterTestingModule,
          HttpClientTestingModule,
      ], 
      providers: [{ provide: MyService, useClass: MyMockService }]
    });
});

Through a combination of reflection, inheritance, and the capabilities of Angular's TestBed, we successfully mock child components in standalone component imports. The implementation of our custom mock command has significantly streamlined our component tests.