Angular - Viết unit test với Mock và Spy

Trước khi đào sâu vào chủ đề của bài viết, tôi sẽ giới thiệu qua về việc viết unit test trong Angular. Angular sử dụng JasmineKarma để viết và chạy test.

  • Jasmine là một javascript testing framework hỗ trợ BDD (Behavior Driven Development), nó cố gắng mô tả các tests trong một định dạng chúng ta có thể dễ dàng đọc hiểu, ngay cả đối với những người không am hiểu kĩ thuật cũng có thể hiểu những gì đang test ở đây.
  • Karma là một test runner, nó sinh ra browser và chạy các jasmine tests bên trong nó từ command line. Chúng ta có thể theo dõi kết quả ở browser được sinh ra hoặc ở command line. Karma có cơ chế theo dõi sự thay đổi của codes để tự động chạy lại test.

Mục tiêu của bài viết

  • Biết cách Mock với một fake class
  • Biết cách Mock bằng cách extend class và override hàm
  • Biết cách Mock sử dụng Spy

Sample Code

Ví dụ tôi có một LoginComponent, inject vào đó là AuthenticationService. Để đơn giản hóa việc test, ở đây tôi chỉ sử dụng AuthenticationService để kiểm tra người dùng đã được xác thực hay chưa, nếu chưa, LoginComponent sẽ hiển thị một nút Login để bắt người dùng phải đăng nhập trước khi muốn sử dụng trang web của tôi.

LoginComponent:

# login.component.ts
import { Component } from '@angular/core';
import { AuthenticationService } from './authentication.service';

@Component({
 selector: 'app-login',
 template: `<a [hidden]="isLoggedIn()">Login</a>`
})
export class LoginComponent {

 constructor(private authService: AuthenticationService) {
 }

 isLoggedIn() {
   return this.authService.isAuthenticated();
 }
}

AuthenticationService:

# authentication.service.ts
import { Injectable } from '@angular/core';

@Injectable()
export class AuthUserService {
  isAuthenticated(): boolean {
    return !!localStorage.getItem('token');
  }
}

Trên thực tế, sẽ không có một trang web nào chỉ kiểm tra sự xuất hiện của key token trong localStorage để kết luận được rằng người dùng này đã được xác thực hay chưa, nhưng vì mục đích đơn giản nên là chúng ta sẽ kiểm tra như vậy đi ^^.

Ở những phần tiếp theo, tôi sẽ đi vào giới thiệu một số cách để có thể test được LoginComponent kia nha.

Viết test với một AuthenticationService thực

Chúng ta có thể test LoginComponent bằng cách sử dụng một instance thực của AuthenticationService. Với cách này, chúng ta sẽ cần phải cài đặt một số dữ liệu lưu trong localStorage.

# login.component.spec.ts
import { LoginComponent } from './login.component';
import { AuthenticationService } from "./authentication.service";

describe('LoginComponent', () => {
  let component: LoginComponent;
  let service: AuthenticationService;

  beforeEach(() => { 
    service = new AuthenticationService();
    component = new LoginComponent(service);
  });

  afterEach(() => { 
    localStorage.removeItem('token');
  });

  it('isLoggedIn returns false when the user is not authenticated', () => {
    expect(component.isLoggedIn()).toBeFalsy();
  });

  it('isLoggedIn returns true when the user is authenticated', () => {
    localStorage.setItem('token', 'dG9rZW4='); 
    expect(component.isLoggedIn()).toBeTruthy();
  });
});

Cách này có vẻ không được ổn cho lắm, khi mà chúng ta cần phải biết rõ bên trong AuthenticationService hoạt động như thế nào. Thử tưởng tượng, nếu như LoginComponent inject thêm một vài service khác nữa, chúng ta cũng sẽ cần phải biết rõ cơ chế hoạt động của từng service đó.

Ngoài ra, ví dụ như AuthenticationService thay đổi cơ chế lưu trữ token, chẳng hạn lưu trữ trong sessionStorage hay cookies thay vì localStorage, khi đó test cho LoginComponent dĩ nhiên sẽ bị fail ở case isLoggedIn returns true when the user is authenticated bởi vì token đang được lưu ở trong localStorage mà.

Đây là lý do tại sao chúng ta nên viết test cho các class một cách cô lập, chúng ta chỉ cần quan tâm đến LoginComponent, không cần phải bận tâm đến những class phụ thuộc của nó khi viết test.

Chúng ta sẽ làm được điều đó bằng cách Mock các class phụ thuộc của LoginComponent. Mock là một hành động tạo ra một cái gì đó trông có vẻ giống với những class phụ thuộc, nhưng chúng ta có thể điều khiển kết quả của chúng trong test một cách dễ dàng. Sau đây là một số cách để tạo ra các mock

