实用的测试驱动开发

什么是测试驱动开发?

测试驱动开发(TDD)只是意味着您首先编写测试。 您甚至在编写单行业务逻辑之前就对正确的代码设定了期望。 TDD不仅有助于确保您的代码正确无误,而且还可以帮助您编写较小的函数,在不破坏功能的情况下重构代码并更好地理解问题。

在本文中,我将通过构建一个小型实用程序来介绍TDD的一些概念。 我们还将介绍TDD使您的生活变得简单的一些实际情况。

使用TDD构建HTTP客户端

我们将要建设的

我们将逐步构建一个抽象HTTP谓词的简单HTTP客户端。 为了使重构顺利进行,我们将遵循TDD做法。 我们将使用Jasmine,Sinn和Karma进行测试。 首先, 从示例项目中复制package.jsonkarma.conf.jswebpack.test.js ,或从GitHub repo克隆示例项目

如果您了解新的Fetch API的工作原理会有所帮助,但是示例应易于遵循。 对于初学者来说, Fetch API是XMLHttpRequest的更好替代方案。 它简化了网络交互,并与Promises一起很好地工作。

GET包装

首先,在src / http.js创建一个空文件,并在src / __ tests __ / http-test.js下创建一个附带的测试文件。

让我们为此服务设置一个测试环境。

import * as http from "../http.js";
import sinon from "sinon";
import * as fetch from "isomorphic-fetch";

describe("TestHttpService", () => {
  describe("Test success scenarios", () => {
    beforeEach(() => {
      stubedFetch = sinon.stub(window, "fetch");

      window.fetch.returns(Promise.resolve(mockApiResponse()));

      function mockApiResponse(body = {}) {
        return new window.Response(JSON.stringify(body), {
          status: 200,
          headers: { "Content-type": "application/json" }
        });
      }
    });
  });
});

我们在这里同时使用Jasmine和Sinon,Jasmine定义测试场景,Sinon声明并监视对象。 (茉莉花有自己的间谍方式和测试存根,但我更喜欢Sinon的API。)

上面的代码是不言自明的。 在每次测试运行之前,由于没有可用的服务器,我们劫持了对Fetch API的调用,并返回了一个模拟的Promise对象。 这里的目标是对是否使用正确的参数调用Fetch API进行单元测试,并查看包装程序是否能够正确处理任何网络错误。

让我们从一个失败的测试案例开始:

describe("Test get requests", () => {
  it("should make a GET request", done => {
    http.get(url).then(response => {
      expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
      expect(response).toEqual({});
      done();
    });
  });
});

通过调用karma start测试运行程序。 现在测试显然会失败,因为http没有get方法。 让我们纠正一下。

const status = response => {
  if (response.ok) {
    return Promise.resolve(response);
  }

  return Promise.reject(new Error(response.statusText));
};

export const get = (url, params = {}) => {
  return fetch(url)
    .then(status);
};

如果现在运行测试,将会看到一个失败的响应,提示Expected [object Response] to equal Object({ }) 。 响应是一个Stream对象 。 顾名思义,流对象每个都是数据流。 要从流中获取数据,您需要首先使用其一些辅助方法来读取流。 现在,我们可以假设流将是JSON并通过调用response.json()将其反序列化。

const deserialize = response => response.json();

export const get = (url, params = {}) => {
  return fetch(url)
    .then(status)
    .then(deserialize)
    .catch(error => Promise.reject(new Error(error)));
};

我们的测试套件现在应该是绿色的。

添加查询参数

到目前为止, get方法仅进行了简单的调用,而没有任何查询参数。 让我们编写一个失败的测试,看看它如何与查询参数一起使用。 如果我们通过{ users: [1, 2], limit: 50, isDetailed: false }作为查询参数,我们的HTTP客户端应该对/api/v1/users/?users=1&users=2&limit=50&isDetailed=false进行网络调用。

it("should serialize array parameter", done => {
    const users = [1, 2];
    const limit = 50;
    const isDetailed = false;
    const params = { users, limit, isDetailed };
    http
      .get(url, params)
      .then(response => {
        expect(stubedFetch.calledWith(`${url}?isDetailed=false&limit=50&users=1&users=2/`)).toBeTruthy();
        done();
      })
  });

现在我们已经设置了测试,让我们扩展get方法来处理查询参数。

import { stringify } from "query-string";

export const get = (url, params) => {
  const prefix = url.endsWith('/') ? url : `${url}/`;
  const queryString = params ? `?${stringify(params)}/` : '';
  
  return fetch(`${prefix}${queryString}`)
    .then(status)
    .then(deserializeResponse)
    .catch(error => Promise.reject(new Error(error)));
};

