Test-Driven Progressive Enhancement – A List Apart


Progressive enhancement has become an established best-practice approach to standards-based development. By starting with clean, semantic HTML, and layering enhancements using JavaScript and CSS, we attempt to create a usable experience for everyone: less sophisticated devices and browsers get a simpler but completely functional experience, while more capable ones get the bells and whistles.

Article Continues Below

That’s the theory, at least. But in practice, enhancements are still delivered to most devices, including those that only partially understand them—specifically older browsers and under featured mobile devices. Users of these devices initially receive a perfectly functional page, but it’s progressively “enhanced” into a mess of scripts and styles gone awry, entirely defeating the purpose of the approach.

So how do we build enhanced experiences while making sure all users get a functional site? By testing a device’s capabilities up front, we can make informed decisions about the level of experience to deliver to that device.

Testing for capabilities#section2

Not long ago, we found that we could test portions of a device’s CSS support using simple JavaScript. It started with a simple box model test: we injected an element into the page, applied various styles to the element, and then used JavaScript to check whether the element was rendered properly.

function boxmodel(){ 
    var newDiv = document.createElement('div');
    document.body.appendChild(newDiv);
    newDiv.style.width="20px";
    newDiv.style.padding = '10px';
    var divWidth = newDiv.offsetWidth;
    document.body.removeChild(newDiv);
    return divWidth == 40;
}

Using the function above, we can pose the question, if(boxmodel()) to find out if a browser properly supports the CSS box model. If a feature is properly supported, we can safely enhance our page using this feature.

With this idea in mind, we wrote additional functions to test support for other CSS properties, such as:

  • float
  • clear
  • position
  • overflow
  • line-height

But testing CSS support doesn’t cover everything. Fortunately, we can also use techniques such as object detection to test a browser’s JavaScript support. We wrote additional functions to test commonly used JavaScript Objects, such as:

  • document.createElement()
  • document.getElementById()
  • xmlHttpRequest()
  • window.onresize()
  • window.print()

We found that by running all of the tests above, we could get a good idea of whether or not a device would properly display the enhanced version of most of our clients’ web applications.

Following up on this premise, I, along with my colleagues at Filament Group, developed testUserDevice.js, which tests the capabilities of a device before providing enhancements. This script runs independently of any JavaScript libraries, weighs in at around 5kb, and executes in five or six milliseconds.

The tests run by testUserDevice.js can be modified, added, or deleted to meet the requirements of a given website. There are even options that allow you to run multiple test cases or a subset of tests to create multi-level user experiences.

Making use of the test results#section3

Integrating testUserDevice.js into a page is simple, just attach the JavaScript file to your page and call the following:

testUserDevice.init();

This will tell testUserDevice to begin running all available tests as soon as the body element is available in the DOM. If all tests return with a passing score, the script will proceed to make enhancements.

For the sake of this article, let’s take a look at a moderately complex form with some opportunities for enhancement. First, we start with the most basic HTML and a simple linear layout.

Demo 1

CSS enhancements#section4

Once a device has successfully passed a given set of tests, testUserDevice.js will enable page enhancements in a number of ways. By default, the following DOM modifications are made:

  • The class name enhanced is added to the body element.
  • Alternate stylesheet links with title attributes of (title=“enhanced”) are enabled.
  • Inversely, all stylesheet links with title attributes of (title=“not_enhanced”) are disabled.

These page modifications provide several hooks that will allow you to set up layers of progressive enhancement. For starters, the body class can be used along with CSS selectors to make small experience enhancements. For example, you might have three divs that are stacked vertically at page load:

body div.example {
     margin: 1em 0;
}

If the user’s browser or device passes the test, these divs could be repositioned into three floated columns:

body.enhanced div.example {
     float: left;
     width: 30%;
     margin: 0;
}
basic linear layout and enhanced floated layout
Divs repositioned into three floated columns.

This works well for pages that contain only a few enhancements, but it wouldn’t make sense to write an entire advanced stylesheet using these conditional selectors. For large enhancements, you can store advanced styles in alternate stylesheets. By giving these alternate stylesheets a title of enhanced, they will be enabled in the event of a passed test. For example, this:

rel="alternate stylesheet" type="text/css" href="https://alistapart.com/article/testdriven/enhanced.css" title="enhanced" />

becomes this:

rel="stylesheet" type="text/css" href="https://alistapart.com/article/testdriven/enhanced.css" title="enhanced" />

Of course, this is nothing more than a basic stylesheet switch, but the important point is that we’ve set up our enhancements to show only in devices that can handle them properly.

