w3ctech

[Jest]单元测试初学者指南 - 第三部分 - 模拟Http请求和访问文件

原文: Unit Testing Beginners Guide - Part 3 - Mock Http And Files access

作者:jstweetster

示例代码

在本文结束时,您将能够使用Jest正确的测试包含http请求,文件访问方法,数据库调用或者任何其他类型的辅助作用的生产代码。此外,您将学习如何处理其他类似的问题,而且您需要避免实际调用的方法活模块(例如数据库调用)。

译者注:接下来的测试涉及到Jest需要使用Babel的情况,所以需要安装一些依赖,可以查看官方文档说明 https://jestjs.io/docs/zh-Hans/getting-started ,也可以直接查看上面给出的示例代码。

1)安装依赖

yarn add --dev babel-jest babel-core regenerator-runtime babel-preset-env # 也可以使用 npm 安装

2)在项目根目录新建 .babelrc 文件,添加内容如下:

{
  "presets": ["env"]
}

模拟HTTP请求

让我们假设我们有一段代码使用 XMLHttpRequest 方法执行网络请求,如下:

const API_ROOT = 'http://jsonplaceholder.typicode.com';
class API {
    getPosts() {
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.open('GET', `${API_ROOT}/posts`);
            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4) {
                    const resp = JSON.parse(xhr.responseText);
                    if (resp.error) {
                        reject(resp.error);
                    } else {
                        resolve(resp);
                    }
                }
            }
            xhr.send();
        })
    }
}

export default new API();

我们怎样测试这个方法?

首先,让我们定义这个方法是干什么的。

getPosts() 使用API调用来检索博客中的帖子列表,并且返回一个解析帖子列表的 Promise

我们想要避免的是在我们的测试中实际进行API调用。

为什么?

  • 因为调用的资源可能在运行测试的环境中不可用
  • 因为我们有第三方依赖(API端点),这是的测试容易失败。
  • 因为测试将会运行的很慢
  • 因为端点调用可能具有需要清理的意外后果(例如写入DB)
  • 因为你不再进行单元测试了。相反,我们将进行功能测试领域了
  • 最后,因为被调用的资源可能在时间和复杂性方面执行昂贵的操作

正如你所看到的,我们有很多理由希望避免直接在具有实际网络请求单元测试中工作。

模拟 XMLHttpRequest

解决方法是回溯到使用 XMLHttpRequest 对象的正确模拟,拦截对它的调用并伪造行为。

如果你不记得究竟模拟什么和怎样使用,你可以查看“使用Jest中的spies和fake timers” 和Jest官方文档对mocks的说明。

让我们尝试创建第一个测试(假设我们有一个 api.spec.js 文件):

import API from '../src/api.js';
const mockXHR = {
  open: jest.fn(),
  send: jest.fn(),
  readyState: 4,
  responseText: JSON.stringify(
    [
      {
        title: 'test post'
      },
      {
        tile: 'second test post'
      }
    ]
  )
};
const oldXMLHttpRequest = window.XMLHttpRequest;
window.XMLHttpRequest = jest.fn(() => mockXHR);

describe('API integration test suite', function () {
  test('Should retrieve the list of posts from the server when calling getPosts method', function (done) {
    const reqPromise = API.getPosts();
    mockXHR.onreadystatechange();
    reqPromise.then((posts) => {
      expect(posts.length).toBe(2);
      expect(posts[0].title).toBe('test post');
      expect(posts[1].title).toBe('second test post');
      done();
    });
  });
});

让我们一步一步的来了解发生了什么

const mockXHR = {
  open: jest.fn(),
  send: jest.fn(),
  readyState: 4,
  responseText: JSON.stringify(
    [
      {
        title: 'test post'
      },
      {
        title: 'second test post'
      }
    ]
  )
};

我们开始创建了一个假的XHR对象,实际的 opensend 方法是不做任何事情的函数。我们还设置 readyState 的值为4(通常用于检测请求是否已完成)和 responseText 的值为适合我们要测试的内容。

我们可以通过为 responseText 提供正确的文本值来模拟我们想要的任何API响应。

const oldXMLHttpRequest = window.XMLHttpRequest;
window.XMLHttpRequest = jest.fn(() => mockXHR);

接下来,我们将备份内置的XMLHttpRequest 对象,将其替换为返回我们的模拟对象的函数。备份真正的 XMLHttpRequest 对象是一个好主意,因为在测试结束时我们应该清理环境,让环境处于使用它之前的初始状态。

因此,每当调用 new XMLHttpRequest 时,将会返回 mockXHR 对象。

而且,最后我们使用这个设置,对 getPosts 函数进行单元测试更加容易啦。

const reqPromise = API.getPosts();
mockXHR.onreadystatechange();

调用这个 API ,然后通过调用 onreadystatechange 函数来模拟响应到达。当 onreadystatechange 被调用后,我们实际上正在调用在 api.js 中设置的状态更改的回调函数:

xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
        const resp = JSON.parse(xhr.responseText);
        if (resp.error) {
            reject(resp.error);
        } else {
            resolve(resp);
        }
    }
}

