Angular Unit Testing Without Testbed
Not a member of Medium? Read this article for free here.
In this article, we gonna investigate my favorite style of unit testing in Angular. Nowadays, many devs use TestBed. Before continuing, let me share my opinion on why I don’t prefer this approach. I got familiar with this new approach in one of the new projects I joined. Initially, I was disappointed since it was not the famous approach I worked with 😕. Later, I realized how simple and not loose coupled it was so decided to apply it to my new personal projects 😎. In short here are some takeaways:
Testbed approach:
Advantages:
✅ Integration testing
✅ DOM testing
Disadvantages:
❌ Complicated and verbose
❌ Fragile
Non-Testbed approach
Advantages:
✅ Simple and neat
✅ Not fragile
Disadvantages:
❌ No integration testing
❌ No DOM testing
Now let’s continue with some more details:
❌ Testbed approach is complicated and verbose
TestBed needs more boilerplate code. For instance, let’s compare two different approaches:
Testbed version
describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent; beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents().then(() => {
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
});
}); it(`should have as title 'unit-testing-demo'`, () => {
expect(component.title).toEqual('unit-testing-demo');
});});
Without Testbed Version
Looks neat and elegant.
describe('AppComponent', () => {
let component: AppComponent; beforeEach(() => {
component = new AppComponent();
}); it('should create', () => {
expect(component).toBeTruthy();
});
});
❌ Testbed approach is fragile and time-consuming.
Assume you have written some unit tests for a component. When later you add additional components to other components it results in the breaking of current component unit tests. This is not ideal for me since unit testing of a particular component should not be affected by other components. Common approach to resolve this by importing whole module of components but still, I have faced unexpected errors such as NullInjectorError
which was difficult to debug.
Unit testing without Testbed
Now let’s continue what it looks like to test without testbed.
1) Unit Testing methods:
Let’s start with the simple. Assume you have a method like below:
public getPostsByTag(posts: IPost[], tag: string): IPost[] {
return posts.filter((post) => post.tags.includes(tag));
}
Test: ✍
import { AppComponent } from './app.component';
import { IPost } from './post.model';describe('AppComponent', () => {
let component: AppComponent;
let postsMock: IPost[]; beforeEach(() => {
postsMock = [
{ id: '1', tags: 'angular' },
{ id: '2', tags: 'html' },
{ id: '3', tags: 'angular' }
];
component = new AppComponent();
component.posts = postsMock;
}); describe('#getPostsByTag', () => {
it("should return correct posts", () => {
const tag = 'angular';
const expectedPosts: IPost[] = [postsMock[0], postsMock[2]]; expect(component.getPostsByTag(postsMock, tag)).toEqual(expectedPosts);
}) });});
1a) Testing methods calls:
Component:
export class AppComponent {
private posts?: IPost[];
private isLoading?: boolean;
public setPosts(posts: IPost[]) {
this.posts = posts;
this.setLoading(false);
} public setLoading(value: boolean): void {
this.isLoading = value;
}
}
Test:✍
describe('AppComponent', () => {
let component: AppComponent;
beforeEach(() => {
component = new AppComponent();
}); describe('#setPosts', () => {
it("should set loading", () => {
spyOn(component, 'setLoading'); component.setPosts(POSTS_LIST_MOCK); expect(component.setLoading).toHaveBeenCalledWith(false);
})
});});
2) Unit Testing Output events:
Example component:
export class AppComponent {
@Output() readonly addPost = new EventEmitter<void>();
public onAddPost(): void {
this.addPost.next();
}
}
Test:✍
Here we spy on Output event via spyOn(component.addPost, 'next');
describe('AppComponent', () => {
let component: AppComponent;
beforeEach(() => {
component = new AppComponent();
spyOn(component.addPost, 'next');
}); describe('#onAddPost', () => {
it("should emit output event", () => {
component.onAddPost(); expect(component.addPost.next).toHaveBeenCalled();
});
});});
3) Unit Testing Services calls:
Component:
export class AppComponent {
private posts?: IPost[];
constructor(private readonly postsService: PostsService) { } public fetchPosts(): void {
this.postsService.fetchPosts()
.pipe(
catchError(() => EMPTY)
)
.subscribe(res => {
this.posts = res;
})
} public getPosts(): IPost[] | undefined {
return this.posts;
}}
You can either create separate service mocks for services or use below ready method to auto-spy service methods. For more information, you can also visit this Indepth Blog or use 3rth party spies generator libs such as auto-spies .
export type SpyOf<T> = T &
{
[k in keyof T]: T[k] extends Function ? jasmine.Spy : never;
};export function autoSpy<T>(obj: new (...args: any[]) => T): SpyOf<T> {
const res: SpyOf<T> = {} as any; const keys: any[] = Object.getOwnPropertyNames(obj.prototype); keys.forEach((key) => {
// @ts-ignore
res[key] = jasmine.createSpy(key);
}); return res;
}
Test:✍
describe('AppComponent', () => {
let component: AppComponent;
let postServiceMock: SpyOf<PostsService>;
beforeEach(() => {
postServiceMock = autoSpy(PostsService);
postServiceMock.getPosts.and.returnValue(EMPTY); component = new AppComponent(postServiceMock);
}); describe('#fetchPosts', () => {
it("should fetch posts", () => {
component.fetchPosts(); expect(postServiceMock.getPosts).toHaveBeenCalled();
});
});});
Note: Since spyies do not return observable we explicitly return observable via postServiceMock.getPosts.and.returnValue(EMPTY) which is used to prevent erorr on subscribe.
3a) Testing service methods with conditions:
TEST:✍
describe('AppComponent', () => {
let component: AppComponent;
let postServiceMock: SpyOf<PostsService>; beforeEach(() => {
postServiceMock = autoSpy(PostsService);
postServiceMock.fetchPosts.and.returnValue(EMPTY); component = new AppComponent(postServiceMock);
}); describe('#fetchPosts', () => { describe('and fetch posts success', () => {
beforeEach(() => {
postServiceMock.fetchPosts.and.returnValue(of(POSTS_LIST_MOCK));
}); it("should set posts", () => {
component.fetchPosts(); expect(component.getPosts()).toEqual(POSTS_LIST_MOCK);
});
}); describe('and fetch posts failure', () => {
beforeEach(() => {
postServiceMock.fetchPosts.
and.returnValue(throwError(() => new Error('some-error')));
}); it("should NOT set posts", () => {
component.fetchPosts(); expect(component.getPosts()).toBeUndefined();
});
});
});});
3b) Testing services with variables:
Component:
export class AppComponent implements OnInit {
public postId?: number;constructor(
private readonly postsService: PostsService,
private readonly activatedRoute: ActivatedRoute,
) { } public ngOnInit(): void {
this.setPostId();
} private setPostId(): void {
this.postId = this.activatedRoute.snapshot.params['id'];
}}
Test:✍
describe('AppComponent', () => {
let component: AppComponent;
let postServiceMock: SpyOf<PostsService>;
let activatedRouteMock: SpyOf<ActivatedRoute>; beforeEach(() => {
activatedRouteMock = {
snapshot: {
params: { id: 10 }
}
} as any; postServiceMock = autoSpy(PostsService);
postServiceMock.fetchPosts.and.returnValue(EMPTY); component = new AppComponent(postServiceMock, activatedRouteMock);
}); describe('#ngOninit', () => {
it('should set post id', () => {
component.ngOnInit(); expect(component.postId).toEqual(10);
});
});
});
3c) Testing service events (observables):
Component:
export class AppComponent implements OnInit {
private posts?: IPost[]; constructor(
private readonly postsService: PostsService,
private readonly activatedRoute: ActivatedRoute,
) { } public ngOnInit(): void {
this.listenToRouteIdChange();
} private listenToRouteIdChange(): void {
this.activatedRoute.params.subscribe(res => {
this.fetchPosts();
})
} public fetchPosts(): void {
this.postsService.fetchPosts()
.pipe(
catchError(() => EMPTY)
)
.subscribe(res => {
this.posts = res;
})
}}
Test: ✍
Here we just assign new Subject
observable to method of service which returns observable to have a control for manual triggering events.
describe('AppComponent', () => {
let component: AppComponent;
let postServiceMock: SpyOf<PostsService>;
let activatedRouteMock: SpyOf<ActivatedRoute>;
let paramsChange$: Subject<any>;
beforeEach(() => {
paramsChange$ = new Subject(); activatedRouteMock = {
params: paramsChange$,
snapshot: {
params: { id: 10 }
}
} as any; postServiceMock = autoSpy(PostsService);
postServiceMock.fetchPosts.and.returnValue(EMPTY); component = new AppComponent(postServiceMock, activatedRouteMock);
}); describe('#ngOninit', () => {
it('and route id changed', () => {
component.ngOnInit();
spyOn(component, 'fetchPosts'); paramsChange$.next(10); // trigger event
expect(component.fetchPosts).toHaveBeenCalled();
});
});
});
4) Testing NgRx/store
Instead of injecting services with observable emitters such as NgRx select, you should inject services that use NgRx. For example,
export class BrowseVideosComponent implements OnInit { constructor(
private accountStore: AccountStoreService,
) {}
Then test it as in section 3c by assigning subjects to methods.
When Not to use it? 🤔
Without Testbed we can neither write integration tests nor DOM testing. If you have a business requirement for integration testing then this approach may not suit you. I personally favor unit testing + end-to-end testing (either cypress or automation tests by QA engineer). If you do not have a such requirement you can try this approach with the benefits of performance and ergonomic style.
That’s it, hope you found it useful.