Constellation background with JS and TDD

Gildardo Adrian Maravilla
15 min readNov 28, 2016

--

Constellation background

So here goes the story … you saw a website with a constellation background. How do they do that? In this post we are going to make an animated constellation background using TDD.

We are going to work with the browser and the easy way to play with it is using Codepen. Create a new pen, give it a name and save it. Why do we need to save an empty pen?, you may ask. To work using TDD we need to use two pens at the same time. One for the code and one for the tests. In the second pen add the following:

<div id="qunit"></div>
<div id="qunit-fixture"></div>

Open the settings. Add https://code.jquery.com/qunit/qunit-2.0.1.css to the pen as external CSS and https://code.jquery.com/qunit/qunit-2.0.1.js to the JS. Also add the URL of the empty pen. Now the code can be tested, even if there’s no code yet.

Make sure that QUnit is loaded with the following code:

QUnit.test( "hello test", function( assert ) {
assert.ok( 1 == "1", "Passed!" );
});

You should see something like this if everything is fine:

QUnit tests

What is the first thing we want to test? The existence of an object called constellation.

QUnit.test("Constellation object exists", function( assert ) {
assert.ok( window.constellation, "Constellation object exists" );
});
Failed test: Constellation object does not exist.

Now let’s create the object. Remember, with TDD you should write the minimum code necessary to pass the test.

(function(root) {
var constellation = function () {};
root.constellation = constellation;
})(window)
First test passing

Rerun the test suite every time you add a new test and every time you save the library pen. Now that we have a constellation object we need to think what else to test. Because constellation it’s a module, we can include a star class. Before writing more tests, let’s think what a star should be able to do.

  • Star should have a position (x, y).
  • Star should have a velocity.
  • Star should have a move method.
  • Star should have a draw method.

Now let’s think about the constellation module.

  • Constellation module should draw starts.
  • Constellation module should connect stars to form a constellation-like figure.

This is the basic behavior we expect from our objects, we can add more features later. We are going to implement our requirements one by one. The first test checks the existence of the star class. The testing code is like this (note that I refactored the test suite to group them):

QUnit.module("Existence of objects");QUnit.test( "Constellation object exists", function( assert ) {
assert.ok( window.constellation, "Constellation object exists" );
});
QUnit.test("Star class exists", function( assert ) {
assert.ok( window.constellation.star, "Star class exists" );
});

Just add a new function inside the Constellation module to pass the test:

(function(root) {
var constellation = function () {
var star = function() {} return {
star: star
}
}();
root.constellation = constellation;
})(window);

The next test checks the position of the star:

QUnit.module("Star class");QUnit.test("Star has a position defined", function( assert ) {
var star = new constellation.star(0, 0);
assert.equal(star.x, 0, "X-coordinate should be 0" );
assert.equal(star.y, 0, "Y-coordinate should be 0" );
});

The test fails as expected. From the call we see that star should accept two parameters to set the initial position. The coordinates are part of the public API. Don’t worry about that for now, at the end we’re going to make them “private”. A simple modification to the star class should pass the test.

  var star = function(x, y) {
this.x = x;
this.y = y;
}

The velocity requirement works almost the same.

QUnit.test("Star has a velocity defined", function( assert ) {
var star = new constellation.star(0, 0, 1, 1);
assert.equal(star.vx, 1, "X-velocity should be 1" );
assert.equal(star.vy, 1, "Y-velocity should be 1" );
});

The code we need is trivial.

  var star = function(x, y, vx, vy) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
}

Before move to the methods, look close at the code. What happens if we create a new star without parameters? Let’s set default values for the position and velocity:

QUnit.test("Star has a default position and velocity", function( assert ) {
var star = new constellation.star();
assert.equal(star.x, 0, "X-coordinate should be 0" );
assert.equal(star.y, 0, "Y-coordinate should be 0" );
assert.equal(star.vx, 1, "X-velocity should be 1" );
assert.equal(star.vy, 1, "Y-velocity should be 1" );
});

By default parameters are undefined. This makes easy to check their values and use fallback ones.

  var star = function(x, y, vx, vy) {
this.x = x || 0;
this.y = y || 0;
this.vx = vx || 1;
this.vy = vy || 1;
}

Now we can implement the first method, move. This method will change the position by the velocity amount.

QUnit.test("Star has a move method", function( assert ) {
var star = new constellation.star(5, 6, 2, 4);
star.move();
assert.equal(star.x, 7, "X-coordinate should be 7" );
assert.equal(star.y, 10, "Y-coordinate should be 10" );
});