如果存在参数,我们将构造查询字符串并将其附加到URL。

在这里,我使用了查询字符串库-这是一个不错的小助手库,可帮助处理各种查询参数方案。

处理突变

GET可能是最简单的HTTP方法要实现。 GET是幂等的,不应将其用于任何突变。 POST通常用于更新服务器中的某些记录。 这意味着默认情况下,POST请求需要一些护栏,例如CSRF令牌。 下一节将对此进行更多介绍。

让我们从构造一个针对基本POST请求的测试开始:

describe(`Test post requests`, () => {

    it("should send request with custom headers", done => {
        const postParams = { 
        users: [1, 2] 
        };
        http.post(url, postParams, { contentType: http.HTTP_HEADER_TYPES.text })
        .then(response => {
            const [uri, params] = [...stubedFetch.getCall(0).args];

            expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
            expect(params.body).toEqual(JSON.stringify(postParams));

            expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.text);
            done();
        });
    });
});

POST的签名与GET非常相似。 它具有options属性,您可以在其中定义标题,正文,最重要的是method 。 该方法描述了HTTP动词,在这种情况下为"post"

现在,让我们假设内容类型为JSON,然后开始执行POST请求。

export const HTTP_HEADER_TYPES = {
  json: "application/json",
  text: "application/text",
  form: "application/x-www-form-urlencoded",
  multipart: "multipart/form-data"
};


export const post = (url, params) => {

  const headers = new Headers();

  headers.append("Content-Type", HTTP_HEADER_TYPES.json);

  return fetch(url, {
    headers,
    method: "post",
    body: JSON.stringify(params),
  });
};

至此,我们的post方法非常原始。 除了JSON请求外,它不支持其他任何功能。

备用内容类型和CSRF令牌

让我们允许调用者确定内容类型,然后将CSRF令牌扔进战场。 根据您的要求,可以将CSRF设为可选。 在我们的用例中,我们将假定这是一个选择加入功能,并让调用方确定是否需要在标头中设置CSRF令牌。

为此,首先将一个options对象作为第三个参数传递给我们的方法。

it("should send request with CSRF", done => {
        const postParams = { 
        users: [1, 2 ] 
        };
        http.post(url, postParams, {
            contentType: http.HTTP_HEADER_TYPES.text,
            includeCsrf: true 
        }).then(response => {
            const [uri, params] = [...stubedFetch.getCall(0).args];

            expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
            expect(params.body).toEqual(JSON.stringify(postParams));
            expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.text);
            expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

            done();
        });
    });

当我们提供options{contentType: http.HTTP_HEADER_TYPES.text,includeCsrf: true ,它应该设置内容标题和相应的CSRF头。 让我们更新post函数以支持这些新选项。

export const post = (url, params, options={}) => {
  const {contentType, includeCsrf} = options;

  const headers = new Headers();

  headers.append("Content-Type", contentType || HTTP_HEADER_TYPES.json());
  if (includeCsrf) {
    headers.append("X-CSRF-Token", getCSRFToken());
  }

  return fetch(url, {
    headers,
    method: "post",
    body: JSON.stringify(params),
  });
};

const getCsrfToken = () => {
    //This depends on your implementation detail
    //Usually this is part of your session cookie
    return 'csrf'
}

请注意,获取CSRF令牌是实现细节。 通常,它是会话cookie的一部分,您可以从那里提取它。 我将不在本文中进一步介绍。

您的测试套件现在应该很高兴。

编码形式

我们的post方法现在正在成形,但是在发送尸体时仍然微不足道。 对于每种内容类型,您都必须对数据进行不同的处理。 处理表单时,在通过网络发送数据之前,我们应该将数据编码为字符串。

it("should send a form-encoded request", done => {
        const users = [1, 2];
        const limit = 50;
        const isDetailed = false;
        const postParams = { users, limit, isDetailed };

        http.post(url, postParams, {
            contentType: http.HTTP_HEADER_TYPES.form,
            includeCsrf: true 
        }).then(response => {
            const [uri, params] = [...stubedFetch.getCall(0).args];

            expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
            expect(params.body).toEqual("isDetailed=false&limit=50&users=1&users=2");
            expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.form);
            expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

            done();
        });
    });

让我们提取一个小的辅助方法来完成这项繁重的工作。 基于contentType ,它以不同的方式处理数据。

const encodeRequests = (params, contentType) => {

  switch (contentType) {
    case HTTP_HEADER_TYPES.form: {
      return stringify(params);
    }
    
    default:
      return JSON.stringify(params);
  }
}

