Wednesday, October 17, 2018

Practical Test-Driven Development

Practical Test-Driven Development

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.jsonkarma.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. 

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:

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.

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()

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.  

Now that we have our test set up, let's extend our get method to handle query params.

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:

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. 

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. 

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.

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. 

Let's extract a small helper method to do this heavy lifting. Based on the contentType, it processes the data differently. 

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. 

Similarly, we can reuse the current post method by making the verb configurable, and rename the method name to reflect something generic. 

Now that all our POST tests are passing, all that's left is to add another method for 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