Because we’re going to use this function with all the stars, the function will live in the prototype. Don’t make copies of functions when they can be shared across all the objects.

star.prototype.move = function() {
this.x += this.vx;
this.y += this.vy;
}

So far, we have half of the requirements list done. The real problem begins with the draw method. How we should draw the stars? We’re going to use a canvas to draw them. Update the requirements list:

  • Star should have a position (x,y).
  • Star should have a velocity to more around.
  • Star should have a move method.
  • Star should have a draw method.
  • Constellation must receive a canvas element.
  • Constellation must set a 2d context.
  • Constellation module should draw starts.
  • Constellation module should connect stars to form a constellation-like figure.

We can check the canvas element using the QUnit fixture. Modify the HTML in the pen with the following:

<div id="qunit-fixture">
<canvas id="constellation-bg"></canvas>
</div>

Our expectations are the following: pass an element’s id, check if the element exists, use it as the module’s canvas and return true. If the element doesn’t exist or the module is initialized without one, the function should throw an error. The first criteria is checked like this:

QUnit.module("Constellation module");QUnit.test("Initialize Constellation module", function( assert ) {
assert.ok(constellation.init("constellation-bg"), "Constellation is initialized with a canvas id");
});

The Constellation module has a new function:

    var init = function (id) {
var element = document.getElementById(id);
if (element) {
canvas = element;
return true;
}
}
return {
init: init,
star: star
}

This function works because there is a canvas with the id. The wrong conditions, passing a bad id or not passing one, throw an exception. Adding one test and make it pass will make the other one also pass. This is a part of the TDD process where we work ahead of time, but don’t believe me and implement it.

QUnit.test("Initialize Constellation module with a bad id", function( assert ) {
assert.throws(function() {
constellation.init("canvas");
}, "Constellation should throw an exception when a bad id is given");
});

The logic is simple, just add an else clause:

      if (element) {
canvas = element;
return true;
} else {
throw "Error setting the canvas";
}

The test without an id should pass as well. Usually you want to go from green to red, so lets write two test at once. To draw in the canvas we need a drawing context. That’s our next goal. If you add tests one by one, you’ll see the first one passes without touching the code.

QUnit.test("Initialize Constellation module without id", function( assert ) {
assert.throws(function() {
constellation.init();
}, "Constellation should throw an exception when no id is given");
});
QUnit.test("Set Constellation context", function( assert ) {
var context = null;
var canvas = document.getElementById("constellation-bg");
this.canvas = canvas;
canvas.getContext = function() {
canvas.getContext.called = true;
canvas.getContext.args = arguments;
return {};
};
constellation.init("constellation-bg");
constellation.setContext();
assert.ok(canvas.getContext.called, "Get the RenderingContext from canvas");
assert.equal(canvas.getContext.args[0], "2d", "Use a 2d context");
context = constellation.getContext();
assert.equal(context.fillStyle, "#FFF", "Default color is #FFF");
assert.equal(context.strokeStyle, "#FFF", "Default color is #FFF");
assert.equal(context.lineWidth, 1, "Default line width is 1");
canvas = this.canvas;
});

The second test is more complex than the previous. Here we look for a rendering context. To verify the behavior of the function we mock the canvas and inspect what is happening behind the scene. Also note that the options are hard coded. Later we’ll add the ability to pass options, right now we use a set of default values. We know exactly what the implementation should be:

    var setContext = function() {
context = canvas.getContext("2d");
context.fillStyle = "#FFF";
context.strokeStyle = "#FFF";
context.lineWidth = 1;
}

var getContext = function() {
return context;
}
return {
init: init,
star: star,
setContext: setContext,
getContext: getContext
}

Before moving on, a quick refactoring. Just like we created a function to set the context, we can create a function to set the canvas element.

QUnit.test("Set Constellation canvas ", function( assert ) {
assert.ok(constellation.setCanvas("constellation-bg"), "Constellation sets the canvas using the id");
});

The new function is like the code we have in the init function:

    var setCanvas = function(id) {
var element = document.getElementById(id);
if (element) {
canvas = element;
return true;
}
}

Using this new function we can refactor the way the module is initialized:

    var init = function(id) {
if (!setCanvas(id)) {
throw "Error setting the canvas";
}
return true;
}
var setCanvas = function(id) {
var element = document.getElementById(id);
if (element) {
canvas = element;
return true;
}
}
/*Some code */ return {
init: init,
star: star,
setContext: setContext,
setCanvas: setCanvas,
getContext: getContext
}
}();
root.constellation = constellation;
})(window);

