Healthy Angular Component Testing
Unit testing has always been a recurrent topic in all the Angular conferences. Many amazing people have explained in great detail how to test components in different scenarios. The documentation itself has plenty of useful information with regard to that.
However nobody seems to realise that there is no consistency among so many proposals. Almost in all cases there are 2 or 3 ways of approaching testing development. That is kind of confusing and, by all means, not healthy.
So how do I become a healthier Angular component tester? Easy: just pick the best approach for each case. In other words: live by your own rules.
I usually do not talk in first person when I write articles but now I am going to do it in order to point out that these are solely the rules I live by. You can pick some or all of them to build up your own criteria and at the same time gain testing consistency in your team.
Do not use NO_ERRORS_SCHEMA
Using this const
is an easy way of doing shallow testing since it ignores unrecognised elements and attributes. But that is also the problem itself: the compiler will also not throw any errors about missing components and their attributes.
Instead of that, if you have nested components the first thing you should write are your stub components. Use those in your TestBed declarations and do not worry about the fact that they are not “real”. They will have their own unit test and we should exclusively care about the current component.
@Component({ selector: 'child-comp', template: '' })
class ChildStubComponent {
@Input() prop: string;
}
Provide your mocks
Generally there are 2 types of mocks:
Data mocks
Service mocks
Most likely the former will be used by the latter. But when should I mock a service? When not to provide the TestBed with the real one?
When the service performs any HTTP requests. In this case we must never use the real one since we do not want to execute undesired calls when testing the component. Besides that service will have his own unit test, too.
When the service is included in an already imported module (e.g. the CoreModule) but we do not want to remove that from the TestBed because we are also interested in other features from the same module. Then we need to use the
overrideComponent()
method. It will override the real service passed through the module and replace it with the mock.
Now, there are several ways of mocking a service. For instance you may be using Jest instead of Karma + Jasmine which is fine. I myself use it because I think it is better to do expect(fixture).toMatchSnapshot()
rather than expect(component).toBeDefined()
. You avoid some imprecisions with that assertion but you should not use Jest snapshot testing anywhere else in your specs.
Anyhow, even if you use Jest I do not recommend to use jest.fn()
for mocking services. I believe it is better and more clear to do it with a Typescript class:
const optionsMock = {
option1: 'option1',
option2: 'option2',
option3: 'option3'
};
export class ServiceMock {
options = optionsMock;
promiseMethod() {
return Promise.resolve();
}
observableMethod() {
return of([]);
}
voidMethod() {}
}
Do a consistent beforeEach
Create a single beforeEach
for both the sync and async part. To make it consistent you can join the two of them with the Promise then()
method.
And never call fixture.detectChanges()
on the beforeEach
, because if you do that you will never be able to test whatever might happen in the component constructor. So it it best to always write that line of code on every spec instead.
Be aware of the variable boilerplate
You start your suite by declaring but not initialising all variables that you need for your specs. The ones that refer to the component fixture are a common boilerplate:
let fixture: ComponentFixture<MyComponent>;
let component: MyComponent;
let debugEl: DebugElement;
let nativeEl: Element | HTMLElement;
Angular is a multi-platform framework. It gives us a way to use web methods that will function on whatever platform we deploy. You can see the debug element as a wrapper of the native element. This is the reason why we should always declare it. Also because we will use some methods that belong to it and not to the native one (e.g. debugEl.injector.get(MyService)
. And the fixture is necessary to get the rest of them in the first place.
I have actually created a class to get rid of this boilerplate. Check it out:
import { DebugElement } from '@angular/core';
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
export interface ComponentSuiteElements<H, N = any> {
host: ComponentTestingElement<H>;
nested?: ComponentTestingElement<N>;
}
export interface ComponentTestingElement<T> {
component: T;
debugEl: DebugElement;
nativeEl: Element | HTMLElement;
}
export class ComponentSuite<H, N = any> {
elements: ComponentSuiteElements<H, N>;
constructor(private fixture: ComponentFixture<H>, private selector?: string) {
this.setElements();
}
private getHost(): ComponentTestingElement<H> {
const component: H = this.fixture.componentInstance;
const debugEl: DebugElement = this.fixture.debugElement;
const nativeEl: Element | HTMLElement = debugEl.nativeElement;
return { component, debugEl, nativeEl };
}
private getIntegrationElements(): ComponentSuiteElements<H, N> {
const host: ComponentTestingElement<H> = this.getHost();
const nested: ComponentTestingElement<N> = this.getNested(host.debugEl);
return {
host,
nested
};
}
private getNested(hostDebugEl: DebugElement): ComponentTestingElement<N> {
const debugEl: DebugElement = hostDebugEl.query(By.css(this.selector));
const component: N = debugEl.componentInstance;
const nativeEl: Element | HTMLElement = debugEl.nativeElement;
return { component, debugEl, nativeEl };
}
private getShallowElements(): ComponentSuiteElements<H> {
return { host: this.getHost() };
}
private setElements(): void {
if (this.selector) {
this.elements = this.getIntegrationElements();
} else {
this.elements = this.getShallowElements();
}
}
}
This class is design to offer a solution for both (Angular) integration testing and shallow testing. A good example:
import { ComponentSuite, ComponentSuiteElements } from '@my-test-kit';
import { PortalModule, TemplatePortal } from '@angular/cdk/portal';
import {
AfterContentInit,
Component,
TemplateRef,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NestedComponent } from './nested.component';
@Component({
template: `
<ng-template>External Content</ng-template>
<nested [content]="content"></nested>
`
})
class HostComponent implements AfterContentInit {
@ViewChild(TemplateRef)
templateRef: TemplateRef<any>;
content: TemplatePortal;
constructor(private viewContainerRef: ViewContainerRef) {}
ngAfterContentInit() {
this.content = new TemplatePortal(this.templateRef, this.viewContainerRef);
}
}
describe('NestedComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let els: ComponentSuiteElements<HostComponent, NestedComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [PortalModule],
declarations: [HostComponent, NestedComponent]
}).compileComponents()
.then(() => {
fixture = TestBed.createComponent(HostComponent);
els = new ComponentSuite<HostComponent, NestedComponent>(
fixture,
'nested'
).elements;
});
}));
describe('basic case', () => {
it('should match snapshot', () => {
fixture.detectChanges();
expect(fixture).toMatchSnapshot();
});
it('should render content from external template', () => {
fixture.detectChanges();
const externalContent: TemplatePortal = els.host.component.content;
const internalContent: TemplatePortal = els.nested.component.content;
expect(externalContent).toBe(internalContent);
});
});
});
This way, through the created els
variable, you can access to all the 4 mentioned boilerplate variables of both the host component and the nested component.
Test the asynchrony
Depending on the case you can use:
Jasmine Marbles (or Jest Marbles if you are using Jest) for observables. A clear example would be an XHR to be expressed in our component.
Angular
async
+ ES2017await
for promises, if you use any.fakeAsync()
+tick()
for timers. This gives a more precise time control.
Conclusion
Make sure your tests are simple and meaningful. This ideas will make your testing more consistent and effective. Some of them can be argued and you are free use them or not, or to add additional ones. The most important thing is that you understand what you are doing and why you are doing it. This way you can communicate these practices to your team and agree on following them together.
Of course this criteria may evolve as time goes by. Who knows? Since the Angular ecosystem already provides stuff like a command line interface (Angular CLI), a component library (Angular Material), a build system (Bazel) or server side renderer (Angular Universal), we might be allowed to dream of an special Google tool that does not only cover unit testing but also e2e testing at the same time, since the assertions on both worlds are the almost same…
Food for thought!