by @pontahontas November 2013
A friend asked me the other day - How can I read get-parameters from a URL and parse it into a JavaScript object? And I thought - Perfect opportunity for a little TDD!
I'm assuming you have some knowledge of JavaScript, that you have node installed and that you know your way around a shell such as the Terminal or git-bash.
TDD is a software development process that looks like this:
Want to know more, check this out:
- Test-driven development (wikipedia)
- JavaScript TDD by Ricky Clegg (slides)
- Test-Driven JavaScript Development (book)
- Programming, only better, by Bodil (vimeo)
Take the query string ?taste=sweet%2Bsour&taste=salty%2Bdelicious&taste=frosty&start=&end=
and turn it into a JavaScript object that look like this:
{
taste: [sweet, sour, salty, delicious, frosty],
start: "",
end: ""
}
We'll be using Karma as our test-runner, with the mocha test framework and Chai assertion library. I'll be using my favorite text editor, you can use whichever you like.
Begin by creating a new folder for the project and navigate to it (I named mine queryStringParser)
mkdir queryStringParser
cd queryStringParser
Managing dependencies is a very good thing, but out of scope for this article. However I encourage you to use package.json
, you can read about it here and here.
Install Karma globally, then install the karma-mocha- and the karma-chai-plugin and finally create a karma configuration file. I list my answers below.
npm install -g karma
npm install karma-mocha
npm install karma-chai
karma init
- testing framework: mocha
- Require.js: no
- Capture browser: Chrome
- Location of source/test files: js/*.js
- Location of source/test files: test/**/*-test.js
- Exclude files:
- Watch all the files: yes
Note: If you're on windows, you might have to set an environment variable like CHROME_BIN=%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe
to get the chrome launcher to work. You can read about how to do this here.
Open the newly created file karma.conf.js
in your favorite text editor and add chai to frameworks. If you're on a mac, you'll want the osx-reporter too: npm install karma-osx-reporter
and add it to reporters.
// karma.conf.js
frameworks: ['mocha', 'chai'],
reporters: ['progress', 'osx'], // if you're on a mac
Finally create two folders and two files, js/queryStringParser.js
and test/queryStringParser-test.js
- which correspond to the file pattern we set up in the karma.conf.js
-file.
Try it out by typing karma start
in your shell which should output something like this:
The ERROR
is just saying thet we have no tests yet, so let's take care of that.
The philosophy of test driven development is that you cannot write a single line of code without having written a failing test case first.
We're using the mocha test framework with Chai assertian library and we'll be writing our tests in BDD-flavor. To read up on the Chai-syntax, take a look here and keep it open for reference.
We will create a function that transforms the query string into a js-object, so open up test/queryStringParser-test.js
and enter:
describe("queryStringParser", function() {
it("should be a function", function() {
expect(queryStringParser).to.be.a('function');
});
});
First we describe
what it is we will be testing: "queryStringParser".
Then we describe what it
is we expect: "should be a function".
And last we test if our expect
-ations are true.
Save the file and take a look at the test in your shell - it should fail. To fix it open up js/queryStringParser.js
and enter:
var queryStringParser = function() {};
Take a look at the test again, it should now be green. Awesome! Test driven development FTW! Give yourself a big pat on the back and let's continue. Our approach is to not write any code without first having written a failing test. This means we need another test!
Since we will be parsing a query string we may want to throw an error if the input is not a string. We'll test for that with an asynchronous test, meaning that the test case will not be fulfilled until done()
is called. Notice the done
in function(done)
. Put this test below the first it
.
it("should throw error if input is not a string", function(done) {
try {
queryStringParser();
} catch (e) {
done();
}
});
You can read more about testing asynchronous code here.
var queryStringParser = function(queryString) {
if ("string" !== typeof queryString) {
throw new Error("Input parameter must be string");
}
};
The requirements state that we should "turn it [the query string] into a JavaScript object", so let's make sure the function returns just that.
it("should return an object", function() {
var res = queryStringParser('');
expect(res).to.be.an("object");
});
Notice I'm passing an empty string to querStringParser()
above, because otherwise it will throw the error we specified before. Make sure it's failing, and then make it pass.
var queryStringParser = function(queryString) {
if ("string" !== typeof queryString) {
throw new Error("Input parameter must be string");
}
return {};
};
We only want to make the test pass, nothing more - faithfully abiding to the principle of least effort.
Now let's divide the queryString into key/value-pairs that will be on the returned object.
it("should return object with keys extracted from queryString", function() {
expect(queryStringParser('key=value&prop=thing')).to.have.keys(['key', 'prop']);
});
One small step for the test, a giant leap for our function:
var queryStringParser = function(queryString) {
if ("string" !== typeof queryString) {
throw new Error("Input parameter must be string");
}
var ret = {};
// extract keys
queryString.split('&').forEach(function(keyVal) {
ret[keyVal.split('=')[0]] = "";
});
return ret;
};
And then a test to make sure that the value makes it through as well, we change the test into this:
it("should return object with keys extracted from queryString", function() {
var res = queryStringParser('key=value&prop=thing');
expect(res).to.have.property('key').that.equal('value');
expect(res).to.have.property('prop').that.equal('thing');
});
Make sure the tests are failing, and then
// extract key/value-pairs
queryString.split('&').forEach(function(keyVal) {
var keyValArr = keyVal.split('='),
key = keyValArr[0],
val = keyValArr[1];
ret[key] = val;
});
it("should remove the initial question mark from queryString", function() {
expect(queryStringParser("?key=val")).to.have.property("key");
});
I'm using slice
for this but you can choose to use substr
or substring
if you wish. Take a look at these performance tests before making up you mind. Place this code above the forEach.
if (queryString.charAt(0) === "?") {
queryString = queryString.slice(1);
}
it("should replace each escaped sequence in the encoded URI component", function() {
var author = "Arthur C. Clarke",
res = queryStringParser("?author=" + encodeURIComponent(author));
expect(res.author).to.equal(author);
});
For this we'll be using decodeURIComponent.
if (queryString.charAt(0) === "?") {
queryString = decodeURIComponent(queryString.slice(1));
} else {
queryString = decodeURIComponent(queryString);
}
Now it appears the friend (our requirements) wishes us to split values containing %2B
(decoded to +
) into an array. I say ok.
it("should turn +-separated values into array", function() {
var letters = "A+B+C+D",
res = queryStringParser("?letters=" + encodeURIComponent(letters));
expect(res.letters).to.eql(letters.split("+"));
});
Note: to.eql
can also be expressed as to.deep.equal
// meanwhile in the forEach...
if (/\+/.test(val)) {
val = val.split("+");
}
ret[key] = val;
According to the friend it should be possible to input the same key several times which should then append that value to the previous array.
it("should concatenate values to keys that already hold an array", function() {
var res = queryStringParser("nums=1%2B2&nums=3%2B4");
expect(res.nums).to.eql(['1', '2', '3', '4']);
});
Investigate, then concatenate!
if (ret[key] && Array.isArray(ret[key])) {
val = ret[key].concat(val);
}
ret[key] = val;
I'm aware this will not work if the first value only holds one value (no plus-sign), but I'm leaving it like this for now because this solution is sufficient to meet our requirements. And when they change, we add more tests.
One final test to see that we meet the requirements.
it("should meet the requirements", function() {
var str = '?taste=sweet%2Bsour&taste=salty%2Bdelicious&taste=frosty&start=&end=',
res = queryStringParser(str);
expect(res).to.have.property('taste')
.that.eql(['sweet', 'sour', 'salty', 'delicious', 'frosty']);
expect(res).to.have.property('start').that.equal("");
expect(res).to.have.property('end').that.equal("");
});
Hallelujah, it's working! Praise the test driven JavaScript flying spaghetti monster!
With all those tests making sure our code is working you can go ahead and re-factor it in a worry-free fashion! Below are links to the latest version of the files.
-
Maybe my friend decides he wanna be able to throw a whole URL on the function, then our question-mark-splice-remover no longer holds up. Try to add a test case for this and then improve the code to handle whole URL's as well.
-
What if the new value is an array but not the old? Try writing a test case that account for that and then make it work.
This was one very simple function, but when you are working with, let's say objects and methods, then it's a good idea to nest the describe
's so that they reflect the structure of the code. I put a hash before method names and a dot before property names and I also frequently make use of beforeEach
which executes before each test. Below is an example to give you an idea. You can read more about it on mocha's website.
I also highly recommend trying out sinonjs which gives you spies
, mocks
, stubs
and a whole range of other tools that make your testing days easier than winning a tanning contest with a true nerd.
// test/models/person-test.js
describe("Person", function() {
var person;
beforeEach(function() {
// one fresh instance of Person before each test
person = new Person;
});
describe("#ctor", function() { // constructor
it("should set correct properties", function() {
expect(person.friends).to.be.an("array").with.length(0);
expect(person.age).to.equal(0);
});
});
describe("#fullName", function() { // method
it("should return full name", function() {
person.set('firstName', 'Donald');
person.set('lastName', 'Duck');
expect(person.fullName()).to.equal('Donald Duck');
});
});
describe(".version", function() { // property
it("should be 1.0.1", function() {
expect(Person.version).to.equal('1.0.1');
});
});
});
In the beginning of my testing days I found it hard to define what and how to test. I spent a lot of time trying to figure out what to test, how to test it and how to write good test descriptions. I'm still working on this, but it's more easy now, it comes naturally and I don't feel that it is slowing me down. Actually I find myself thinking about how to write tests first.
So I ask you, in the words of Jamie Walters: Hold on, till you feel a little stronger. Hold on, to TDD.
And when the initial tests are in place, refactoring is a breeze, and coming back to an old piece of code I don't have to be afraid of breaking it. It keeps me from chewing on my knuckles, throwing furniture around and harassing my environment, it's a peace-keeper and someday I hope the Nobel commity will acknowledge this.
Don't hesitate to give me feedback, questions and suggested improvements. If something was unclear or not working for you, I'll try and answer as fast as I can. Thank you for reading.
//Pontus, hontas, @pontahontas