Refactor the context test to set the canvas with this function instead of initializing the module. Being able to set the canvas without calling the init method will be handy at later stages. After this change we should check that the context is set at initialization time:

QUnit.test("Set rendering context on Constellation initialization", function( assert ) {
var context = false;
constellation.init("constellation-bg");
context = constellation.getContext();
assert.ok(context, "Context is set on Constellation initialization");
});

This test passes but we didn’t expect that. The problem is that the context was set in a previous test and the module remembers it. We need to go from green to red, and again to green. This is the reason we run tests as we write them, to avoid state bugs.

    var init = function(id) {
canvas = null;
context = null;
if (!setCanvas(id)) {
throw "Error setting the canvas";
}
return true;
}

The test fails like we expected. Set the context before returning the function:

    var init = function(id) {
canvas = null;
context = null;
if (!setCanvas(id)) {
throw "Error setting the canvas";
}
setContext();
return true;
}

Now we can try to test the draw method:

QUnit.test("Star has draw method", function( assert ) {
var canvas = document.getElementById("constellation-bg");
this.canvas = canvas;
var context = {
beginPath: {},
arc: {},
fill: {}
}
canvas.getContext = function() {
return {
beginPath: function() {
context.beginPath.called = true;
},
arc: function() {
context.arc.called = true;
},
fill: function() {
context.fill.called = true;
}
};
};
constellation.setCanvas("constellation-bg");
constellation.setContext();
var star = new constellation.star();
star.draw();
assert.ok(context.beginPath.called, "Begin a path");
assert.ok(context.arc.called, "Draw a dot using the arc function");
assert.ok(context.fill.called, "Fill the dot");
canvas = this.canvas;
});

The function just draw a point. The radius value is hard coded to 1:

    star.prototype.draw = function() {
context.beginPath();
context.arc(this.x, this.y, 1, 0, Math.PI * 2);
context.fill();
}

The next item on the to do list is drawing the stars when the module is initialized but, what stars? We need to create an array holding the module’s stars.

QUnit.test("Constellation module should have a stars array", function( assert ) {
var stars;
constellation.init("constellation-bg");
stars = constellation.getStars();
assert.equal(stars.length, 200, "There should be 200 stars");
});

The function begins with an empty array because otherwise every time the init method is called the array grows.

    var init = function(id) {
var i;
canvas = null;
context = null;
stars = [];
if (!setCanvas(id)) {
throw "Error setting the canvas";
}
setContext();
for (i=0; i<200; i++) {
stars.push(new star());
}
return true;
}

var getStars = function() {
return stars;
}
return {
init: init,
star: star,
setContext: setContext,
context: getContext,
getStars: getStars
}

The module creates 200 stars. The draw method should be called 200 times to draw them all. We have tested the draw method so we just check the number of calls.

QUnit.test("Constellation module should draw the stars", function( assert ) {
var counter = 0;
this.oldStarPrototype = constellation.star.prototype;
constellation.star.prototype.draw = function() {
counter++;
}
window.constellation.init("constellation-bg");
assert.equal(counter, 200, "The module should draw 200 stars");
constellation.star.prototype = this.oldStarPrototype;
});

We can use the for loop used to create them to pass this test.

      for(i=0; i<200; i++) {
stars.push(new star());
stars[i].draw();
}

At this point the full test suite has 17 test doing 27 assertions. Not bad for a pretty simple module.

