深入理解Mock,Stub

深入理解Mock,Stub

前面我们学习了Mock,Stub的一些基础知识,今天我们深入理解一下:

我们知道,单元测试最重要的工作就是破除依赖项(break dependency),破除依赖的技巧有好几个,如何使用这些技巧也是我们需要深入研究和学习的。

首先需要指出的是破除依赖项的技巧有Mock类和Stub类。为了破除依赖项它们都可以说是伪造相关library界面的假物件,只是用途不一样而已:

Stub:

一個可控制的假物件,用以取代原本相依的第三方元件,可以使單元測試不用去擔心第三方的邏輯會導致測試失敗。

Mock:

一個假物件,用以替第三方元件判斷與其互動的方式是正確還是錯誤的。也就是說,可以用mock來驗證本身的邏輯與第三方元件的互動方式是否正確。

大家能否看出它们之间的差异吗?其实就是

mock可能會導致測試失敗,stub僅僅用以取代真實物件,並不會導致測試失敗。

使用mock表示在测试与第三方元件的互动情况,而使用Stub无论如何都会让本身的逻辑正常运行,目的是测试最后自己的代码逻辑运行后第三方回传的结果是否正确。

因此不管遇到什么破除依赖项技巧的任何类别(buzzword, jargon),只要判断这个方法会不会导致测试失败,就能够判断这个技巧的真面目是Mock还是Stub

 

依赖Library的意图

理解了Mock和Stub的差异后,我们来看看不同情境下该用什么方法。在开发中,我们依赖第三方的library,主要有两个意图:

1、提供library参数,期望它返回我们需要的结果

2、提供library资源,希望它能帮我们存储起来,或者改变另一个系统的状态。

期待第三方程序返回计算结果

若需要相依library返回所需的計算結果的情況,會使用stub偽造收到參數後的回傳值,以避開需要真實相依的library計算的狀況,如此一來就不用擔心相依的代码修改後會導致自己的測試失敗。同時若透過stub解除相依的測試通過但實際運行時卻出錯,我們可以更確定自己的代码沒有出錯,而是第三方代码造成的錯誤。

委派第三方系統辦事

委派大致上分為「改變第三方系統的狀態」或是「請求第三方系統幫忙做事」的情況,開發人員會期待library能夠收到正確的參數,並以此來確認是否自身的邏輯有提供正確的參數給library,所以此時會使用mock依照收到的參數正確與否決定單元測試是否通過。而不需要直接面對真正的第三方系統,否則可能一個隨機的網路連線問題都會導致單元測試失敗或拖慢運行時間,如此一來就失去單元測試快速且值得信任的特性。

 

Fake/Dummy/Spy ?

有人會想問一下那這三個東西到底是?前面總結過可以透過「偽造的物件能否導致測試失敗」這個條件來判斷到底是stub還是mock,我們就來根據他們的使用方式來揭開一下他們的真面目吧!

Fake

Fake完全是個不做任何事情的假物件,用來填補並避開測試所相依的元件和邏輯,測試僅僅只會經過這些假物件,而不會做任何驗證,不做驗證毫無疑問是個stub

Dummy

一般Dummy做為一個空物件,也許是用來填補缺少的參數或是其他測試已經將其視作mock測試過,在當前的測試中僅僅只需要經過此物件,而不需要任何驗證,不驗證表示不會導致失敗,意味這其實他是個stub

Spy

spy大概是最微妙的一個;一般的mock是針對整個物件(類別實體)進行偽造,但spy在使用上只會偽造類別中的部分方法,此時若針對偽造的方法有去驗證該方法是否收到正確的參數,則表示該spy需視為一個mock,另一方面若spy的目的是為了讓偽造的方法回傳需要的結果以供接下來的測試邏輯使用那spy則被視為stub,總之把握大原則「偽造的(部分)物件能否導致測試失敗」

 

真正的單元測試必須要獨立於相依的外部邏輯,因此在這個章節中介紹主要的兩種解除外部相依的技巧:stubmock,也知道在何種情境下該用哪種方法,接著在下個小節就是最令人期待的實作部分。

 

用例分析:

有一個定期的job從你的好友列表中,找出這個月生日的所有好友,之後透過email寄生日祝賀給他們,我們會透過一個BirthdayService中的 congratuate()方法來完成需求,service的程式碼如下

export class BirthdayService {
  constructor(
    private friendRepository: IFriendsRepository,
    private notificationService: INotificationService
  ) {}

  private isTheSameDate(date1: Date, date2: Date) {
    return date1.getMonth() === date2.getMonth()
      && date1.getDate() === date2.getDate();
    // date.getDay() is used to return the day of the week
  }

  congratulate(userId: string) {
    const firends: Friend[] = this.friendRepository.getAll(userId);
    const today = new Date();
    firends.forEach((eachFriend: Friend) => {
      if (!this.isTheSameDate(today, eachFriend.birthday)) {
        return;
      }
      this.notificationService.notify(eachFriend.firstName);
    });
  }
}

constructor()可以得知我們這裡有兩個相依性需要解決,分別是friendRepositorynotificationService,他們的型別都是TypeScript的Interface,這時我們就可以透過ts-auto-mock的強大能力來幫我們避免實體化friendRepositorynotificationService

