Test-Driving a new feature for JavaScript

Earlier this week I had a piece of JavaScript that I wanted to like this:

var index = someArray.indexOf(someObject);

The problem there is that the indexOf method of the Array object was introduced in JavaScript 1.6, which isn't implemented in all browsers (actually, IE seems to be the real problem here).

Anyway, thanks to the awesomeness of dynamic typing and prototypeal inheritance in JavaScript, we can fix that ourselves with code similar to the below.

if (!Array.prototype.indexOf) {
  Array.prototype.indexOf = function(item) {
    // implementation goes here
  };
}

Actually, if you're just looking for the final solution you can find it easily with your search engine of choice or skip to the bottom of this article.

But, instead of going through the normal trial and error approach, I chose to flex my TDD muscle and try to create this method using a test-first routine. For this exercise, I chose QUnit as the unit testing framework.

I started with a standard empty testing HTML file where I'll put my test cases and a reference to my-library.js where I'll add this new method.

<html&gt
  <head>
    <title>Tests for mylibrary.js</title>
    <link href="testsuite.css" rel="stylesheet" type="text/css" />
    <script src="jquery.js" type="text/javascript"></script>
    <script src="testrunner.js" type="text/javascript"></script>
    <script src="my-library.js" type="text/javascript"></script>
  </head>
  <body>
    <h1>Tests for mylibrary.js</h1>
    <h2 id="banner">
      <span style="color:#fff;">Result: Red/Green?</span>
    </h2>
    <h2 id="userAgent"></h2>
    <ol id="tests"></ol>
     <!--
        The #main element is like your test fixture. It's like the 
		data for your tests.     
        Whatever exists inside the #main element gets restored 
        before each test.
        Feel free to manipulate the contents of #main in your tests.
      -->
    <div id="main">
    </div>
  </body>
</html>

You can put your test cases in a separate .js file and reference it here but I'll just add the necessary JavaScript to the HTML file itself in this exercise.

Here are the test cases I'm going to support:

  1. Missing items return -1
  2. Existing item is found
  3. Existing item is found with positive starting index
  4. Existing item is not found with positive starting index
  5. Existing item is found with negative starting index
  6. Existing item is not found with negative starting index
  7. Comparison is non-coercive

Let's start with the first test: Missing items return -1. It's a simple value comparison. At this point I do not have anything written yet for the indexOf function, it doesn't even exist if you're on IE. Add the following to the HTML file.

$(function() {
  var list = [11, 22, 33, 44];
  
  module('Array.indexOf');
  
  test('Missing items return -1', function() {
    equals(list.indexOf(1234), -1);
  });

});