QUnit.module("Existence of objects");QUnit.test("Constellation object exists", function( assert ) {
assert.ok( window.constellation, "Constellation object exists" );
});
QUnit.test("Star class exists", function( assert ) {
assert.ok( window.constellation.star, "Star class exists" );
});
QUnit.module("Star class");QUnit.test("Star has a position defined", function( assert ) {
var star = new constellation.star(0,0);
assert.equal(star.x, 0, "X-coordinate should be 0" );
assert.equal(star.y, 0, "Y-coordinate should be 0" );
});
QUnit.test("Star has a velocity defined", function( assert ) {
var star = new constellation.star(0,0,1,1);
assert.equal(star.vx, 1, "X-velocity should be 1" );
assert.equal(star.vy, 1, "Y-velocity should be 1" );
});
QUnit.test("Star has a default position and velocity", function( assert ) {
var star = new constellation.star();
assert.equal(star.x, 0, "X-coordinate should be 0" );
assert.equal(star.y, 0, "Y-coordinate should be 0" );
assert.equal(star.vx, 1, "X-velocity should be 1" );
assert.equal(star.vy, 1, "Y-velocity should be 1" );
});
QUnit.test("Star has a move method", function( assert ) {
var star = new constellation.star(5, 6, 2, 4);
star.move();
assert.equal(star.x, 7, "X-coordinate should be 7" );
assert.equal(star.y, 10, "Y-coordinate should be 10" );
});
QUnit.test("Star has draw method", function( assert ) {
var canvas = document.getElementById("constellation-bg");
this.canvas = canvas;
var context = {
beginPath: {},
arc: {},
fill: {}
}
canvas.getContext = function() {
return {
beginPath: function() {
context.beginPath.called = true;
},
arc: function() {
context.arc.called = true;
},
fill: function() {
context.fill.called = true;
}
};
};
constellation.setCanvas("constellation-bg");
constellation.setContext();
var star = new constellation.star();
star.draw();
assert.ok(context.beginPath.called);
assert.ok(context.arc.called);
assert.ok(context.fill.called);
canvas = this.canvas;
});
QUnit.module("Constellation module");QUnit.test( "Initialize Constellation module", function( assert ) {
assert.ok(constellation.init("constellation-bg"), "Constellation is initialized with a canvas id");
});
QUnit.test("Initialize Constellation module with bad id", function( assert ) {
assert.throws(function() {
constellation.init("canvas");
}, "Constellation should throw an exception when a bad id is given");
});
QUnit.test("Initialize Constellation module without id", function( assert ) {
assert.throws(function() {
constellation.init();
}, "Constellation should throw an exception when no id given");
});
QUnit.test("Set Constellation canvas ", function( assert ) {
assert.ok(constellation.setCanvas("constellation-bg"), "Constellation sets the canvas using the id");
});
QUnit.test("Set Constellation context", function( assert ) {
var canvas = document.getElementById("constellation-bg");
this.canvas = canvas;
canvas.getContext = function() {
canvas.getContext.called = true;
canvas.getContext.args = arguments;
return {};
};
constellation.setCanvas("constellation-bg");
constellation.setContext();
assert.ok(canvas.getContext.called, "Get the RenderingContext from canvas");
assert.equal(canvas.getContext.args[0], "2d", "Use a 2d context");
var context = constellation.getContext();
assert.equal(context.fillStyle, "#FFF", "Default color is #FFF");
assert.equal(context.strokeStyle, "#FFF", "Default color is #FFF");
assert.equal(context.lineWidth, 1, "Default line width is 1");
canvas = this.canvas;
});
QUnit.test("Set rendering context on Constellation initialization", function( assert ) {
var context = false;
constellation.init("constellation-bg");
context = constellation.getContext();
assert.ok(context, "Context is set on Constellation initialization");
});
QUnit.test("Constellation module should have a stars array", function( assert ) {
var stars;
constellation.init("constellation-bg");
stars = constellation.getStars();
assert.equal(stars.length, 200, "There should be 200 stars");
});
QUnit.test("Constellation module should draw the stars", function( assert ) {
var counter = 0;
this.oldStarPrototype = constellation.star.prototype;
constellation.star.prototype.draw = function() {
counter++;
}
window.constellation.init("constellation-bg");
assert.equal(counter, 200, "The module should draw 200 stars");
constellation.star.prototype = this.oldStarPrototype;
});

To test and develop at the same time we needed 2 pens. Following this logic you may think that a third pen will be required to use the library, but we can use a little hack to see a demo without another pen:

Write this html:

<canvas id="stars"></canvas>

This css:

#stars {
width: 100%;
height: 100%;
background: #222;
}

And here is the full JS:

(function(root) {
var constellation = function() {
var canvas, context, stars;
var star = function(x, y, vx, vy) {
this.x = x || 0;
this.y = y || 0;
this.vx = vx || 1;
this.vy = vy || 1;
}
star.prototype.move = function() {
this.x += this.vx;
this.y += this.vy;
}

star.prototype.draw = function() {
context.beginPath();
context.arc(this.x, this.y, 1, 0, Math.PI * 2);
context.fill();
}
var setCanvas = function(id) {
var element = document.getElementById(id);
if (element) {
canvas = element;
return true;
}
}
var setContext = function() {
context = canvas.getContext("2d");
context.fillStyle = "#FFF";
context.strokeStyle = "#FFF";
context.lineWidth = 1;
}
var init = function(id) {
var i;
canvas = null;
context = null;
stars = [];
if (!setCanvas(id)) {
throw "Error setting the canvas";
}
setContext();
for (i=0; i<200; i++) {
stars.push(new star());
stars[i].draw();
}
return true;
}

var getStars = function() {
return stars;
}
var getContext = function() {
return context;
}
return {
init: init,
star: star,
setContext: setContext,
setCanvas: setCanvas,
getContext: getContext,
getStars: getStars
}
}();
root.constellation = constellation;
})(window);
try {
constellation.init("stars");
} catch(e) {}