Mock với fake class

Trong cách này, chúng ta tạo một fake class của AuthenticationService có tên là MockAuthService. Trong MockAuthService chúng ta cũng tạo ra hàm isAuthenticated() giống như trong AuthenticationService, nhưng sẽ có thể điều khiển kết quả trả về của hàm đó dễ dàng hơn, mà không cần biết rõ xử lý thực sự bên trong của nó ra sao. Hãy xem đoạn codes ở dưới để rõ hơn nhé 😉

# login.component.spec.ts
import { LoginComponent } from './login.component';

class MockAuthService { 
 authenticated = false;

 isAuthenticated() {
   return this.authenticated;
 }
}

describe('Component: Login', () => {

 let component: LoginComponent;
 let service: MockAuthService;

 beforeEach(() => { 
   service = new MockAuthService();
   component = new LoginComponent(service);
 });

 it('isLoggedIn returns false when the user is not authenticated', () => {
   service.authenticated = false; 
   expect(component.isLoggedIn()).toBeFalsy();
 });

 it('isLoggedIn returns true when the user is authenticated', () => {
   service.authenticated = true; 
   expect(component.isLoggedIn()).toBeTruthy();
 });
});

Ta có thể thấy, kết quả trả về của hàm isAuthenticated() có thể dễ dàng được điều khiển bằng cách thay đổi giá trị của thuộc tính authenticated bên trong MockAuthService. Đồng thời, nếu như có những sự thay đổi ở bên trong hàm isAuthenticated của class AuthenticationService, các test của chúng ta vẫn sẽ luôn đúng ^^.

Mock bằng cách extend class và override hàm

Thỉnh thoảng, việc tạo ra một fake class hoàn chỉnh có thể sẽ trở nên phức tạp, mất thời gian khi class thực chứa nhiều hàm khác cũng cần phải có để có thể hoạt động được. Để khắc phục nhược điểm trên, chúng ta có thể tạo một class extend từ AuthenticationService và override lại hàm nào mà chúng ta cần sử dụng cho việc test trên:

class MockAuthService extends AuthenticationService {
 authenticated = false;

 isAuthenticated() {
   return this.authenticated;
 }
}

Class MockAuthService ở trên extend từ AuthenticationService. Nó có thể truy cập được vào tất cả các hàm và thuộc tính trong AuthenticationService, nhưng chỉ override lại hàm isAuthenticated thôi, vì vậy chúng ta có thể dễ dàng điều khiển được hành vi của nó và viết test một cách cô lập.

Mock sử dụng Spy

Spy là một tính năng của Jasmine framework, cho phép chúng ta mock giá trị trả về của các hàm trong một instance của class. Để mock giá trị trả về của isAuthenticated trong service nhận giá trị true, chúng ta sử dụng Spy như sau:

spyOn(service, 'isAuthenticated').and.returnValue(true);

Hãy viết lại đoạn test trên sử dụng Spy trên một instance thực của AuthenticationService xem thế nào nha:

# login.component.spec.ts
import { LoginComponent } from './login.component';
import { AuthenticationService } from "./authentication.service";

describe('LoginComponent', () => {
  let component: LoginComponent;
  let service: AuthenticationService;

  beforeEach(() => { 
    service = new AuthenticationService();
    component = new LoginComponent(service);
  });

  it('isLoggedIn returns false when the user is not authenticated', () => {
    spyOn(service, 'isAuthenticated').and.returnValue(false);
    expect(component.isLoggedIn()).toBeFalsy();
    expect(service.isAuthenticated).toHaveBeenCalled();
  });

  it('isLoggedIn returns true when the user is authenticated', () => {
    spyOn(service, 'isAuthenticated').and.returnValue(true);
    expect(component.isLoggedIn()).toBeTruthy();
    expect(service.isAuthenticated).toHaveBeenCalled();
  });
});

Kết luận

Trên đây tôi đã giới thiệu cho các bạn một số cách test trong Angular bằng cách tạo các Mock sử dụng fake class, extend một class hoặc sử dụng một instance thực của class với Spy. Việc tạo các Mock giúp chúng ta viết test cho các class một cách cô lập, dễ dàng hơn, không cần phải bận tâm đến cơ chế làm việc bên trong của các class phụ thuộc. Hi vọng sẽ giúp ích cho bạn đọc trong việc viết unit test trong các dự án Angular nhé ^^.

Tài liệu tham khảo

  1. https://angular.io/guide/testing
  2. https://codecraft.tv/courses/angular/unit-testing/mocks-and-spies/