When you open the HTML file on IE (I'm using IE8), you'll see this:

Totally expected, but let's try to make this test pass. In the my-library.js file, let's add the simplest code that can satisfy that test.

if (!Array.prototype.indexOf) {
    Array.prototype.indexOf = function() {
      return -1;
    };
}

The outer if is there just to prevent replacing the function if it already exists. The function is clearly incomplete, but that's not the point. The point is that it passes the test:

Great. Time for the second test: Existing item is found. For that one we will need to loop through the items in the array. The array is this in the method. Here's the test, which we add right after the first one.

test('Existing item is found', function() {
  equals(list.indexOf(22), 1);
});

And of course that fails

Time to make it pass.

if (!Array.prototype.indexOf) {
  Array.prototype.indexOf = function(item) {
    for (var i=0; i<this.length; i++) {
      if (this[i] == item) {
        return i;
      }
    }
    return -1;
  };
}

And it passes:

Now let's tackle the third test case: Existing item is found with positive starting index. This one starts to make things interesting. Here's the test.

test('Existing item is found with positive starting index', function() {
  //let's try a few boundary conditions
  equals(list.indexOf(33, 0), 2);
  equals(list.indexOf(33, 1), 2);
  equals(list.indexOf(33, 2), 2);
});

Hmmm. The test passes, as we can see below. Well, that was kind of an accident but I'll leave the test as is because it does test what was specified. Leaving the test will help us if we make changes that break this specification.

The next test case is Existing item is not found with positive starting index. We could write it like this:

test('Existing item is not found with positive starting index', function() {
  equals(list.indexOf(33, 3), -1);
  equals(list.indexOf(33, 1000), -1);
});

And boom! It fails. It should, we don't even have support for that second parameter yet.

Failing it is, pass it we must. First attempt:

if (!Array.prototype.indexOf) {
  Array.prototype.indexOf = function(item, startIndex) {
    for (var i=startIndex; i<this.length; i++) {
      if (this[i] == item) {
        return i;
      }
    }
    return -1;
  };
}

It passes the new test, but it fails another one:

That's actually pretty cool. We would have created a mess if we had just added the second parameter without some regression testing. Hooray for unit tests!

What's happening is that my startIndex defaults to undefined if not passed by the caller. It should default to zero. Easy fix.

if (!Array.prototype.indexOf) {
  Array.prototype.indexOf = function(item, startIndex) {
    startIndex = startIndex || 0;
    for (var i=startIndex; i<this.length; i++) {
      if (this[i] == item) {
        return i;
      }
    }
    return -1;
  };
}

I see the green light!

Our next test case is up: Existing item is found with negative starting index. What we are trying to do here is allow the caller to specify the starting position as an offset from the last item using a negative number. So in a 4-element array, passing -1 means that we start at the last item and passing -3 we start at the second item. See the test.

test('Existing item is found with negative starting index', function() {
  equals(list.indexOf(33, -2), 2);
  equals(list.indexOf(33, -3), 2);
  equals(list.indexOf(33, -1000), 2);
});

Once again it passes by accident.

The next text case will show that it was an accident: Existing item is not found with negative starting index. The test for that is below:

test('Existing item is not found with negative starting index', function() {
  equals(list.indexOf(33, -1), -1);
  equals(list.indexOf(11, -3), -1);
});

It fails:

Making it pass is not that hard, we just need to compute a positive version of that negative start index.

if (!Array.prototype.indexOf) {
  Array.prototype.indexOf = function(item, startIndex) {
    startIndex = startIndex || 0;
    
    if (startIndex < 0) {
      startIndex += this.length;
    }
    
    for (var i=startIndex; i<this.length; i++) {
      if (this[i] == item) {
        return i;
      }
    }
    return -1;
  };
}

Ding, ding, ding! Another passing test for our team.

Lastly, we need to make sure that the Comparison is non-coercive. By that we mean, a number will never equal a string — or generally speaking, the things being compared must be of the same type. Let's get some tests that try to point out tha flaw in our current code.

test('Comparison is non-coercive', function() {
  equals(list.indexOf('22'), -1);
});

This fails because, strictly speaking, 22 and '22' are two different things and we didn't want to match that element like we did.

On to the fix. Can you spot the difference? Try harder.

if (!Array.prototype.indexOf) {
  Array.prototype.indexOf = function(item, startIndex) {
    startIndex = startIndex || 0;
    
    if (startIndex < 0) {
        startIndex += this.length;
    }
    
    for (var i=startIndex; i<this.length; i++) {
        if (this[i] === item) {
          return i;
        }
    }
    return -1;
  };
}

And here is the QUnit report in all its successful glory.

If you've never been exposed to TDD this may have felt awkward but hopefully you noticed that it's more of an evolutionary design approach, as opposed to trying to make it perfect on the first attempt or even reckless patching.

It also left us with a valuable collection of tests that we can execute after making changes to the code, ensuring we didn't break anything by accident.


Posted 11-12-2009 7:16 PM by sergiopereira

[Advertisement]

Comments

DotNetKicks.com wrote Test-Driving a new feature for JavaScript
on 11-12-2009 10:49 PM

You've been kicked (a good thing) - Trackback from DotNetKicks.com

DotNetShoutout wrote Test-Driving a new feature for JavaScript
on 11-12-2009 10:53 PM

Thank you for submitting this cool story - Trackback from DotNetShoutout

redsquare wrote re: Test-Driving a new feature for JavaScript
on 11-13-2009 7:22 AM

Have you looked at documentcloud.github.com/underscore.

All the work already done

sergiopereira wrote re: Test-Driving a new feature for JavaScript
on 11-13-2009 8:50 AM

@redsquare, yes I have. For the project where I had this little issue I didn't want to add another library since we have jQuery loaded. But all that is beside the point of the blog, which was to demonstrate the TDD workflow for solving a well-understood problem.

About The CodeBetter.Com Blog Network
CodeBetter.Com FAQ

Our Mission

Advertisers should contact Brendan

Subscribe
Google Reader or Homepage

del.icio.us CodeBetter.com Latest Items
Add to My Yahoo!
Subscribe with Bloglines
Subscribe in NewsGator Online
Subscribe with myFeedster
Add to My AOL
Furl CodeBetter.com Latest Items
Subscribe in Rojo

Member Projects
DimeCasts.Net - Derik Whittaker

Friends of Devlicio.us
Red-Gate Tools For SQL and .NET

NDepend

SlickEdit
 
SmartInspect .NET Logging
NGEDIT: ViEmu and Codekana
LiteAccounting.Com
DevExpress
Fixx
NHibernate Profiler
Unfuddle
Balsamiq Mockups
Scrumy
JetBrains - ReSharper
Umbraco
NServiceBus
RavenDb
Web Sequence Diagrams
Ducksboard<-- NEW Friend!

 



Site Copyright © 2007 CodeBetter.Com
Content Copyright Individual Bloggers

 

Community Server (Commercial Edition)