软件测试 —— 实用的测试驱动开发方法

什么是测试驱动开发

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

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

使用 TDD 构建 HTTP 客户端

我们将构建什么

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

如果您了解新的 Fetch API 的工作原理,将会有所帮助,但这些示例应该很容易理解。对于新手来说,Fetch API 是 XMLHttpRequest 的更好替代方案。它简化了网络交互并与 Promise 配合良好。

GET 的包装

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

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

 
  1. import * as http from "../http.js";

  2. import sinon from "sinon";

  3. import * as fetch from "isomorphic-fetch";

  4. describe("TestHttpService", () => {

  5. describe("Test success scenarios", () => {

  6. beforeEach(() => {

  7. stubedFetch = sinon.stub(window, "fetch");

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

  9. function mockApiResponse(body = {}) {

  10. return new window.Response(JSON.stringify(body), {

  11. status: 200,

  12. headers: { "Content-type": "application/json" }

  13. });

  14. }

  15. });

  16. });

  17. });

我们在这里使用 Jasmine 和 Sinon — Jasmine 定义测试场景,Sinon 断言和监视对象。 (Jasmine 有自己的方式来监视和存根测试,但我更喜欢 Sinon 的 API。)

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

让我们从失败的测试用例开始:

 
  1. describe("Test get requests", () => {

  2. it("should make a GET request", done => {

  3. http.get(url).then(response => {

  4. expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();

  5. expect(response).toEqual({});

  6. done();

  7. });

  8. });

  9. });

通过调用 karma start 启动测试运行程序。现在测试显然会失败,因为 http 中没有 get 方法。让我们纠正这个问题。

 
  1. const status = response => {

  2. if (response.ok) {

  3. return Promise.resolve(response);

  4. }

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

  6. };

  7. export const get = (url, params = {}) => {

  8. return fetch(url)

  9. .then(status);

  10. };

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

添加查询参数

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

 
  1. it("should serialize array parameter", done => {

  2. const users = [1, 2];

  3. const limit = 50;

  4. const isDetailed = false;

  5. const params = { users, limit, isDetailed };

  6. http

  7. .get(url, params)

  8. .then(response => {

  9. expect(stubedFetch.calledWith(`${url}?isDetailed=false&limit=50&users=1&users=2/`)).toBeTruthy();

  10. done();

  11. })

  12. });

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

 
  1. import { stringify } from "query-string";

  2. export const get = (url, params) => {

  3. const prefix = url.endsWith('/') ? url : `${url}/`;

  4. const queryString = params ? `?${stringify(params)}/` : '';

  5. return fetch(`${prefix}${queryString}`)

  6. .then(status)

  7. .then(deserializeResponse)

  8. .catch(error => Promise.reject(new Error(error)));

  9. };

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

在这里,我使用了查询字符串库 - 这是一个很好的小帮助程序库,有助于处理各种查询参数场景。

 
处理突变

GET 可能是实现起来最简单的 HTTP 方法。 GET 是幂等的,不应该用于任何突变。 POST 通常意味着更新服务器中的一些记录。这意味着 POST 请求默认需要一些防护措施,例如 CSRF 令牌。下一节将详细介绍这一点。

让我们首先构建一个基本 POST 请求的测试:

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

  2. it("should send request with custom headers", done => {

  3. const postParams = {

  4. users: [1, 2]

  5. };

  6. http.post(url, postParams, { contentType: http.HTTP_HEADER_TYPES.text })

  7. .then(response => {

  8. const [uri, params] = [...stubedFetch.getCall(0).args];

  9. expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();

  10. expect(params.body).toEqual(JSON.stringify(postParams));

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

  12. done();

  13. });

  14. });

  15. });

POST 的签名与 GET 非常相似。它需要一个 options 属性,您可以在其中定义标头、正文,以及最重要的 method。该方法描述了 HTTP 动词,在本例中为 "post"

现在,我们假设内容类型是 JSON 并开始实现 POST 请求。

 
  1. export const HTTP_HEADER_TYPES = {

  2. json: "application/json",

  3. text: "application/text",

  4. form: "application/x-www-form-urlencoded",

  5. multipart: "multipart/form-data"

  6. };

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

  8. const headers = new Headers();

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

  10. return fetch(url, {

  11. headers,

  12. method: "post",

  13. body: JSON.stringify(params),

  14. });

  15. };

此时,我们的post方法就非常原始了。除了 JSON 请求之外,它不支持任何其他内容。

替代内容类型和 CSRF 令牌

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

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

 
  1. it("should send request with CSRF", done => {

  2. const postParams = {

  3. users: [1, 2 ]

  4. };

  5. http.post(url, postParams, {

  6. contentType: http.HTTP_HEADER_TYPES.text,

  7. includeCsrf: true

  8. }).then(response => {

  9. const [uri, params] = [...stubedFetch.getCall(0).args];

  10. expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();

  11. expect(params.body).toEqual(JSON.stringify(postParams));

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

  13. expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

  14. done();

  15. });

  16. });

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

 
  1. export const post = (url, params, options={}) => {

  2. const {contentType, includeCsrf} = options;

  3. const headers = new Headers();

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

  5. if (includeCsrf) {

  6. headers.append("X-CSRF-Token", getCSRFToken());

  7. }

  8. return fetch(url, {

  9. headers,

  10. method: "post",

  11. body: JSON.stringify(params),

  12. });

  13. };

  14. const getCsrfToken = () => {

  15. //This depends on your implementation detail

  16. //Usually this is part of your session cookie

  17. return 'csrf'

  18. }

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