As mentioned earlier, testUserDevice.js will also disable any stylesheets specified by a title attribute of (title="not_enhanced"). This may not be necessary in most cases, but if you have basic stylesheets that will conflict with the enhanced ones, this is a nice way to keep them separated.

The following demonstration revisits our form example, now with a layout that is progressively enhanced with CSS in capable devices.

Demo 2

Although the demo above uses relatively simple CSS, it’s still important to test for device capabilities first since the layout may be unusable in a device that renders the CSS improperly.

JavaScript enhancements#section5

Many page enhancements use JavaScript. Fortunately, testUserDevice.js allows you to specify scripting that you would like to execute in your enhanced experience as well. This is done by simply passing in a function as an argument into our script, as follows.

testUserDevice.init( function(){ /* fancy stuff goes here */ } );

As you can see, we’ve passed an anonymous function as an argument to our init method. If our test passes, the scripting enclosed in the function argument will execute immediately. Keep in mind that this is likely to occur before your typical JavaScript library’s DOM ready event will fire, so DOM-related scripting should still be enclosed in an event that waits for elements to be available before executing (such as jQuery’s ready event or the body.onload event).

Our final demo page displays the same form as before, now progressively enhanced using JavaScript (in devices deemed capable) to its full feature set:

  • The birthday field has a date-picker component.
  • The preference selections are now sliders.
  • In the favorite pet fieldset, the “breed” fields are conditionally enabled by the chosen radio item.
  • The “breed” fields use auto-complete.
  • The form now uses Ajax for submission.

Demo 3

Once is all you need…#section6

Upon the completion of a test, testUserDevice.js drops a cookie recording the results of each test case and uses it on future page loads, which means less processing and better performance for users. But the opportunities to optimize don’t stop on the front end: with a little PHP handy work, you can actually look for these cookies on the back-end and serve pages knowing a device’s capabilities already! This means you could serve your page with an enhanced class already on your body element, enable your advanced stylesheets from the start, and execute advanced scripting immediately at page load.

'; ?>

testUserDevice.js includes options for handling custom test cases and adding your own tests.

Writing custom test cases#section8

Using testUserDevice’s default behavior of running a single test case is likely to be enough for most projects, but if you’d like to make multiple experience divisions, the option is available. For example, if we intended to make only two enhancements on our demo page:

  • Form submission via ajax
  • Select menus are converted into sliders

By default, we would test for all of the features required for these enhancements, and then enhance the entire page if the device passes all the tests. In contrast, we may want Ajax to work regardless of whether the selects are sliders or not (and vice versa). This is what I call multiple experience divisions. For this, we’ll need to write a custom test case, such as the following case which only tests a device’s Ajax support.

testUserDevice.init([
    {
        testName: 'ajaxCapable',
        pass: ['ajax'],
        scripting: function(){ useAjax(); }
    }
]);

Instead of passing a function into our init function as we did earlier, we’re now passing an array as an argument instead. This array contains one or more objects each containing three settings: testName (string, name of test), pass (array of test names corresponding to available tests), and scripting (function to execute upon passing test case). These test case objects can be used to make enhancements that only require a subset of features, letting you skip the all-or-nothing test.

Keep in mind that each test case’s testName property will be used to make DOM modifications in the same way our default test used the name “enhanced”. This means that upon passing test case above, our script will add a class of ajaxCapable to the body element, stylesheets will be enabled and disabled based on title attributes containing the word ajaxCapable, and a cookie will be saved as ajaxCapable=pass.

Adding your own tests#section9

All of the tests included in testUserDevice.js are stored in a tests object that can be easily modified to your needs. testUserDevice.js also comes with a handy helper function to let you add tests in a clean, separated manner. This helper function can be reached by calling testUserDevice.add(testName, testScript). For example, to add a test that makes sure a user’s browser supports a simple JavaScript confirmation, you could write:

testUserDevice.add( 'confirm', function(){ 
    if(window.confirm){return true;}
    else {return false;}
});

Or, more concisely…

testUserDevice.add( 'confirm', function(){ 
    return (window.confirm) ? true : false;
});

The add function’s first argument contains a string representing the name of this test. The second argument is the test itself, which must be a function that returns true or false (true for a passing grade; false for failure).

Developing for tomorrow, today#section10

As the device and browser landscape continues to grow and developers continue to find ways to implement more features and functionalities, our job of making universally usable websites will remain challenging. Integrating capabilities testing into our development process allows us to take full advantage of state-of-the-art features without ruining the experience for the users of less capable browsers and devices.

It’s important to remember that the approach described in this article does not endorse the use of any particular set of tests—just the idea that you should test to make sure capabilities exist before trying to use them. We welcome any ideas and suggestions on how our approach can be improved and extended.

Scroll to Top