beforeEach(() => {
  friendsRepository = createMock<IFriendsRepository>();
  notificationService = createMock<INotificationService>();
  service = new BirthdayService(friendsRepository, notificationService);
});

mock 完後,就開始來進行正式的測試,根據情境可從上方的BirthdayService.congratuate()理解出,這裡先從friendRepository中取得好友列表,之後循序查看是否今日與朋友生日是同一天,若相同就利用notificationService送出好友的姓名?(其實應該是祝賀訊息,不過為了demo用就簡化成送姓名就好 😅),所以我們第一個測試是:

利用mock的概念測試 — 邏輯與friendRepository的互動狀況,具體來說就是測試邏輯是否有把user id提供給friendRepository用以取得朋友列表,如下所示

it("when check if today is some friends' birthday then retrieve friends from repository", () => {
  // arrange
  const userId = 'henry.chou';

  // action
  service.congratulate(userId);

  // assert
  expect(friendsRepository.getAll).toBeCalledWith(userId);
});

根據單元測試的best practice,一個mock應該只測試一種互動,因此上面的測試只測試跟repository的互動,而將朋友列表傳notificationService的互動,則應該用另一個測試來進行

第二個測試情境則發生在已經從friendRepository取回朋友列表之後,當天生日的朋友會經由notificationService收到祝賀通知,測試如下

it("when today is some friends' birthday then felicitate via noitify service", () => {
    // arrange
    const userId = 'henry.chou';
    jest.useFakeTimers('modern').setSystemTime(new Date('2020-04-05'));
    jest
      .spyOn(friendsRepository, 'getAll')
      .mockReturnValue(genreateFriendList());

    // action
    service.congratulate(userId);

    // assert
    expect(notificationService.notify).toBeCalledTimes(2);
    expect(notificationService.notify).toHaveBeenNthCalledWith(
      1,
      expect.stringMatching(/.*Nick$/)
    );
    expect(notificationService.notify).toHaveBeenNthCalledWith(
      2,
      expect.stringMatching(/.*Linda$/)
    );
  });

以上的測試有兩個需要額外注意的重點

1、timer的mock

2、friendRepository.getAll()的spy

第一個timer的fake是個非常經典的範例,前面提過單元測試需要確保其穩定性,這樣才可以每一次執行都能有相同的結果,因此我們會期望時間應該被鎖定在開發者想要測試的時間,而不是當前的時間,以避免「今天不是某朋友的生日,單元測試就會失敗」的窘境。

接著由於此測試的重點是與notificationService的互動,會希望能確保每次friendRepository提供的朋友列表都是一致的,如此才能確保notificationService在於每次的測試中都能測試一樣的內容(穩定性),因此透過jest.spyOn()的方法來fake每次friendRepository回傳的內容。

 

測試完與主要相依元件的互動後,最後就是測試本身的邏輯;該BirthdayService.congratulate()方法最主要的邏輯是:「找出這個月生日的所有好友,之後透過email寄生日祝賀給他們」,結合上面所學先偽造共12位朋友,並且剛好1~12月各有一位朋友生日,假設今天剛好是某一位朋友的生日,測試看看noitificationService是否會送祝賀訊息給該朋友,測試如下

describe('Given user with friends and their birthday in each month', () => {
  const mockDates = [
    {name: 'Rehaan', day: '2021-01-01'},
    {name: 'Ansh', day: '2020-02-01'},
    {name: 'Karam', day: '2020-03-01'},
    {name: 'Beatriz', day: '2020-04-01'},
    {name: 'Joely', day: '2020-05-01'},
    {name: 'Isaac', day: '2020-06-01'},
    {name: 'Stefania', day: '2020-07-01'},
    {name: 'Allan', day: '2020-08-01'},
    {name: 'Bronwyn', day: '2020-09-01'},
    {name: 'Glenn', day: '2020-10-01'},
    {name: 'Niall', day: '2020-11-01'},
    {name: 'Taybah', day: '2020-12-01'},
  ];
  const mockFriendList = (): Friend[] => {
    let friends: Friend[] = [];
    mockDates.forEach((eachFriend) => {
      friends.push({
        firstName: eachFriend.name,
        birthday: new Date(eachFriend.day),
        email: `${eachFriend.name.toLowerCase()}@example.com`,
      });
    });
    return friends;
  }
  it.each(mockDates)(
    "when today($day) is $name's birthday, then congratulate $name",
    ({name, day}) => {
      // arrange
      const userId = 'henry.chou';
      jest.useFakeTimers('modern').setSystemTime(new Date(day));
      jest.spyOn(friendsRepository, 'getAll').mockReturnValue(mockFriendList());

      // action
      service.congratulate(userId);

      // assert
      const regex = new RegExp(`.*${name}$`);
      expect(notificationService.notify).toHaveBeenCalledTimes(1);
      expect(notificationService.notify).toHaveBeenCalledWith(
        expect.stringMatching(regex)
      );
    }
  );
});

順帶一提,後面兩個測試由於主要都在測試notificationService是否有正確的被互動因此friendRepository會被視為stub,而notificationService才是mock,由assertion處可以知道只有notificationService能導致單元測試失敗。

版权声明:著作权归作者所有。

thumb_up 1 | star_outline 0 | textsms 0