您的测试套件现在应该很满意。

编码形式

我们的 post 方法现在已经成型,但是在发送正文时仍然很简单。您必须针对每种内容类型以不同的方式处理数据。处理表单时,我们应该在通过网络发送数据之前将数据编码为字符串。

 
  1. it("should send a form-encoded request", done => {

  2. const users = [1, 2];

  3. const limit = 50;

  4. const isDetailed = false;

  5. const postParams = { users, limit, isDetailed };

  6. http.post(url, postParams, {

  7. contentType: http.HTTP_HEADER_TYPES.form,

  8. includeCsrf: true

  9. }).then(response => {

  10. const [uri, params] = [...stubedFetch.getCall(0).args];

  11. expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();

  12. expect(params.body).toEqual("isDetailed=false&limit=50&users=1&users=2");

  13. expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.form);

  14. expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

  15. done();

  16. });

  17. });

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

 
  1. const encodeRequests = (params, contentType) => {

  2. switch (contentType) {

  3. case HTTP_HEADER_TYPES.form: {

  4. return stringify(params);

  5. }

  6. default:

  7. return JSON.stringify(params);

  8. }

  9. }

  10. export const post = (url, params, options={}) => {

  11. const {includeCsrf, contentType} = options;

  12. const headers = new Headers();

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

  14. if (includeCsrf) {

  15. headers.append("X-CSRF-Token", getCSRFToken());

  16. }

  17. return fetch(url, {

  18. headers,

  19. method="post",

  20. body: encodeRequests(params, contentType || HTTP_HEADER_TYPES.json)

  21. }).then(deserializeResponse)

  22. .catch(error => Promise.reject(new Error(error)));

  23. };

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

处理 PATCH 请求

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

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

  2. describe(`Test ${verb} requests`, () => {

  3. let stubCSRF, csrf;

  4. beforeEach(() => {

  5. csrf = "CSRF";

  6. stub(http, "getCSRFToken").returns(csrf);

  7. });

  8. afterEach(() => {

  9. http.getCSRFToken.restore();

  10. });

  11. it("should send request with custom headers", done => {

  12. const postParams = {

  13. users: [1, 2]

  14. };

  15. http[verb](url, postParams, { contentType: http.HTTP_HEADER_TYPES.text })

  16. .then(response => {

  17. const [uri, params] = [...stubedFetch.getCall(0).args];

  18. expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();

  19. expect(params.body).toEqual(JSON.stringify(postParams));

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

  21. done();

  22. });

  23. });

  24. it("should send request with CSRF", done => {

  25. const postParams = {

  26. users: [1, 2 ]

  27. };

  28. http[verb](url, postParams, {

  29. contentType: http.HTTP_HEADER_TYPES.text,

  30. includeCsrf: true

  31. }).then(response => {

  32. const [uri, params] = [...stubedFetch.getCall(0).args];

  33. expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();

  34. expect(params.body).toEqual(JSON.stringify(postParams));

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

  36. expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

  37. done();

  38. });

  39. });

  40. it("should send a form-encoded request", done => {

  41. const users = [1, 2];

  42. const limit = 50;

  43. const isDetailed = false;

  44. const postParams = { users, limit, isDetailed };

  45. http[verb](url, postParams, {

  46. contentType: http.HTTP_HEADER_TYPES.form,

  47. includeCsrf: true

  48. }).then(response => {

  49. const [uri, params] = [...stubedFetch.getCall(0).args];

  50. expect(stubedFetch.calledWith(`${url}`)).toBeTruthy();

  51. expect(params.body).toEqual("isDetailed=false&limit=50&users=1&users=2");

  52. expect(params.headers.get("Content-Type")).toEqual(http.HTTP_HEADER_TYPES.form);

  53. expect(params.headers.get("X-CSRF-Token")).toEqual(csrf);

  54. done();

  55. });

  56. });

  57. });

  58. });

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

 
  1. const request = (url, params, options={}, method="post") => {

  2. const {includeCsrf, contentType} = options;

  3. const headers = new Headers();

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

  5. if (includeCsrf) {

  6. headers.append("X-CSRF-Token", getCSRFToken());

  7. }

  8. return fetch(url, {

  9. headers,

  10. method,

  11. body: encodeRequests(params, contentType)

  12. }).then(deserializeResponse)

  13. .catch(error => Promise.reject(new Error(error)));

  14. };

  15. 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 是程序员工具带中的一项有用技能。随着时间的推移,您会对何时应用此方法产生直觉。

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

以上就是实用的测试驱动开发方法的详细内容!

感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取   

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值