When I was writing the unit test for Anchorific.js, I was having a hard time writing test for the ScrollSpy implementation as there was not many documentation on testing for scrolling event. Therefore, I decided to come up with my own solution, which is not perfect, but it does a great job in doing what I wanted it to do. So, lets assume that we have a simple ScrollSpy plugin that will update the active state of the navigation based on your scroll position. Just have a look at the demo page of Anchorific.js, as Scrollspy.js was extracted from it.
The plugin code is quite simple, as you can see below.
/*
Simple ScrollSpy implementation by Ren Aysha
It updates the active links of the navigation based on your scroll position.
When a header tag is at the top of the viewport, it will highlight the navigation link that
has the href value of the header's ID.
Usage: $( '.content' ).scrollspy();
.content should contain several header tags - h1, h2, h3... Should have a navigation list with a
href value that corresponds to each header tag's ID.
Example:
<div class="content">
<h1 id="hello-world">Hello World<h1>
<p>Some lorem ipsum shit</p>
<h1 id="hello-people">Hello People<h2>
<p>Some lorem ipsum shit</p>
</div>
<div class="navigation">
<ul>
<li><a href="#hello-world">Hello World</a></li>
<li><a href="#hello-people">Hello People</a></li>
<ul>
</div>
*/
if ( typeof Object.create !== 'function' ) {
Object.create = function( obj ) {
function F() {}
F.prototype = obj;
return new F();
};
}
(function( $, window, document, undefined ) {
"use strict";
var ScrollSpy = {
init: function( options, elem ) {
var self = this;
self.elem = elem;
self.$elem = $( elem );
self.headers = self.$elem.find( 'h1, h2, h3, h4, h5, h6' );
self.spy();
},
spy: function() {
var self = this, previous, current, list, top, prev;
$( window ).scroll( function( e ) {
// get all the header on top of the viewport
current = self.headers.map( function( e ) {
if ( ( $( this ).offset().top - $( window ).scrollTop() ) < 10 ) {
return this;
}
});
// get only the latest header on the viewport
current = $( current ).eq( current.length - 1 );
if ( current && current.length ) {
// get all li tag that contains href of # ( all the parents )
list = $( 'li:has("a[href="#' + current.attr( 'id' ) + '"]")' );
if ( prev !== undefined ) {
prev.removeClass( 'active' );
}
list.addClass( 'active' );
prev = list;
}
});
}
};
$.fn.scrollspy = function( options ) {
return this.each(function() {
if ( ! $.data( this, 'scrollspy' ) ) {
var spy = Object.create( ScrollSpy );
spy.init( options, this );
$.data( this, 'scrollspy', spy );
}
});
};
})( jQuery, window, document );
Testing scrolling event by using iFrame
My idea is to use iFrame when we are testing out scrolling events. So, it is important for us to create a HTML file where we can construct some header tags and use the plugin on it. It will look like this:
<!-- index.html -->
<body>
<div class="content">
<h1 id="hello-world">Hello World<h1>
<p>Some lorem ipsum shit. Obviously, this needs to be longer as we need to ensure that it can scroll down...</p>
<h1 id="hello-people">Hello People<h2>
<p>Some lorem ipsum shit. Obviously, this needs to be longer as we need to ensure that it can scroll down...</p>
</div>
<div class="navigation">
<ul>
<li><a href="#hello-world">Hello World</a></li>
<li><a href="#hello-people">Hello People</a></li>
<ul>
</div>
<script src="src/scrollspy.js"></script>
<!-- Needed for unit test -->
<script type="text/javascript">
var c = $( '.content' ).scrollspy(), people = $( 'h1#hello-people');
// This is our test case, lets try jumping to hello-people, the test should be able to tell us that one link is highlighted
$( 'html, body' ).animate({
scrollTop: people.offset().top
}, 3);
</script>
</body>
Notice that I am adding another block of code; I didn’t just stop at calling the scrollspy() method. I wrote a test fixture which will scroll down to the ‘hello people’ section – h1#hello-people will be just right at the edge of the view port when this code runs. This will cause the code below
<li><a href="#hello-people">Hello People</a></li>
to be flagged with an active class.
Now, we need to do is call the index.html with an iFrame tag under the #qunit-fixture element:
<div id="qunit-fixture">
<!-- To test scrollspy; iframe is needed in order to simulate scrolling -->
<div id="scrollspy">
<iframe src="../index.html" width="1200" height="1000" id="scroll">
</iframe>
</div>
Finally, we will write the test. The test will check how many links are highlighted. Highlighted links should contain the class ‘active’. Since we had written a test fixture that jumps straight to h1#hello-people, the navigation should only have one link that is active:
asyncTest( 'Scrollspy highlights links correctly when scroll', function() {
setTimeout(function() {
// iFrame has an ID of #scroll
var s = $( '#scroll' ).contents().find( '.navigation' ).find( 'li.active' );
equal( s.length, 1, "No of links highlighted is correct when scroll to h1#hello-people" );
start();
}, 100 );
});
We are using asyncTest() instead of the usual test() method because the link will not get highlighted immediately; it takes a bit of delay for the link to be flag with .active, though it is not for long.
The Downside
You need a local server (MAMP, XAMPP, etc) in order to run the test. Why? Well, try and run the test without a local server. Console.log will tell you:
- Blocked a frame with origin “null” from accessing a frame with origin “null”. Protocols, domains, and ports must match.
- Uncaught SecurityError: Blocked a frame with origin “null” from accessing a frame with origin “null”. Protocols, domains, and ports must match.
Why exactly? Because of Same-Origin Policy. For security reasons, it can only run when it originates from the same site. Specifically, it should have the same protocols, domain name, and port number. Therefore, you need to run the test in a local web server. For mamp, usually it is localhost:8888/scrollspy/test.html. You can also create an application with Node.js and put your test files in the application folder, start the server, and navigate to your test document.
A solution without local web server?
Luckily, there is one. I am using Grunt.js, a JavaScript task runner and you need to install Node.js in order to install Grunt. Grunt has a lot of plugins and one of them is Qunit.js. You need to configure it in your Gruntfile.js and run the test through the terminal by typing grunt or grunt qunit.
So how exactly do we use Grunt.js? That will be another post on its own!
You have another idea of doing this? I would love to hear it out.