Lessons Learned: Unit Testing DOM Manipulation Code
Hi, everyone. This is James Shore with my lessons learned about unit testing DOM manipulation code. I'm recording this on November 7th, 2012.
Before we begin, I'd like to thank Cyrus Innovation for sponsoring this episode. Cyrus Innovation is an Agile consulting firm with offices in New York and Boston. They provide teams of expert developers to create custom software solutions for both startups and enterprises. For more information, visit them at cyrusinnovation.com.
In this episode, we're looking at testing DOM manipulation code. We'll start by looking at a complete DOM test, then go into more detail. We'll look at the essential components of a unit test and how they change when you're testing the DOM. Then we'll demonstrate those ideas by building a simple DOM test from scratch.
The Example DOM Test
Let's start by looking at the example test. This is a simple example that you can build upon for more sophisticated problems. The code's available at the link on your screen.
In this example, our production code checks a text field to see if it's empty. If it is, the production code sets a "required" CSS class on the field. If it isn't, then the code clears the class.
Our tests check this behavior by creating an empty text field... calling the production code... and checking that the CSS class has been added. We also have a test that creates a non-empty text field with the CSS class present... calls the production code... and verifies that the class has been removed.
Now let's go into more detail.
DOM Testing Fundamentals
Now, in theory, the DOM is a cross-platform API. In theory, if your DOM code works on Firefox, it should also work on Safari and Internet Explorer and Chrome and Android and... In practice... um, no. Not even close. If you want to know your DOM code works in a particular browser, you're going to have to actually run your tests inside that browser.
There are a lot of different ways to run your tests in a browser. My current preferred approach is Testacular, the cross-browser test runner. I talked about it in our last Lessons Learned—number six. See that episode for information about how to use Testacular.
Regardless of which testing framework you use, your tests will tend to follow the same basic pattern. First you set up your test environment... then you run the production code... then you check that the production code did what it was supposed to. Bill Wake calls this "Arrange, Act, Assert." Sometimes there's a fourth step where you clean up, or "Reset." It's the pirate approach to testing. Yaaarrr, matey.
Anyway... in DOM testing, the same pattern applies. In the "Arrange" step, you'll create the DOM elements your production code uses. In the "Act" step, you'll run your production code. And in the "Assert" step, you'll check the DOM elements to see if they've changed the way that they were supposed to. Finally, you'll "Reset" the DOM by removing the DOM elements you added in the "Arrange" step.
There's an additional wrinkle I'd like you to think about when testing the DOM. Good design is about partly about having clear separations of responsibility. This allows you to make to changes to one part of your system without needing to change other parts.
On the client side, one of those clear separations is between content and behavior. When you're writing your tests and designing your production code, try to make them independent of the actual HTML and CSS content and layout. Design your code so that you can change your layout without changing your code.
For example, if your production code validates a form field, you could have it turn the field red when it fails. But that couples your code to your layout. If you decide that you want a different shade of red, or you want the border to change color, you'll have to change the production code.
Instead, have your production code apply a CSS class when the validation fails. That way, when you want to change the way your validation looks, you just need to change your CSS.
Similarly, in our WeeWikiPaint application over on the Live channel, we need our production code to create a Raphael canvas. We have to tell Raphael what size to use for the canvas, but that's a layout question. So we programatically pull the size that we defined in CSS out of the DOM. That allows us to change the CSS—and the size of the canvas—without changing our production code.
Let's see how this works in practice.
Test-Driving the DOM
For this example, we're going to write a simple bit of validation logic. You can build on this example to create more sophisticated code.
Our example code checks a text field to see if it's empty. If it is, it marks it as "required." If not, it marks it as good.
We'll start out with the Testacular test infrastructure we set up in Lessons Learned #6. For simplicity, we're only testing against Chrome in this example, but we could run against as many browsers as we want. See Lessons Learned #6 for details.
Let's create our first test. In test-driven development, writing the test is an act of design. How do we want to call our code? How do we want it to behave?
In this case, let's write a function that validates empty text fields. We want to keep our code separated from our layout, so let's say the function applies a CSS class when it's empty.
Now that we have our test, we can follow the Yaaarrrr! pattern... Let's fill in the "Act" part first, because we know that we want to call a function. Let's call it
validateTextField()... and pass in the DOM element we want to validate.
Now for "Arrange." We need to create a text field that's invalid. An empty input element with the
text type will do the trick. (For real code, I'll usually use a DOM manipulation library like JQuery, but for this example I wanted to keep things simple.)
And finally, "Assert." We'll get the field's class attribute... and assert that it matches a magic CSS class. I like to prepend my application name to the class—in this case, "example"—so my code's classes don't clash with the classes I define in my CSS files.
In some cases, we'll delete the DOM element in the "reset" step. We don't have to do that because we didn't add the element to our document. It will fall out of scope and get cleaned up when our test function exits.
Now let's see if our tests work. It should fail because we haven't written the function yet. We run the test... and it fails as expected. Add the function... run the test again... and now it fails saying the class wasn't found. Perfect.
Now we'll make the test pass. It's easy: we can just set the
example-required class. Run the test... and it passes.
That's clearly not good enough, so let's write our next test. We need to handle the case when the field isn't empty. We'll copy our existing test... change the description... set the
example-required class... give it some text... and change the assertion.
This test should fail... and it does.
It's easy to make it pass. We'll check the value of the field... remove the class if the field's not blank... and add the class if it is. Now our tests should pass... and they do.
Now the code basically works. There's still some behavior left to add, though. In order to be independent of our layout, we need to make sure our code doesn't erase any existing CSS classes. I'll leave that as an exercise for you to complete on your own.
We need to clean up our duplicated code. We have some common set up code, so we'll factor that into a
beforeEach block... which works.
Our assertions are duplicated as well, so we'll clean that up... and that works.
Finally, we'll factor out our magic
example-required string into a constant... and that works.
And that's it. We're done.
So that's what I've learned so far about unit testing DOM manipulation code. To recap, testing DOM manipulation code is much like other forms of unit testing. You need to set up your test, run your production code, and assert that the code did what it was supposed to.
I've left some of the tests as an exercise for you to finish on your own. You can find those tests, and the rest of this episode's example code, at the link on your screen. Thanks for watching, and I'll see you next time!