Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.


Crafting a reliable test is more nuanced than it appears; it demands both experience and a structured approach to get right. The arrange, or setup, phase is often what distinguishes a decent test from a truly great one. For Angular components, Single Component Angular Modules (SCAMs) can bridge that gap between an ordinary test and a high-quality one. Adopting SCAMs eliminates brittle test configurations that rely on half the codebase, fostering a team that is both content and productive, without hesitation in refactoring.

Reflecting on a past team I worked with, SCAMs would have spared us countless hours of frustration with our component tests. Even a seasoned developer would need several minutes to get the existing tests green again after most component modifications. Meanwhile, newcomers often found themselves staring at failing tests with no clue how to proceed. This was puzzling because in most cases, the component itself functioned correctly in the app. Although we eventually got used to the routine of fixing these tests, it remained a costly and somewhat inefficient process.

SCAMs offer a solution to this pain point, and when paired with the Angular Testing Library, writing component tests can even become enjoyable.

Let's examine a scenario. Can you identify any modifications to the MyAwesomeComponent that would cause its rendering to break, leading to a test failure?

it('renders the MyAwesomeComponent component', async () => {
    await render(MyAwesomeComponent, {
        imports: [
            MatButtonModule,
            MatDialogModule,
            MatInputModule,
            MatTableModule,
            MyAwesomeSharedModule,
        ],
        declarations: [MyAwesomeChildComponent, MyAwesomeGrandChildComponent],
        providers: [
            {
                provide: EntityService,
                useValue: mock(EntityService),
            },
        ],
    });

    // ... the rest of the test here ...
});
Enter fullscreen mode Exit fullscreen mode

Several alterations could potentially break the test. To name a few:

  • introducing a new module dependency within the MyAwesomeComponent
  • the MyAwesomeComponent starting to rely on a new child component
  • a descendant (child or grandchild) of MyAwesomeComponent incorporating a new component
  • the removal of a component that was previously referenced
  • adding MyAwesomeComponent to MyAwesomeSharedModule

Single Component Angular Modules as a Remedy

To shield tests from these internal shifts, SCAMs prove to be valuable. By following the SCAM pattern, any adjustment to the component or directive is contained within its own module. Since this module is brought directly into the test, the test's setup reflects those changes automatically, without any extra effort.

Without overcomplicating the definition of a SCAM, here's what the module for the component under test might look like.

For a deeper dive, refer to Single Component Angular Modules by Lars Gyrup Brink Nielsen.

@NgModule({
    declarations: [MyAwesomeComponent],
    exports: [MyAwesomeComponent],
    imports: [
        MatButtonModule,
        MatDialogModule,
        MatInputModule,
        MatTableModule,
        MyAwesomeSharedModule,
        MyAwesomeChildComponentModule,
        MyAwesomeGrandChildComponentModule,
    ],
})
export class MyAwesomeComponentModule {}
Enter fullscreen mode Exit fullscreen mode

Standard Setup with excludeComponentDeclaration

Render the component while importing the module where it is declared. To avoid the rendered component from being automatically included in the TestBed's declarations, set the excludeComponentDeclaration option.

it('renders the MyAwesomeComponent component', async () => {
    await render(MyAwesomeComponent, {
        excludeComponentDeclaration: true,
        imports: [MyAwesomeComponentModule],
        providers: [
            {
                provide: EntityService,
                useValue: mock(EntityService),
            },
        ],
    });

    // ... the rest of the test here ...
});
Enter fullscreen mode Exit fullscreen mode

Setting excludeComponentDeclaration Globally

If SCAMs are the standard approach, the excludeComponentDeclaration property can be set globally through the configure method in the test setup file. This way, you can omit this option from each individual render call.

import { configure } from '@testing-library/angular';

configure({
    excludeComponentDeclaration: true,
});
Enter fullscreen mode Exit fullscreen mode
it('renders the MyAwesomeComponent component', async () => {
    await render(MyAwesomeComponent, {
        imports: [MyAwesomeComponentModule],
        providers: [
            {
                provide: EntityService,
                useValue: mock(EntityService),
            },
        ],
    });

    // ... the rest of the test here ...
});
Enter fullscreen mode Exit fullscreen mode

Rendering with the Component's Template

Alternatively, instead of using the component's Type, you can pass its template string directly. This approach doesn't require the excludeComponentDeclaration property to be set.

it('renders the MyAwesomeComponent component', async () => {
    await render(`<my-awesome-component></my-awesome-component>`, {
        imports: [MyAwesomeComponentModule],
        providers: [
            {
                provide: EntityService,
                useValue: mock(EntityService),
            },
        ],
    });

    // ... the rest of the test here ...
});
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Although SCAMs come with a variety of advantages, the most compelling feature for me is the boost they provide to Angular component testing. In my own experience, working with SCAMs has been a pleasure and has made me significantly more productive.

Organizing your Angular building blocks as SCAMs removes the guesswork from keeping test dependencies up-to-date. Every production change to the component is automatically reflected in its test, so you stay aligned.

By adopting this practice, your tests remain robust against internal modifications, letting you dedicate all your energy to building new functionality.

Happy testing!


Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.