What Is Test-Driven Development?
Test-driven development (TDD) simply means that you write your tests first. You set the expectations for correct code up front, before you have even written a single line of business logic. Not only does TDD help make sure that your code is correct, but it also helps you write smaller functions, refactor your code without breaking functionality, and understand your problem better.
In this article, I'll introduce some of the concepts of TDD by building a small utility. We will also cover some of the practical scenarios where TDD will make your life simple.
Building an HTTP Client With TDD
What We'll Be Building
We'll be incrementally building a simple HTTP client that abstracts various HTTP verbs. To make the refactors smooth, we will follow TDD practices. We will be using Jasmine, Sinon, and Karma for testing. To get started, copy package.json, karma.conf.js, and webpack.test.js from the sample project, or just clone the sample project from the GitHub repo.
It helps if you understand how the new Fetch API works, but the examples should be easy to follow. For the uninitiated, the Fetch API is a better alternative to XMLHttpRequest. It simplifies network interactions and works well with Promises.
A Wrapper Over GET
First, create an empty file at src/http.js and an accompanying test file under src/__tests__/http-test.js.
Let's set up a test environment for this service.
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" } }); } }); }); });
We're using both Jasmine and Sinon here—Jasmine to define the test scenarios and Sinon to assert and spy on objects. (Jasmine has its own way to spy and stub on tests, but I like Sinon's API better.)
The above code is self-explanatory. Before every test run, we hijack the call to the Fetch API, as there is no server available, and return a mock promise object. The goal here is to unit test if the Fetch API is called with the right params and see if the wrapper is able to handle any network errors properly.
Let's start with a failing test case:
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(); }); }); });
Start your test runner by calling karma start
. The tests will obviously fail now, since there is no get
method in http
. Let's rectify that.
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); };
If you run your tests now, you'll see a failed response saying Expected [object Response] to equal Object({ })
. The response is a Stream object. Stream objects, as the name suggests, are each a stream of data. To get the data from a stream, you need to read the stream first, using some of its helper methods. For now, we can assume that the stream will be JSON and deserialize it by calling 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))); };
Our test suite should be green now.
Adding Query Parameters
So far, the get
method just makes a simple call without any query params. Let's write a failing test to see how it should work with query parameters. If we pass { users: [1, 2], limit: 50, isDetailed: false }
as query params, our HTTP client should make a network call to /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(); }) });
Now that we have our test set up, let's extend our get
method to handle query params.
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))); };
If the params are present, we construct a query string and append it to the URL.
Here I've used the query-string library—it's a nice little helper library that helps in handling various query params scenarios.
Handling Mutations
GET is perhaps the simplest of HTTP methods to implement. GET is idempotent, and it should not be used for any mutations. POST is typically meant to update some records in the server. This means that POST requests need some guardrails in place by default, like a CSRF token. More on that in the next section.
Let's start by constructing a test for a basic POST request:
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(); }); }); });
The signature for the POST is very similar to GET. It takes an options
property, where you can define the headers, body and, most importantly, method
. The method describes the HTTP verb—in this case, "post"
.
For now, let's assume that the content type is JSON and start our implementation of the POST request.
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), }); };
At this point, our post
method is very primitive. It doesn't support anything other than a JSON request.
Alternate Content Types and CSRF Tokens
Let's allow the caller to decide the content type, and throw the CSRF token into the fray. Depending on your requirements, you can make CSRF optional. In our use case, we will assume that this is an opt-in feature and let the caller determine if you need to set a CSRF token in the header.
To do this, start by passing an options object as the third parameter to our method.
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(); }); });
When we supply options
with {contentType: http.HTTP_HEADER_TYPES.text,includeCsrf: true
, it should set the content header and the CSRF headers accordingly. Let's update the post
function to support these new options.
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' }
Note that getting the CSRF token is an implementation detail. Usually, it's part of your session cookie, and you can extract it from there. I won't cover it further in this article.
Your test suite should be happy now.
Encoding Forms
Our post
method is taking shape now, but it's still trivial when sending the body. You'll have to massage your data differently for each content type. When dealing with forms, we should encode the data as a string before sending it across the wire.
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(); }); });
Let's extract a small helper method to do this heavy lifting. Based on the contentType
, it processes the data differently.
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))); };
Look at that! Our tests are still passing even after refactoring a core component.
Handling PATCH Requests
Another commonly used HTTP verb is PATCH. Now, PATCH is a mutative call, which means that its signature of these two actions is very similar. The only difference is in the HTTP verb. We can reuse all the tests that we wrote for POST, with a simple tweak.
['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(); }); }); }); });
Similarly, we can reuse the current post
method by making the verb configurable, and rename the method name to reflect something generic.
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');
Now that all our POST tests are passing, all that's left is to add another method for patch
.
export const patch = (url, params, options = {}) => request(url, params, options, 'patch');
Simple, right? As an exercise, try adding a PUT or DELETE request on your own. If you're stuck, feel free to refer to the repo.
When to TDD?
The community is divided on this. Some programmers run and hide the moment they hear the word TDD, while others live by it. You can achieve some of the beneficial effects of TDD simply by having a good test suite. There is no right answer here. It's all about how comfortable you and your team are with your approach.
As a rule of thumb, I use TDD for complex, unstructured problems that I need more clarity on. While evaluating an approach or comparing multiple approaches, I find it helpful to define the problem statement and the boundaries up front. It helps in crystallizing the requirements and the edge cases that your function needs to handle. If the number of cases is too high, it suggests that your program may be doing too many things and maybe it's time to split it into smaller units. If the requirements are straightforward, I skip TDD and add the tests later.
Wrapping Up
There is a lot of noise on this topic, and it's easy to get lost. If I can leave you with some parting advice: don't worry too much about the TDD itself, but focus on the underlying principles. It's all about writing clean, easy-to-understand, maintainable code. TDD is a useful skill in a programmer's tool belt. Over time, you'll develop an intuition about when to apply this.
Thanks for reading, and do let us know your thoughts in the comments section.
No comments:
Post a Comment