如何對 Service 單元測試 ?

栏目: Java · 发布时间: 8年前

内容简介:如何對 Service 單元測試 ?

凡與顯示相關邏輯,我們會寫在 component;凡與資料相關邏輯,我們會寫在 service。而 service 最常見的應用,就是透過 HttpClient 存取 API。

對於 service 單元測試而言,我們必須對 HttpClient 加以隔離;而對 component 單元測試而言,我們必須對 service 加以隔離,我們該如何對 service 與 component 進行單元測試呢 ?

Version

Angular CLI 1.6.2

Node.js 8.9.4

Angular 5.2.2

User Story

如何對 Service 單元測試 ?

  • Header 會顯示 Welcome to app!
  • 程式一開始會顯示 所有 post
  • Add Post 會呼叫 POST API 新增 post
  • List Posts 會呼叫 GET API 回傳 所有 post

Task

  • 目前有 PostService 使用 HttpClient 存取 API,為了對 PostService單元測試 ,必須對 HttpClient 加以隔離
  • 目前有 AppComponent 使用 PostService , 為了對 AppComponent單元測試 ,必須對 PostService 加以隔離

Architecture

如何對 Service 單元測試 ?

  • AppComponent 負責 新增 post顯示 post 的介面顯示;而 PostService 負責 API 的串接
  • 根據 依賴反轉原則AppComponent 不應該直接相依於 PostService ,而是兩者相依於 interface
  • 根據 介面隔離原則AppComponent 只相依於它所需要的 interface,因此以 AppComponent 的角度訂出 PostInterface ,且 PostService 必須實作此 interface
  • 因為 AppComponentPostService 都相依於 PostInterface ,兩者都只知道 PostInterface 而已,而不知道彼此,因此 AppComponentPostService 徹底解耦合
  • 透過 DI 將實作 PostInterfacePostService 注入到 AppComponent ,且將 HttpClient 注入到 PostService

Implementation

AppComponentPostService 的實作並非本文的重點,本文的重點在於實作 AppComponentPostService單元測試 部分。

PostService

如何對 Service 單元測試 ?

post.service.spec.ts

import { TestBed } from '@angular/core/testing';
import { PostService } from './post.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { PostInterfaceToken } from '../interface/injection.token';
import { Post } from '../../model/post.model';
import { environment } from '../../environments/environment';
import { PostInterface } from '../interface/post.interface';

describe('PostService', () => {
  let postService: PostInterface;
  let mockHttpClient: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule
      ],
      providers: [
        {provide: PostInterfaceToken, useClass: PostService}
      ]
    });

    postService = TestBed.get(PostInterfaceToken, PostService);
    mockHttpClient = TestBed.get(HttpTestingController);
  });

  it('should be created', () => {
    expect(PostService).toBeTruthy();
  });

  it(`should list all posts`, () => {
    /** act */
    const expected: Post[] = [
      {
        id: 1,
        title: 'Design Pattern',
        author: 'Dr. Eric Gamma'
      }
    ];

    postService.listPosts$().subscribe(posts => {
      /** assert */
      expect(posts).toEqual(expected);
    });

    /** arrange */
    const mockResponse: Post[] = [
      {
        id: 1,
        title: 'Design Pattern',
        author: 'Eric Gamma'
      }
    ];

    mockHttpClient.expectOne({
      url: `${environment.apiServer}/posts`,
      method: 'GET'
    }).flush(mockResponse);
  });

  it(`should add post`, () => {
    /** act */
    const expected: Post = {
      id: 1,
      title: 'OOP',
      author: 'Sam'
    };

    postService.addPost(expected).subscribe(post => {
      /** assert */
      expect(post).toBe(expected);
    });

    /** arrange */
    mockHttpClient.expectOne({
      url: `${environment.apiServer}/posts`,
      method: 'POST'
    }).flush(expected);
  });

  afterEach(() => {
    mockHttpClient.verify();
  });
});

14 行

TestBed.configureTestingModule({
  imports: [
    HttpClientTestingModule
  ],
  providers: [
    {provide: PostInterfaceToken, useClass: PostService}
  ]
});

Angular 有 module 觀念,若使用到了其他 module,必須在 imports 設定;若使用到 DI,則必須在 providers 設定。

若只有一個 module,則在 AppModule 設定。

但是單元測試時,並沒有使用 AppModule 的設定,因為我們可能在測試時使用其他替代 module,也可能自己實作 fake 另外 DI。

Angular 提供了 TestBed.configureTestingModule() ,讓我們另外設定跑測試時的 importsproviders 部分。

15 行

imports: [
  HttpClientTestingModule
],

原本 HttpClient 使用的是 HttpClientModule ,這會使得 HttpClient 真的透過網路去打 API,這就不符合單元測試 隔離 的要求,因此 Angular 另外提供 HttpClientTestingModule 取代 HttpClientModule

18 行

providers: [
  {provide: PostInterfaceToken, useClass: PostService}
]

由於我們要測試的就是 PostService ,因此 PostService 也必須由 DI 幫我們建立。

但因爲 PostService 是基於 PostInterface 建立,因此必須透過 PostInterfaceToken mapping 到 PostService

23 行

postService = TestBed.get(PostInterfaceToken, PostService);
mockHttpClient = TestBed.get(HttpTestingController);

providers 設定好 interface 與 class 的 mapping 關係後,我們必須透過 DI 建立 postServicemockHttpClient

其中 HttpTestingController 相當於 mock 版的 HttpClient ,因此取名為 mockHttpClient

TestBed.get() 其實相當於 new ,只是這是藉由 DI 幫我們 new 而已

31 行

it(`should list all posts`, () => {
  /** act */
  const expected: Post[] = [
    {
      id: 1,
      title: 'Design Pattern',
      author: 'Dr. Eric Gamma'
    }
  ];

  postService.listPosts$().subscribe(posts => {
    /** assert */
    expect(posts).toEqual(expected);
  });

  /** arrange */
  const mockResponse: Post[] = [
    {
      id: 1,
      title: 'Design Pattern',
      author: 'Eric Gamma'
    }
  ];

  mockHttpClient.expectOne({
    url: `${environment.apiServer}/posts`,
    method: 'GET'
  }).flush(mockResponse);
});

先談談如何測試 GET

3A 原則的 arrange 習慣都會寫在最前面,但在 service 的單元測試時, arrange 必須寫在最後面,否則會執行錯誤,稍後會解釋原因。

32 行

/** act */
const expected: Post[] = [
  {
    id: 1,
    title: 'Design Pattern',
    author: 'Dr. Eric Gamma'
  }
];

postService.listPosts$().subscribe(posts => {
  /** assert */
  expect(posts).toEqual(expected);
});

直接對 PostService.listPost$() 測試,由於 listPost$() 回傳 Observable ,因此 expect() 必須寫在 subscribe() 內。

將預期的測試結果寫在 expected 內。

一般 Observable 會在 subscribe() 後執行,不過在 HttpTestingController 的設計裡, subscribe() 會在 flush() 才執行,稍後會看到 flush() ,所以此時並還沒有執行 expect() 測試

46 行

/** arrange */
const mockResponse: Post[] = [
  {
    id: 1,
    title: 'Design Pattern',
    author: 'Eric Gamma'
  }
];

mockHttpClient.expectOne({
  url: `${environment.apiServer}/posts`,
  method: 'GET'
}).flush(mockResponse);

之前已經使用 HttpClientTestingModule 取代 HttpClientHttpTestingController 取代 HttpClient ,這只能確保呼叫 API 時不用透過網路。

還要透過 expectOne() 設定要 mock 的 URI 與 action,之所以取名為 expectOne() ,就是期望有人真的呼叫這個 URI 一次 ,且必須為 GET ,若沒有呼叫這個 URI 或者不是 GET ,將造成單元測試 紅燈

這也是為什麼 HttpTestingController 的設計是 actassert 要先寫,最後再寫 arrange ,因為 HttpTestingController 本身也有 assert 功能,必須有 act ,才能知道 assert URI 與 GET 有沒有錯誤。

最後使用 flush() 設定 mock 的回傳值, flush 英文就是 沖水 ,當 HttpTestingControllermockResponse 沖出去後,才會執行 subscribe() 內的 expect() 測試。

也就是說若你忘了寫 flush() ,其實單元測試也會 綠燈 ,但此時的綠燈並不是真的測試通過,而是根本沒有執行到 subscribe() 內的 expect()

81 行

afterEach(() => {
  mockHttpClient.verify();
});

實務上可能真的忘了寫 expectOne()flush() ,導致 subscribe() 內的 expect() 根本沒跑到而造成單元測試 綠燈 ,因此必須在每個單元測試跑完補上 mockHttpClient.verify() ,若有任何 API request 卻沒有經過 expectOne()flush() 測試,則 verify() 會造成單元測試 紅燈 ,藉以彌補忘了寫 expectOne()flush() 的人為錯誤。

