首頁 >web前端 >js教程 >實用的測試驅動開發方法

實用的測試驅動開發方法

WBOY
WBOY原創
2023-09-03 17:05:10563瀏覽

實用的測試驅動開發方法

什麼是測試驅動開發?

測試驅動開發(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 下建立一個隨附的測試文件。

讓我們為此服務設定一個測試環境。

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 啟動測試運行程序。現在測試顯然會失敗,因為 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);
};

如果您現在執行測試,您將看到失敗的回應,顯示 預期 [object Response] 等於 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 方法现在已经成型,但是在发送正文时仍然很简单。您必须针对每种内容类型以不同的方式处理数据。处理表单时,我们应该在通过网络发送数据之前将数据编码为字符串。

   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 请求。如果您遇到困难,请随时参考该存储库。

何时进行 TDD?

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

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

总结

关于这个话题有很多噪音,而且很容易迷失方向。如果我能给你一些临别建议的话:不要太担心 TDD 本身,而要关注基本原则。这一切都是为了编写干净、易于理解、可维护的代码。 TDD 是程序员工具带中的一项有用技能。随着时间的推移,您会对何时应用此方法产生直觉。

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

以上是實用的測試驅動開發方法的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn