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
MyAwesomeComponentthat 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 ... });Several alterations could potentially break the test. To name a few:
- introducing a new module dependency within the
MyAwesomeComponent- the
MyAwesomeComponentstarting to rely on a new child component- a descendant (child or grandchild) of
MyAwesomeComponentincorporating a new component- the removal of a component that was previously referenced
- adding
MyAwesomeComponenttoMyAwesomeSharedModuleSingle 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 {}Standard Setup with
excludeComponentDeclarationRender 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
excludeComponentDeclarationoption.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 ... });Setting
excludeComponentDeclarationGloballyIf SCAMs are the standard approach, the
excludeComponentDeclarationproperty can be set globally through theconfiguremethod in the test setup file. This way, you can omit this option from each individualrendercall.import { configure } from '@testing-library/angular'; configure({ excludeComponentDeclaration: true, });it('renders the MyAwesomeComponent component', async () => { await render(MyAwesomeComponent, { imports: [MyAwesomeComponentModule], providers: [ { provide: EntityService, useValue: mock(EntityService), }, ], }); // ... the rest of the test here ... });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 theexcludeComponentDeclarationproperty 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 ... });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.
Single Component Angular Modules and Component Tests go hand in hand
Say no to brittle tests with SCAMs