Q : 我們在 listPosts$() 的單元測試到底測試了什麼 ?

  • 若 service 的 API 與 mock 不同,會出現單元測試 紅燈 ,可能是 service 的 API 錯誤
  • 若 service 的 action 與 mock 不同,會出現單元測試 紅燈 ,可能是 service 的 action 錯誤
  • 若 service 的 response 與 expected 不同,可能是 service 的邏輯錯誤

61 行

it(`should add post`, () => {
    /** act */
    const expected: Post = {
      id: 1,
      title: 'OOP',
      author: 'Sam'
    };

    postService.addPost(expected).subscribe(post => {
      /** assert */
      expect(post).toBe(expected);
    });

    /** arrange */
    mockHttpClient.expectOne({
      url: `${environment.apiServer}/posts`,
      method: 'POST'
    }).flush(expected);
  });

再來談談如何測試 POST

62 行

/** act */
const expected: Post = {
  id: 1,
  title: 'OOP',
  author: 'Sam'
};

postService.addPost(expected).subscribe(post => {
  /** assert */
  expect(post).toBe(expected);
});

直接對 PostService.addPost() 測試,由於 addPost() 回傳 Observable ,因此 expect() 必須寫在 subscribe() 內。

將預期的測試結果寫在 expected 內。

74 行

/** arrange */
mockHttpClient.expectOne({
  url: `${environment.apiServer}/posts`,
  method: 'POST'
}).flush(expected);

因為要 mock POST ,因此 method 部分改為 POST ,其他部分與 GET 部分完全相同。

Q : 我們在 addPost() 到底測試了什麼 ?

  • 若 service 的 API 與 mock 不同,會出現單元測試 紅燈 ,可能是 service 的 API 錯誤
  • 若 service 的 action 與 mock 不同,會出現單元測試 紅燈 ,可能是 service 的 action 錯誤
  • 若 service 的 response 與 expected 不同,可能是 service 的邏輯錯誤

如何對 Service 單元測試 ?

使用 Wallaby.js 通過所有 service 單元測試。

AppComponent

如何對 Service 單元測試 ?

app.component.spec.ts

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { FormsModule } from '@angular/forms';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { PostService } from './service/post.service';
import { PostInterfaceToken } from './interface/injection.token';
import { DebugElement } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { PostInterface } from './interface/post.interface';

describe('AppComponent', () => {
  let fixture: ComponentFixture<AppComponent>;
  let appComponent: AppComponent;
  let debugElement: DebugElement;
  let htmlElement: HTMLElement;
  let postService: PostInterface;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
      imports: [
        FormsModule,
        HttpClientTestingModule
      ],
      providers: [
        {provide: PostInterfaceToken, useClass: PostService}
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    appComponent = fixture.componentInstance;
    debugElement = fixture.debugElement;
    htmlElement = debugElement.nativeElement;
    fixture.detectChanges();

    postService = TestBed.get(PostInterfaceToken, PostService);
  }));
  
  it('should create the app', async(() => {
    expect(appComponent).toBeTruthy();
  }));

  it(`should have as title 'app'`, async(() => {
    expect(appComponent.title).toEqual('app');
  }));

  it('should render title in a h1 tag', async(() => {
    expect(htmlElement.querySelector('h1').textContent).toContain('Welcome to app!');
  }));

  it(`should list posts`, () => {
    const expected$ = Observable.of([
      {
        id: 1,
        title: 'Design Pattern',
        author: 'Dr. Eric Gamma'
      }
    ]);

    /** arrange */
    spyOn(postService, 'listPosts$').and.returnValue(expected$);
    /** act */
    appComponent.onListPostsClick();
    /** assert */
    expect(appComponent.posts$).toEqual(expected$);
  });

  it(`should add post`, () => {
    const expected$ = Observable.of(
      {
        id: 1,
        title: 'Design Pattern',
        author: 'Dr. Eric Gamma'
      }
    );

    /** arrange */
    const spy = spyOn(postService, 'addPost').and.returnValue(expected$);
    /** act */
    appComponent.onAddPostClick();
    /** assert */
    expect(spy).toHaveBeenCalled();
  });
});

20 行

TestBed.configureTestingModule({
  declarations: [
    AppComponent
  ],
  imports: [
    FormsModule,
    HttpClientTestingModule
  ],
  providers: [
    {provide: PostInterfaceToken, useClass: PostService}
  ]
}).compileComponents();

跑 component 單元測試時,一樣沒有使用 AppModule 的設定,因為我們可能在測試時使用其他替代 module,也可能自己實作 fake 另外 DI。

因此一樣使用 TestBed.configureTestingModule() ,讓我們另外設定跑測試時的 importsproviders 部分。

