如何测试包含导入的异步方法的类?

Posted

技术标签:

【中文标题】如何测试包含导入的异步方法的类?【英文标题】:How can I test a class which contains imported async methods in it? 【发布时间】:2019-12-19 15:12:09 【问题描述】:

这是我第一次使用测试,我得到了测试 UI 组件的技巧。现在我正在尝试测试一个包含一些静态方法的类。它也包含参数。

查看课程:

import UserInfoModel from '../models/UserInfo.model';
import ApiClient from './apiClient';
import ApiNormalizer from './apiNormalizer';
import Article from '../models/Article.model';
import Notification from '../models/Notification.model';
import Content from '../models/Link.model';

export interface ResponseData 
  [key: string]: any;


export default class ApiService 
  static makeApiCall(
    url: string,
    normalizeCallback: (d: ResponseData) => ResponseData | null,
    callback: (d: any) => any
  ) 
    return ApiClient.get(url)
      .then(res => 
        callback(normalizeCallback(res.data));
      )
      .catch(error => 
        console.error(error);
      );
  


  static getProfile(callback: (a: UserInfoModel) => void) 
    return ApiService.makeApiCall(`profile`, ApiNormalizer.normalizeProfile, callback);
  

我已经创建了一个正在通过的小测试,但我不确定自己在做什么。

// @ts-ignore
import moxios from 'moxios';
import axios from 'axios';
import  baseURL  from './apiClient';
import  dummyUserInfo  from './../models/UserInfo.model';

describe('apiService', () => 
  let axiosInstance: any;

  beforeEach(() => 
    axiosInstance = axios.create();
    moxios.install();
  );

  afterEach(() => 
    moxios.uninstall();
  );

  it('should perform get profile call', done => 
    moxios.stubRequest(`$baseURL.DEVprofile`, 
      status: 200,
      response: 
        _user: dummyUserInfo
      
    );

    axiosInstance
      .get(`$baseURL.DEVprofile`)
      .then((res: any) => 
        expect(res.status).toEqual(200);
        expect(res.data._user).toEqual(dummyUserInfo);
      )
      .finally(done);
  );
);

我正在使用 moxios 测试 axios 的东西 -> https://github.com/axios/moxios

那么用它的方法测试这个类的正确方法是什么?

【问题讨论】:

【参考方案1】:

简介

Unit tests 是由软件开发人员编写和运行的自动化测试,以确保应用程序的一部分符合其设计并按预期运行。就好像我们在谈论面向对象编程一样,一个单元通常是一个完整的接口,例如一个类,但也可以是一个单独的方法。

单元测试的目标是隔离程序的每个部分并显示各个部分是正确的。因此,如果我们考虑您的 ApiService.makeApiCall 函数:

  static makeApiCall(
    url: string,
    normalizeCallback: (d: ResponseData) => ResponseData | null,
    callback: (d: any) => any
  ) 
    return ApiClient.get(url)
      .then((res: any) => 
        callback(normalizeCallback(res.data));
      )
      .catch(error => 
        console.error(error);
      );
  

我们可以看到它有一个外部资源调用ApiClient.get,它应该是mocked。在这种情况下模拟 HTTP 请求并不完全正确,因为 ApiService 没有直接使用它们,在这种情况下,您的单元变得比预期的更广泛。

嘲讽

Jest 框架提供了很好的mocking 机制,Omair Nabiel 的例子是正确的。但是,我更喜欢不仅使用预定义的数据对函数进行存根,而且还要检查存根函数是否被调用了预期的次数(所以使用真正的模拟)。所以完整的模拟示例如下所示:

/**
* Importing `ApiClient` directly in order to reference it later
*/
import ApiClient from './apiClient'; 

/**
* Mocking `ApiClient` with some fake data provider
*/
const mockData = ;

jest.mock('./apiClient', function () 

  return 
    get: jest.fn((url: string) => 
      return Promise.resolve(data: mockData);
    )
  
);

这允许向您的测试示例添加额外的断言:

it('should call api client method', () => 
  ApiService.makeApiCall('test url', (data) => data, (res) => res);
  /**
  * Checking `ApiClient.get` to be called desired number of times 
  * with correct arguments
  */
  expect(ApiClient.get).toBeCalledTimes(1);
  expect(ApiClient.get).toBeCalledWith('test url');  
);

阳性测试

所以,只要我们弄清楚什么以及如何模拟数据,我们就可以找出我们应该测试什么。好的测试应该包括two situations:正面测试 - 通过提供有效数据来测试系统和负面测试 - 通过提供无效数据来测试系统。依我拙见,应该添加第三个分支 - 边界测试 - 专注于被测软件的边界或限制条件的测试。如果您对其他类型的测试感兴趣,请参阅此Glossary。