因为模拟的 XHR 对象的 readyState 的值设置为 4,Promise 对象将会立即被解析(resolved)或拒绝(rejected),具体取决于响应。

根据断言,我们验证从 getPosts() 函数返回的 Promise 对象是否具有实际的 JSON 响应值作为被解析的值。

我们通过检查文章数量是否正确以及每篇文章应该是什么值来验证测试的正确性。

expect(posts.length).toBe(2);
expect(posts[0].title).toBe('test post');
expect(posts[1].title).toBe('second test post');

深入理解

我们可以将该方法进行一些改进,使其更灵活和易于使用。

首先,让我们创建一个可以模拟 XHR 对象的工厂函数,它能够让我们更加容易的创建新的模拟 XHR,并且,可选择的指定响应的对象。

const createMockXHR = (responseJSON) => {
    const mockXHR = {
        open: jest.fn(),
        send: jest.fn(),
        readyState: 4,
        responseText: JSON.stringify(
            responseJSON || {}
        )
    };
    return mockXHR;
}

接下来,让我们为每一个单元测试创建一个新的 XHR 对象。在单元测试时,我们最不希望的是在单元测试中具有共享状态,这会导致不可预测且难以调试的测试。实际的测试单元如下:

describe('API integration test suite', function() {
  const oldXMLHttpRequest = window.XMLHttpRequest;
  let mockXHR = null;

  beforeEach(() => {
    mockXHR = createMockXHR();
    window.XMLHttpRequest = jest.fn(() => mockXHR);
  });

  afterEach(() => {
    window.XMLHttpRequest = oldXMLHttpRequest;
  });

  test('Should retrieve the list of posts from the server when calling getPosts method', function(done) {
    const reqPromise = API.getPosts();
    mockXHR.responseText = JSON.stringify([
      { title: 'test post' },
      { title: 'second test post' }
    ]);
    mockXHR.onreadystatechange();
    reqPromise.then((posts) => {
      expect(posts.length).toBe(2);
      expect(posts[0].title).toBe('test post');
      expect(posts[1].title).toBe('second test post');
      done();
    });
  });
});

此外,在每次测试之后,我们通过在 beforeEach 中模拟XMLHttpRequest 并在 afterEach 中恢复原本的 XMLHttpRequest 对象来清理环境。

  beforeEach(() => {
    mockXHR = createMockXHR();
    window.XMLHttpRequest = jest.fn(() => mockXHR);
  });

  afterEach(() => {
    window.XMLHttpRequest = oldXMLHttpRequest;
  });

我们获得的另一个好处是我们可以非常轻松的测试不同的场景。假设我们想要添加另一个测试,模拟 API 返回错误:

test('Should return a failed promise with the error message when the API returns an error', function(done) {
    const reqPromise = API.getPosts();
    mockXHR.responseText = JSON.stringify({
      error: 'Failed to GET posts'
    });
    mockXHR.onreadystatechange();
    reqPromise.catch((err) => {
      expect(err).toBe('Failed to GET posts');
      done();
    });
  });

你是否注意到模拟API不同的响应变得更加容易啦?

如何模拟文件系统/数据库调用和其他副作用(side effects)

我们可以非常轻松地扩展我们应用于HTTP请求的技术,以涵盖其他类型的副作用。

假设我们有一个 FileSystem 组件,它有一个读取文件并将其解析为 JSON 的方法:

import fs from 'fs';
export default class FileSystem {
  parseJSONFile(file) {
    const content = String(fs.readFileSync(file));
    return JSON.parse(content);
  }
}

我们想要测试 parseJSONFile() 方法,但是我们也想要避免从磁盘中创建文件和读取它的内容。

我们的测试单元如下:

jest.mock('fs', () => ({
  readFileSync: jest.fn()
}));

import FileSystem from './FileSystem.js';
import fs from 'fs';

describe('FileSystem test suite', function() {
  test('Should return the parsed JSON from a file specified as param', function(done) {
    const fileReader = new FileSystem();
    fs.readFileSync.mockReturnValue('{ "test": 1 }');
    const result = fileReader.parseJSONFile('test.json');
    expect(result).toEqual({ "test": 1 });
    done();
  });
});

让我们一步一步的来看:

jest.mock('fs', () => ({
    readFileSync: jest.fn()
})})

jest.mock 允许我们模拟我们可能拥有的任何模块,包括在 NodeJS 中构建的并有工厂函数的模块作为第二个参数 (arg),返回模拟的返回值。

在我们的示例中,每当我们的代码里有 const fs = require('fs'); 或者 import fs from 'fs' ,导入的值实际上是我们从工厂函数返回的对象:

{
    readFileSync: jest.fn()
}

在使用 fs.readFileSync 之前调用 jest.mock 很重要。

接下来,我们实例化我们要测试的组件 const fileReader = new FileSystem(); ,并指示 readFileSync spy 返回某个预先制作的字符串:

fs.readFileSync.mockRetrunValue('{ "test": 1 }');

如果你想要了解更多的操作信息,请在 Jest 文档中查看 mockReturnValue

最后,我们验证 parseJSONFile 的结果是解析的 JSON 值:

expect(result).toEqual({ "test": 1 });

以上是对模拟http调用和文件系统调用的介绍。

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复