export const post = (url, params, options={}) => {
  const {includeCsrf, contentType} = options;

  const headers = new Headers();

  headers.append("Content-Type", contentType || HTTP_HEADER_TYPES.json);


  if (includeCsrf) {
    headers.append("X-CSRF-Token", getCSRFToken());
  }

  return fetch(url, {
    headers,
    method="post",
    body: encodeRequests(params, contentType || HTTP_HEADER_TYPES.json)
  }).then(deserializeResponse)
  .catch(error => Promise.reject(new Error(error)));
};

看那个! 即使重构了核心组件,我们的测试仍然通过。

处理补丁请求

另一个常用的HTTP动词是PATCH。 现在,PATCH是一个可变调用,这意味着它对这两个动作的签名非常相似。 唯一的区别在于HTTP动词。 通过一个简单的调整,我们就可以重复使用为POST编写的所有测试。

['post', 'patch'].map(verb => {

describe(`Test ${verb} requests`, () => {
let stubCSRF, csrf;

beforeEach(() => {
  csrf = "CSRF";
  stub(http, "getCSRFToken").returns(csrf);
});

afterEach(() => {
  http.getCSRFToken.restore();
});

it("should send request with custom headers", done => {
  const postParams = { 
    users: [1, 2] 
  };
  http[verb](url, postParams, { contentType: http.HTTP_HEADER_TYPES.text })
    .then(response => {
      const [uri, params] = [...stubedFetch.getCall(0).args];

      expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
      expect(params.body).toEqual(JSON.stringify(postParams));

      expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.text);
      done();
    });
});

it("should send request with CSRF", done => {
  const postParams = { 
    users: [1, 2 ] 
  };
  http[verb](url, postParams, {
      contentType: http.HTTP_HEADER_TYPES.text,
      includeCsrf: true 
    }).then(response => {
      const [uri, params] = [...stubedFetch.getCall(0).args];

      expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
      expect(params.body).toEqual(JSON.stringify(postParams));
      expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.text);
      expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

      done();
    });
});

it("should send a form-encoded request", done => {
  const users = [1, 2];
  const limit = 50;
  const isDetailed = false;
  const postParams = { users, limit, isDetailed };

  http[verb](url, postParams, {
      contentType: http.HTTP_HEADER_TYPES.form,
      includeCsrf: true 
    }).then(response => {
      const [uri, params] = [...stubedFetch.getCall(0).args];

      expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();
      expect(params.body).toEqual("isDetailed=false&limit=50&users=1&users=2");
      expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.form);
      expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

      done();
    });
});

});
});

同样,我们可以通过使动词可配置来重用当前的post方法,并重命名方法名称以反映通用的名称。

const request = (url, params, options={}, method="post") => {
  const {includeCsrf, contentType} = options;

  const headers = new Headers();

  headers.append("Content-Type", contentType || HTTP_HEADER_TYPES.json);


  if (includeCsrf) {
    headers.append("X-CSRF-Token", getCSRFToken());
  }

  return fetch(url, {
    headers,
    method,
    body: encodeRequests(params, contentType)
  }).then(deserializeResponse)
  .catch(error => Promise.reject(new Error(error)));
};


export const post = (url, params, options = {}) => request(url, params, options, 'post');

现在我们所有的POST测试都通过了,剩下的就是为patch添加另一种方法。

export const patch = (url, params, options = {}) => request(url, params, options, 'patch');

简单吧? 作为练习,请尝试自行添加PUT或DELETE请求。 如果您遇到困难,请随时参考该仓库。

什么时候去TDD?

社区对此有分歧。 有些程序员会在听到TDD一词后立即运行并隐藏它们,而另一些则靠它生存。 您只要拥有一个良好的测试套件,就可以实现TDD的一些有益效果。 这里没有正确的答案。 这完全取决于您和您的团队对方法的适应程度。

根据经验,我将TDD用于需要更明确说明的复杂,非结构化问题。 在评估一种方法或比较多种方法时,我发现预先定义问题陈述和界限会很有帮助。 它有助于明确要求和功能需要处理的边缘情况。 如果案例数太多,则表明您的程序可能执行了太多操作,也许是时候将其拆分为较小的单元了。 如果要求很简单,我将跳过TDD并在以后添加测试。

包起来

这个主题上有很多噪音,很容易迷路。 如果我可以向您提供一些建议,请:不要对TDD本身太担心,而应专注于基本原则。 这就是编写干净,易于理解且可维护的代码。 TDD是程序员工具带中的一项有用技能。 随着时间的流逝,您将逐渐了解何时应用此功能。

感谢您的阅读,请在评论部分告诉我们您的想法。

翻译自: https://code.tutsplus.com/tutorials/practical test driven development--cms-30345

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值