• 日常搜索
  • 百度一下
  • Google
  • 在线工具
  • 搜转载

测试驱动开发 (TDD)

什么是测试驱动开发?

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

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

使用 TDD 构建 HTTP 客户端

我们将建造什么

我们将逐步构建一个抽象各种 HTTP 动词的简单 HTTP 客户端。为了使重构顺利进行,我们将遵循 TDD 实践。我们将使用 Jasmine、Sinon 和 Karma 进行测试。要开始使用,请从示例项目中复制 package.json、  karma.conf.jswebpack.test.js ,或者从 GitHub存储库中 克隆 示例项目。 

如果您了解新的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 断言和监视对象。(Jasmine 有自己的方式来监视和存根测试,但我更喜欢 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现在测试显然会失败,因为gethttp让我们纠正一下。

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 令牌。

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

   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方法现在已经成型了,但是在发送body的时候还是很琐碎的。您必须针对每种内容类型以不同的方式处理数据。在处理表单时,我们应该将数据编码为字符串,然后再通过网络发送。 

   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)));
};

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

处理 PATCH 请求

另一个常用的 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 请求。如果您遇到困难,请随时参考 repo。

何时进行 TDD?

社区对此存在分歧。一些程序员一听到 TDD 这个词就逃跑并隐藏起来,而另一些程序员则靠它生活。只需拥有一个好的测试套件,您就可以实现 TDD 的一些有益效果。这里没有正确的答案。这完全取决于您和您的团队对您的方法的满意程度。 

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


文章目录
  • 什么是测试驱动开发?
  • 使用 TDD 构建 HTTP 客户端
    • 我们将建造什么
    • GET 的包装器
      • 添加查询参数
    • 处理突变
      • 替代内容类型和 CSRF 令牌
      • 编码表格
    • 处理 PATCH 请求
  • 何时进行 TDD?