If you run this code you will see a little spot on the corner of your canvas. To make things fun the stars need to have random defaults. Change the defaults test to use random values.

QUnit.test("Star has random defaults", function( assert ) {
var counter = 0;
this.oldRandom = Math.random;
Math.random = function() {
counter++;
}
var star = new constellation.star();
assert.equal(counter, 4);
Math.random = this.oldRandom;
});

We use Math.random 4 times, one for every parameter.

    var star = function(x, y, vx, vy) {
this.x = x || Math.random();
this.y = y || Math.random();
this.vx = vx || Math.random();
this.vy = vy || Math.random();
}

This change breaks a test. This is a expected break because we don’t want stars to be in the corner. Just change the values used in the test and everything will be fine again.

QUnit.test("Star has a position defined", function( assert ) {
var star = new window.constellation.star(1, 1);
assert.equal(star.x, 1, "X-coordinate should be 1" );
assert.equal(star.y, 1, "Y-coordinate should be 1" );
});

If you look at the browser the stars still are in a corner. We want them to be everywhere. The first thing we should check is that there is a canvas when stars are created. Our method doesn’t depend on the canvas and will pass. This is an expected pass.

QUnit.test("Star is created when the canvas is set", function( assert ) {
constellation.setCanvas("constellation-bg");
var star = new constellation.star();
assert.ok(star);
});

To spread the stars all over the canvas just multiply the random value with the canvas size.

    var star = function(x, y, vx, vy) {
var width = canvas.width;
var height = canvas.height;
this.x = x || height * Math.random();
this.y = y || width * Math.random();
this.vx = vx || Math.random();
this.vy = vy || Math.random();
}

Ok, the test suite is broken. Don’t worry, the error is simple. Change the width and height from the canvas size to a hard coded number. Everything should work again. It’s time to chase the bug. We should define the behavior when the canvas is not set, and we’ll use a test to work our way.

QUnit.test("Star is on (0,0) when canvas is not set", function( assert ) {
constellation.setCanvas();
var star = new constellation.star();
assert.equal(star.x, 0, "X-coordinate should be 0");
assert.equal(star.y, 0, "Y-coordinate should be 0");
});

Make the canvas null when setCanvas begins, remove this sanity assignment from init and check for the canvas when the stars are created.

(function(root) {
var constellation = function() {
var canvas, context, stars;
var star = function(x, y, vx, vy) {
var width = 0;
var height = 0;
if (canvas) {
width = canvas.width;
height = canvas.height;
}
this.x = x || height * Math.random();
this.y = y || width * Math.random();
this.vx = vx || Math.random();
this.vy = vy || Math.random();
}
star.prototype.move = function() {
this.x += this.vx;
this.y += this.vy;
}

star.prototype.draw = function() {
context.beginPath();
context.arc(this.x, this.y, 1, 0, Math.PI * 2);
context.fill();
}
var setCanvas = function(id) {
canvas = null;
var element = document.getElementById(id);
if (element) {
canvas = element;
return true;
}
}
var setContext = function() {
context = canvas.getContext("2d");
context.fillStyle = "#FFF";
context.strokeStyle = "#FFF";
context.lineWidth = 1;
}
var init = function(id) {
var i;
context = null;
stars = [];
if (!setCanvas(id)) {
throw "Error setting the canvas";
}
setContext();
for (i=0; i<200; i++) {
stars.push(new star());
stars[i].draw();
}
return true;
}

var getStars = function() {
return stars;
}
var getContext = function() {
return context;
}
return {
init: init,
star: star,
setContext: setContext,
setCanvas: setCanvas,
getContext: getContext,
getStars: getStars
}
}();
root.constellation = constellation;
})(window);
Stars background

Stars in the background, finally. This already a long post so we’ll add the animation in a second part. Go ahead and check the library pen and the test pen.

--

--