makeApiCall 方法的正向测试流应该调用normalizeCallbackcallback 方法,因此我们可以编写这个测试如下(但是,给猫剥皮的方法不止一种):

  it('should call callbacks consequently', (done) => 
    const firstCallback = jest.fn((data: any) => 
      return data;
    );
    const secondCallback = jest.fn((data: any) => 
      return data;
    );
    ApiService.makeApiCall('test url', firstCallback, secondCallback)
      .then(() => 
        expect(firstCallback).toBeCalledTimes(1);
        expect(firstCallback).toBeCalledWith(mockData);

        expect(secondCallback).toBeCalledTimes(1);
        expect(secondCallback).toBeCalledWith(firstCallback(mockData));
        done();
      );
  );

请注意此测试中的几件事: - 我正在使用done 回调让我们知道测试已经完成,因为这个测试的异步性质 - 我正在使用mockData 变量,ApiClient.get 的数据被模拟了,所以我检查回调是否有正确的值 - mockData 和类似的变量应该从 mock 开始。否则 Jest 将不允许将其从模拟中取出 scope

负面测试

测试的否定方式看起来非常相似。 ApiClient.get 方法应该抛出错误,ApiService 应该处理它并放入 console。另外,我正在检查是否没有调用任何回调。

import ApiService from './api.service';

const mockError = message: 'Smth Bad Happened';

jest.mock('./apiClient', function () 

  return 
    get: jest.fn().mockImplementation((url: string) => 
      console.log('error result');
      return Promise.reject(mockError);
    )
  
);

describe( 't1', () => 
  it('should handle error', (done) => 
    console.error = jest.fn();

    const firstCallback = jest.fn((data: any) => 
      return data;
    );
    const secondCallback = jest.fn((data: any) => 
      return data;
    );
    ApiService.makeApiCall('test url', firstCallback, secondCallback)
      .then(() => 
        expect(firstCallback).toBeCalledTimes(0);
        expect(secondCallback).toBeCalledTimes(0);
        expect(console.error).toBeCalledTimes(1);
        expect(console.error).toBeCalledWith(mockError);
        done();
      );
  );
);

边界测试

边界测试在您的情况下可能存在争议,但只要(根据您的类型定义normalizeCallback: (d: ResponseData) => ResponseData | null)第一个回调可以返回null,检查是否成功转移到第二个回调可能是一个好习惯任何错误或异常。我们可以稍微重写我们的第二个测试:

it('should call callbacks consequently', (done) => 
    const firstCallback = jest.fn((data: any) => 
      return null;
    );
    const secondCallback = jest.fn((data: any) => 
      return data;
    );
    ApiService.makeApiCall('test url', firstCallback, secondCallback)
      .then(() => 
        expect(firstCallback).toBeCalledTimes(1);
        expect(firstCallback).toBeCalledWith(mockData);
        expect(secondCallback).toBeCalledTimes(1);
        done();
      );
  );

测试异步代码

关于测试异步代码,您可以阅读综合文档here。主要思想是当你有异步运行的代码时,Jest 需要知道它正在测试的代码何时完成,然后才能继续进行另一个测试。 Jest 提供了三种方法:

    通过回调

    it('the data is peanut butter', done => 
      function callback(data) 
        expect(data).toBe('peanut butter');
        done();
      
    
      fetchData(callback);
    );
    

    Jest 会等到 done 回调被调用后再完成测试。如果从未调用过done(),则测试将失败,这正是您想要发生的。

    通过承诺

    如果您的代码使用 Promise,则有一种更简单的方法来处理异步测试。只需从您的测试中返回一个承诺,Jest 将等待该承诺解决。如果 promise 被拒绝,测试将自动失败。

    async/await 语法

    您可以在测试中使用asyncawait。要编写异步测试,只需在传递给测试的函数前面使用 async 关键字即可。

    it('the data is peanut butter', async () => 
    const data = await fetchData();
    expect(data).toBe('peanut butter');
    );
    

示例

在这里您可以找到一个现成的代码示例 https://github.com/SergeyMell/jest-experiments 如果您有不清楚的地方,请告诉我。

更新 (29.08.2019)

关于你的问题

您好,如何在同一个文件中模拟 ./apiClient 以实现成功和错误?

根据documentation Jest 将自动将jest.mock 调用提升到模块顶部(在任何导入之前)。似乎您可以使用setMockdoMock 代替,但是,开发人员不时会遇到issues 以这种方式嘲笑。它们可以通过使用require 而不是import 和其他黑客(参见this article)来覆盖,但我不喜欢这种方式。

在这种情况下,对我来说正确的方法是拆分模拟定义和实现,所以你声明这个模块将像这样被模拟

jest.mock('./apiClient', function () 
  return 
    get: jest.fn()
  
);

但模拟功能的实现会因测试范围而异:

describe('api service success flow', () => 

  beforeAll(() => 
    //@ts-ignore
    ApiClient.get.mockImplementation((url: string) => 
      return Promise.resolve(data: mockData);
    )
  );

  ...
);

describe('api service error flow', () => 

  beforeAll(() => 
    //@ts-ignore
    ApiClient.get.mockImplementation((url: string) => 
      console.log('error result');
      return Promise.reject(mockError);
    )
  );

  ...
);

这将允许您将所有api service 相关流存储在一个文件中,据我所知,这是您所期望的。 我已经用实现上述所有内容的api.spec.ts 更新了我的github example。请看一下。

【讨论】:

@Non,您还有未回答的问题吗?您需要对某些部分进行额外的详细说明或解释吗? 您好,如何在同一文件中模拟 ./apiClient 以实现成功和错误? 嗨@Non,我已经用这个信息和我的github示例更新了我的答案。请参阅更新 (29.08.2019) 部分【参考方案2】:

我猜你要问的是如何测试ApiService。如果是这种情况,那么模拟您要测试的自己的东西会使单元测试毫无意义。

我期望的是以下项目

    您只想在自己的类中测试逻辑,而不是在库中。 您不想发出实际的网络请求,这会向服务器发送垃圾邮件并使测试运行速度变慢。

如果是这种情况,那么您应该模拟一些库来控制它们的行为,并查看您的类在这些情况下的行为方式。并且,模拟任何涉及网络 IO 的操作,让您的测试更快,更少依赖外部资源。

您可以通过模拟一些依赖项来检查一些事情:

    委派,例如axios 是否使用正确的参数调用了一次? 直接模拟库的行为,在您的情况下使用maxios
import ApiService,  baseURL  from './apiClient';


describe('ApiService', () => 
  let axiosInstance: any;

  beforeEach(() => 
    axiosInstance = axios.create();
    moxios.install();
  );

  afterEach(() => 
    moxios.uninstall();
  );

  // usually 1 test suite for each method
  describe('#getProfile', (done) => 
    // mocking behaviour
    it('should perform get profile call', () => 
      moxios.stubRequest(`$baseURL.DEVprofile`, 
        status: 200,
        response: 
          _user: dummyUserInfo
        
      );

      ApiService.getProfile((profile) => 
        expect(profile).toEqual(dummyUserInfo); // you get what i mean
        done();
      );
    );

    // directly mock axios
    it('delegates to axios', (done) => 
      // you should put this to the top to avoid confusion, it will be hoisted
      jest.mock('axios', () => (
        create: jest.fn(() => (
          get: jest.fn(() => Promise.resolve()),
        )),
      ));

      ApiService.getProfile((profile) => 
        // do some assertion
        expect(axiosInstance.get).toHaveBeenCalledTimes(1);
        expect(axiosInstance.get).toHaveBeenCalledWith(url, someParam, youGetIt);
        done();
      );
    );

    // rmb to test some error case
    it('should throw when param is not correct', (done) =>  ... );
  );
);

【讨论】:

【参考方案3】:

单元测试术语是不言自明的,您测试一个单元。完全隔离的功能。任何外部依赖项都会被模拟。在这里,如果您要测试 makeApiCall 函数,则必须对其参数进行存根,然后模拟 ApiClient 承诺,并期望该函数返回您期望它返回的关于您的模拟和存根参数的任何内容。

人们通常会忘记并且最重要的一件事是测试函数的负例。如果你的函数抛出错误会发生什么,它会破坏应用程序。万一出现故障,您的函数的行为方式。编写测试是为了避免应用中的重大更改。

这里有一个更好的指导如何在 JEST 中测试异步函数的代码示例:

https://www.leighhalliday.com/mocking-axios-in-jest-testing-async-functions

希望对你有帮助

更新

模拟你的 ApiClient

通行证:

jest.mock('./apiClient', () => 
  get: jest.fn(() => Promise.resolve(data)) // for pass case
)

失败案例:

jest.mock('./apiClient', () => 
  get: jest.fn(() => Promise.reject(false)) // for fail case
)

现在在这两种情况下调用 makeApiCall 一次表示成功,一次表示失败。

失败案例:

const makeCall = await makeApiCall( <your stub params here> )
expect(makeCall).toThrowError() // note here you can check whatever you have done to handle error. ToThrowError is not a built-in function but just for understanding

我主要是在 Jasmine 中完成测试,所以最后一段代码是一种伪代码。

【讨论】:

有没有办法让你提供代码来测试我需要什么?

以上是关于如何测试包含导入的异步方法的类?的主要内容,如果未能解决你的问题,请参考以下文章

我如何使用Java反射来实例化无法导入的类?

生成仅包含使用的类的最小化 jar

如何正确使用python模块

等待模块导入异步初始化的最佳方法?

C ++如何从包含的类中调用父类方法?

ApplicationEvent 与ApplicationListener 异步化执行测试