24 行

imports: [
  FormsModule,
  HttpClientTestingModule
],

因為在 component 使用了 two-way binding,因此要加上 FormsModule

Q : 為什麼要 import HttpClientTestingModule 呢 ?

AppComponent 依賴的是 PostService ,看起來與 HttpClient 無關,應該不需要注入 HttpClientTestingModule

但其實 DI 並不是這樣運作,雖然 AppComponent 只用到了 PostService ,但 DI 會將 PostService 下所有的 dependency 都一起注入,所以也要 import HttpClientTestingModule

28 行

providers: [
  {provide: PostInterfaceToken, useClass: PostService}
]

由於我們要測試的就是 PostService ,因此 PostService 也必須由 DI 幫我們建立。

但因爲 PostService 是基於 PostInterface 建立,因此必須透過 PostInterfaceToken mapping 到 PostService

39 行

postService = TestBed.get(PostInterfaceToken, PostService);

providers 設定好 interface 與 class 的 mapping 關係後,我們必須透過 DI 建立 postService 與。

53 行

it(`should list posts`, () => {
  const expected$ = Observable.of([
    {
      id: 1,
      title: 'Design Pattern',
      author: 'Dr. Eric Gamma'
    }
  ]);

  /** arrange */
  spyOn(postService, 'listPosts$').and.returnValue(expected$);
  /** act */
  appComponent.onListPostsClick();
  /** assert */
  expect(appComponent.posts$).toEqual(expected$);
});

55 行

const expected$ = Observable.of([
  {
    id: 1,
    title: 'Design Pattern',
    author: 'Dr. Eric Gamma'
  }
]);

透過 Observable.of()Post[] 轉成 Observable<Post[]>

63 行

/** arrange */
spyOn(postService, 'listPosts$').and.returnValue(expected$);

由於我們要隔離 PostService ,因此使用 spyOn()PostServicelistPost$() 加以 mock,並設定其假的回傳值。

65 行

/** act */
appComponent.onListPostsClick();

實際測試 onListPostClick()

67 行

/** assert */
expect(appComponent.posts$).toEqual(expected$);

測試 AppComponent.post$ 是否如預期。

Q : 我們在 onListPostsClick() 到底測試了什麼 ?

  • 若 component 的 return 與 expected 不同,可能是 component 的邏輯錯誤

70 行

it(`should add post`, () => {
  const expected$ = Observable.of(
    {
      id: 1,
      title: 'Design Pattern',
      author: 'Dr. Eric Gamma'
    }
  );

  /** arrange */
  const spy = spyOn(postService, 'addPost').and.returnValue(expected$);
  /** act */
  appComponent.onAddPostClick();
  /** assert */
  expect(spy).toHaveBeenCalled();
});

72 行

const expected$ = Observable.of(
  {
    id: 1,
    title: 'Design Pattern',
    author: 'Dr. Eric Gamma'
  }
);

透過 Observable.of()Post 轉成 Observable<Post>

80 行

/** arrange */
const spy = spyOn(postService, 'addPost').and.returnValue(expected$);

由於我們要隔離 PostService ,因此使用 spyOn()PostServiceaddPost 加以 mock,並設定其假的回傳值。

82 行

/** act */
appComponent.onAddPostClick();

實際測試 onAddPostClick()

84 行

/** assert */
expect(spy).toHaveBeenCalled();

由於 onAddPostClick() 回傳值為 void ,且 PostService.addPost() 已經有單元測試保護,因此只要測試 PostService.addPost() 曾經被呼叫過即可。

如何對 Service 單元測試 ?

使用 Wallaby.js 通過所有 component 單元測試。

Conclusion

  • 當 component 使用了 service,若要單元測試就牽涉到 DI 與 spyOn()
  • Service 單元測試可透過 HttpTestingController 加以隔離 HttpClient
  • Component 單元測試可透過 spyOn() 加以隔離 service

Sample Code

完整的範例可以在我的 GitHub 上找到

Reference

Angular , Testing

Angular , HttpClient service-unit-test.md

Fabian Gosebrink , Testing Angular Http Service

Chris Pawlukiewicz , Simple Observable Unit Testing in Angular 2

Jesse Palmer, Testing Angular Applications


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Designing for Emotion

Designing for Emotion

Aarron Walter / Happy Cog / 2011-10-18 / USD 18.00

Make your users fall in love with your site via the precepts packed into this brief, charming book by MailChimp user experience design lead Aarron Walter. From classic psychology to case studies, high......一起来看看 《Designing for Emotion》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具