How to make Angular component Test resilient and refactor friendly?
In this article, I will be discussing two strategies to make the Angular component test resilient and refactor friendly.
1. Don’t test component state (internal implementation) #
One of the main disadvantage of testing state is that it is very prone to breakage from future code refactor or new feature addition.
I am going to illustrate this with the help of the following code which is taken directly from the official angular website.
light-switch.component.ts
import { Component } from "@angular/core";
@Component({
selector: "lightswitch-comp",
template: `
<button type="button" (click)="clicked()">Click me!</button>
<span>{{ message }}</span>
`,
})
export class LightswitchComponent {
isOn = false;
clicked() {
this.isOn = !this.isOn;
}
get message() {
return `The light is ${this.isOn ? "On" : "Off"}`;
}
}
light-switch.component.spec.ts
import { LightswitchComponent } from "./light-switch.component";
describe("LightswitchComp", () => {
it("#clicked() should toggle #isOn", () => {
const comp = new LightswitchComponent();
expect(comp.isOn).withContext("off at first").toBe(false);
comp.clicked();
expect(comp.isOn).withContext("on after click").toBe(true);
comp.clicked();
expect(comp.isOn).withContext("off after second click").toBe(false);
});
it('#clicked() should set #message to "is on"', () => {
const comp = new LightswitchComponent();
expect(comp.message)
.withContext("off at first")
.toMatch(/is off/i);
comp.clicked();
expect(comp.message).withContext("on after clicked").toMatch(/is on/i);
});
});
There are few problems with this approach of testing a component.
- If
isOn
got changed totoggle
, it will break the test. - If
message
got changed totoggleInfo
, it will break the test.
light-switch.component.ts (modified version)
import { Component } from "@angular/core";
@Component({
selector: "lightswitch-comp",
template: `
<button type="button" (click)="clicked()">Click me!</button>
<span>{{ toggleInfo }}</span>
`,
})
export class LightswitchComponent {
toggle = false;
clicked() {
this.toggle = !this.toggle;
}
get toggleInfo() {
return `The light is ${this.toggle ? "On" : "Off"}`;
}
}
With the above changes, test will fail even though the feature itself is not broken.
Now, let me ask you few questions.
- Do end users really care about these changes?
- Will these changes impact how the end users see your component?
Note: End users are those who uses your app on the browser.
I guess the answer is NO.
Even though the actual behaviour of the component has not changed at all from the end user’s perspective, above changes on the LightswitchComponent.ts
will break the test. But it should only break if there are genuine issues such as user not able to see the correct message or toggle button not working as expected which users care about the most.
How to fix this ? #
Component should be tested against the DOM (Document Object Model) rather than the internal state.
This is the modified version of test for the same component.
light-switch.component.spec.ts (modified version)
mport { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { LightswitchComponent } from './light-switch.component';
describe('LightswitchComponent', () => {
let component: LightswitchComponent;
let fixture: ComponentFixture<LightswitchComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [LightswitchComponent],
}).compileComponents();
// creates an instance of the LightswitchComponent
// and adds a corresponding element to the test-runner DOM.
// this is equivalent to rendering a component
fixture = TestBed.createComponent(LightswitchComponent);
component = fixture.componentInstance;
});
it('#clicked() should set #message to "is on"', () => {
// selectors
const toggleButton = fixture.debugElement.query(By.css('button'));
const message = fixture.debugElement.query(By.css('span'));
// simulate button click
toggleButton.triggerEventHandler('click');
// binding happens when Angular performs change detection
fixture.detectChanges();
// assertion for toggle on scenario
expect(message.nativeElement.textContent)
.withContext('on after clicked')
.toMatch(/is on/i);
// simulate button click
toggleButton.triggerEventHandler('click');
// binding happens when Angular performs change detection
fixture.detectChanges();
// assertion for toggle off scenario
expect(message.nativeElement.textContent)
.withContext('off after clicked')
.toMatch(/is off/i);
});
});
In the above modified code, we did the following tasks sequentially.
- Render the LightswitchComponent component.
- Get the toggle button and message span html element using
By.css ()
method provided by angular. - Trigger the click event and run change detection to allow DOM binding.
- Write test assertion to make sure we get proper message when we click the button multiple times (toggle scenario).
With this change, component tests will not fail as a result of change in the internal state.
2. Use resilient CSS selectors #
In the above component DOM testing, we saw that we are using the following selectors:
// selectors
const toggleButton = fixture.debugElement.query(By.css("button"));
const message = fixture.debugElement.query(By.css("span"));
There are still few problems with the way we are traversing the DOM to find the desired selector.
- What will happen if we change
span
todiv
to display the message and apply the exact same styles of span ondiv
? ( This should not impact end user because message will still render in the same old way on the browser.) - What will happen if we add a new button just above the old button ?
light-switch.component.ts (modified version)
import { Component } from "@angular/core";
@Component({
selector: "lightswitch-comp",
template: `
<button type="button" (click)="clickedNewFeature()">
New Feature click me !
</button>
<button type="button" (click)="clicked()">Click me!</button>
<div>{{ message }}</div>
`,
})
export class LightswitchComponent {
isOn = false;
clicked() {
this.isOn = !this.isOn;
}
clickedNewFeature() {
// ...
}
get message() {
return `The light is ${this.isOn ? "On" : "Off"}`;
}
}
With either of above changes, component test will break.
Even if we try to use a class on the button as follows, the test will still fail.
Note:
primary-button
class has been added.
light-switch.component.ts (modified version)
template: `
<button class="primary-button" type="button" (click)="clickedNewFeature()">
New Feature click me !
</button>
<button class="primary-button" type="button" (click)="clicked()">Click me!</button>
<div>{{ message }}</div>
`,
light-switch.component.spec.ts (modified version)
// selectors
const toggleButton = fixture.debugElement.query(By.css(".primary-button"));
Note: Both By.css (".primary-button")
or By.css (button)
gives the first found button selector which is a new button that we recently added and not the old button.
We could have used a unique id or class on html elements, but these should be primarily used for styling purpose only. If we use them in component test, we are creating a tight coupling between the presentation layer and the test, and this is a seed for a brittle test.
How to fix this ? #
We need to look for some meaningful unique metadata on the element we’re trying to select in order to solve the problem.
I have added the following attributes.
- A
name
attribute on the button. - A
data-testid
attribute on the div which contains the message.
Note: We can always remove custom
data-*
attribute if we don’t it to be part of the production bundle by simply creating an angular directive.
light-switch.component.ts (modified version)
@Component({
selector: 'lightswitch-comp',
template: `
<button name="new" type="button" (click)="clickedNewFeature()">
New Feature click me !
</button>
<button name="toggle" type="button" (click)="clicked()">Click me!</button>
<div data-testid="message">{{ message }}</div>
`,
})
light-switch.component.spec.ts (modified version)
it('#clicked() should set #message to "is on"', () => {
// selectors
const toggleButton = fixture.debugElement.query(
By.css(`button[name='toggle']`)
);
const message = fixture.debugElement.query(
By.css(`[data-testid='message']`)
);
// simulate button click
toggleButton.triggerEventHandler("click");
// binding happens when Angular performs change detection
fixture.detectChanges();
expect(message.nativeElement.textContent)
.withContext("on after clicked")
.toMatch(/is on/i);
// simulate button click
toggleButton.triggerEventHandler("click");
// binding happens when Angular performs change detection
fixture.detectChanges();
expect(message.nativeElement.textContent)
.withContext("off after clicked")
.toMatch(/is off/i);
});
Everything will pass now 😄
Note: The only time the value of
name
attribute will change is when the product requirement changes. So, toggle will always be toggle and is not coupled with UI/UX in any way (separation of concern).
Furthermore, I think data-testid
attribute will also help in the E2E testing. But if you need to get rid of it, the following directive does the job.
data-testid.directive.ts
import { environment } from "./../environments/environment";
import { Directive, ElementRef, Renderer2 } from "@angular/core";
@Directive({
selector: "[data-testid]",
})
export class DataTestidDirective {
constructor(private el: ElementRef, private renderer: Renderer2) {
if (environment.production) {
this.renderer.removeAttribute(this.el.nativeElement, "data-testid");
}
}
}
Alternative solution to attributes #
Instead of using attributes such as name
, data-testid
and so on, another alternative would be to find the selector with the desired text content. In general, text content is unique in a component among all the elements contained within it. So, we can create some custom helper function for this as follows:
test.util.ts
function getSelectorByText(
node: HTMLElement,
text: string
): HTMLElement | undefined {
if (node?.innerHTML?.trim() === text) {
return node;
}
for (let i = 0; i < node.children.length; i++) {
const found = getSelectorByText(node.children[i] as HTMLElement, text);
if (found) {
return found;
}
}
return undefined;
}
Now, we can use the helper function on our test file as follows. Also, there are no data-testid
and name
attributes anymore.
light-switch.component.ts (modified version)
@Component({
selector: 'lightswitch-comp',
template: `
<button type="button" (click)="clickedNewFeature()">
New Feature click me !
</button>
<button type="button" (click)="clicked()">Click me!</button>
<div>{{ message }}</div>
`,
})
light-switch.component.spec.ts (modified version)
it('#clicked() should set #message to "is on"', () => {
const toggleButton = getSelectorByText(
fixture.debugElement.nativeElement,
"Click me!"
);
// simulate button click
toggleButton?.click();
// binding happens when Angular performs change detection
fixture.detectChanges();
const message = getSelectorByText(
fixture.debugElement.nativeElement,
"The light is On"
);
expect(message?.textContent)
.withContext("on after clicked")
.toMatch(/is on/i);
// simulate button click
toggleButton?.click();
// binding happens when Angular performs change detection
fixture.detectChanges();
expect(message?.textContent)
.withContext("off after clicked")
.toMatch(/is off/i);
});
Furthermore, we can also combine both data-testid
and getSelectorByText
approach together.
Why is text content better than id, class and element selectors? #
- The main reason is that text content does not change that often compared to selectors. Once we setup the mock data for tests, it doesn't change frequently. Don't you agree with me? 😄
- Tests using text content won't break with the change in class, id and element selectors. For example, in the below component, class name for button can change from
primary-button
tosecondary-button
due to the change in requirements (UI). If the class name is referenced in test, it will fail because a button withprimary-button
class does not exist anymore.
component before class
change
@Component({
selector: 'lightswitch-comp',
template: `
<button class="primary-button" type="button" (click)="clicked()">Click me!</button>
<span class="message">{{ toggleInfo }}</span>
`,
})
component after class
change
@Component({
selector: 'lightswitch-comp',
template: `
<button class="secondary-button" name="toggle" type="button" (click)="clicked()">Click me!</button>
<span class="message">{{ toggleInfo }}</span>
`,
})
Imagine if we have relied on the text content for button while writing tests, it will not break even with the change in class name. Which one is highly likely to change, class name or text content ?
Of course, there are situations when the text content also changes, but in that scenario, we also need to make the corresponding changes on the test. This is expected and the main goal is to make our test less brittle and resilient with minimal changes.
There are also situations where we need to use class, id or element selectors, but we need to discourage it's general use in tests.
Conclusion #
In this post we learned
- how to make Angular component test resilient and refactor friendly without relying on the internal state of a component.
- how to use resilient selectors using unique attributes and
getSelectorByText
method to select the html element instead of class, ids and HTML tags. - how to create a directive to remove the
data-testid
attribute from the prod environment bundle.