Most applications leverage API requests to fetch data. Considering data fetching is crucial for Angular testing. Consequently, it's common to include HttpClient
in your unit tests.
Consider the following code:
/** /src/app/services/posts/posts.service.ts */
@Injectable({
providedIn: 'root'
})
export class PostsService {
constructor(private httpClient: HttpClient) {}
getPosts(): Observable<Post[]> {
return this.httpClient.get<Post[]>('<https://some-api.com/posts');
}
}
/** /src/app/components/posts/posts.component.ts */
@Component({/* ... */})
export class PostsComponent {
posts: Post[] = [];
hasPosts = false;
constructor(private postsService: PostsService) {}
getPosts(): void {
this.postsService.getPosts().subscribe(posts => {
this.posts = posts;
});
// π This if statement is questionable... π€
if (this.posts.length > 0) {
this.hasPosts = true;
}
}
}
The intention for getPosts()
is to fetch posts from some server, and the component renders a special message if no posts are available using hasPosts
.
But, there is a glaring issue with this code. The check for posts.length
is outside the subscribe callback and will always return 0
, and hasPosts
will always be false.
The fix is obvious, but thatβs not what this blog post highlights: shouldn't a simple problem like this be easily detected through unit testing? Let's find out.
Writing Your Unit Test
When writing tests involving HttpClient
, you have 2 main options:
-
mock
HttpClient
using HttpTestingController provided byHttpClientTestingModule
. -
mock the service that depends on
HttpClient
by stubbing methods that would normally make a request.
The latter option tends to be the common choice since it requires the least amount of code to set up and is a one-size-fits-all pattern you can use outside of HttpClient
-related implementations.
Let's set up your unit test:
/** /src/app/components/posts/posts.component.spec.ts */
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, Observable } from 'rxjs';
import { PostsComponent } from './posts.component';
import { Post, PostsService } from '../../services/posts.service';
// π A mock instance of PostService
const mockPostsService = {
// Instead of making an API request,
// always return an array of 2 Post objects
getPosts: (): Observable<Post[]> => of([
{ id: 1, title: 'first post'},
{ id: 2, title: 'second post' },
]),
} as PostsService;
describe('PostsComponent', () => {
let component: PostsComponent;
let fixture: ComponentFixture<PostsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PostsComponent],
providers: [
{
provide: PostsService,
// π Provide the mocked instance of PostsService
useValue: mockPostsService,
}
]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PostsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
});
You'll notice a mocked instance of PostsService
in your spec file and provided it using our TestBed. Now you can write your test:
/** /src/app/components/posts/posts.component.spec.ts */
describe("getPosts()", () => {
it('should set hasPosts to true if there are posts', () => {
component.getPosts();
expect(component.posts.length).toBe(2);
expect(component.hasPosts).toBe(true);
});
});
Here is the basic test that should catch the problem with the code. Let's run it:
1 spec, 0 failures
PostsComponent
getPosts()
β
should set hasPosts to true if there are posts
The test should have failed since hasPosts
shouldn't be set to true
correctly. Why does this broken Angular test pass? Is this a bug with Jasmine?
Observables Can Be Asynchronous & Synchronous
The root of this confusion revolves around the common misunderstanding about how scheduling works for RxJS Observables. Unlike Promises, Observables can be both asynchronous and synchronous.
To verify this behavior, consider this code snippet:
import { of } from 'rxjs';
setTimeout(() => {
console.log('(1) setTimeout');
});
Promise.resolve().then(() => {
console.log('(2) Promise');
});
of('(3) Observable').subscribe(value => console.log(value));
console.log('(4) Just console.log');
The order of the console logs are:
(3) Observable
(4) Just console.log
(2) Promise
(1) setTimeout
This is because the subscribe
callback from of()
uses synchronous scheduling by default. This results in (3) Observable
being logged immediately and not delayed like Promises
or setTimeout()
.
API requests are always asynchronous, so the mocked PostsService
should reflect this in its getPosts
method. Luckily, RxJS provides the scheduler utility to override the default scheduling of Observables:
/** /src/app/components/posts/posts.component.spec.ts */
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { asyncScheduler, scheduled, of, Observable } from 'rxjs';
// π Import asyncScheduler and scheduled from 'rxjs'
import { PostsComponent } from './posts.component';
import { Post, PostsService } from '../../services/posts.service';
// A mock instance of PostService
const mockPostsService = {
getPosts: (): Observable<Post[]> => scheduled(of([
{ id: 1, title: 'first post'},
{ id: 2, title: 'second post' },
]), asyncScheduler),
// π Specifying that we want our stream to be asynchronous
} as PostsService;
Now with this refactor, tests will properly catch the problem in the code.
1 spec, 1 failures
PostsComponent
getPosts()
β should set hasPosts to true if there are posts
Expected 0 to be 2.
Expected false to be true.
Conclusion
The moral of the story is to be cautious about how you implement mocks. Mocks can result in false positives in your unit tests when you aren't mindful of how scheduling works with your real implementation.
What do you think?
Are you following best practices when testing your Angular application? Want a second opinion? Join our Community Discord and share your feedback with us on the #Angular channel. Don't hesitate to schedule a free consultation call if you need any assistance. Our team is always here to support you.
Previous Post