buy the book ribbon

A Gentle Excursion into JavaScript

You can never understand one language until you understand at least two.

— Geoffrey Willans
English author and journalist

Our new validation logic is good, but wouldn’t it be nice if the duplicate-item error messages disappeared once the user started fixing the problem, just like our nice HTML5 validation errors do?

Try it—​spin up the site with ./src/manage.py runserver, start a list, and if you try to submit an empty item, you get the "Please fill out this field" pop-up, and it disappears as soon as you enter some text. By contrast, enter an item twice, you get the "You’ve already got this in your list" message in red—and even if you edit your submission to something valid, the error stays there until you submit the form (see But I’ve fixed it!).

A screenshot of the 'Please fill out this field' error in red, still shown despite the fact that the input value has been modified to be different from the existing item in the list
Figure 1. But I’ve fixed it!

To get that error to disappear dynamically, we’d need a teeny-tiny bit of JavaScript. Python is a delightful language to program in. JavaScript wasn’t always that. But many of the rough edges have been smoothed off, and I think it’s fair to say that JavaScript is actually quite nice now. And in the world of web development, using JavaScript is unavoidable. So let’s dip our toes in, and see if we can’t have a bit of fun.

I’m going to assume you know the basics of JavaScript syntax. If not, the Mozilla guides on MDN are always good quality. I’ve also heard good things about Eloquent JavaScript, if you prefer a real book.

Starting with an FT

Let’s add a new functional test (FT) to the ItemValidationTest class; that asserts that our error message disappears when we start typing:

src/functional_tests/test_list_item_validation.py (ch17l001)
def test_error_messages_are_cleared_on_input(self):
    # Edith starts a list and causes a validation error:
    self.browser.get(self.live_server_url)
    self.get_item_input_box().send_keys("Banter too thick")
    self.get_item_input_box().send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table("1: Banter too thick")
    self.get_item_input_box().send_keys("Banter too thick")
    self.get_item_input_box().send_keys(Keys.ENTER)
    self.wait_for(  (1)
        lambda: self.assertTrue(  (1)
            self.browser.find_element(
                By.CSS_SELECTOR, ".invalid-feedback"
            ).is_displayed()  (2)
        )
    )

    # She starts typing in the input box to clear the error
    self.get_item_input_box().send_keys("a")

    # She is pleased to see that the error message disappears
    self.wait_for(
        lambda: self.assertFalse(
            self.browser.find_element(
                By.CSS_SELECTOR, ".invalid-feedback"
            ).is_displayed()  (2)
        )
    )
1 We use another of our wait_for invocations, this time with assertTrue.
2 is_displayed() tells you whether an element is visible or not. We can’t just rely on checking whether the element is present in the DOM, because we’re now going to mark elements as hidden, rather than removing them from the document object model (DOM) altogether.

The FT fails appropriately:

$ python src/manage.py test functional_tests.test_list_item_validation.\
ItemValidationTest.test_error_messages_are_cleared_on_input

FAIL: test_error_messages_are_cleared_on_input (functional_tests.test_list_item
_validation.ItemValidationTest.test_error_messages_are_cleared_on_input)
[...]
  File "...goat-book/src/functional_tests/test_list_item_validation.py", line
89, in <lambda>
    lambda: self.assertFalse(
            ~~~~~~~~~~~~~~~~^
        self.browser.find_element(
        ^^^^^^^^^^^^^^^^^^^^^^^^^^
            By.CSS_SELECTOR, ".invalid-feedback"
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        ).is_displayed()
        ^^^^^^^^^^^^^^^^
    )
    ^
AssertionError: True is not false

But, before we move on: three strikes and refactor! We’ve got several places where we find the error element using CSS. Let’s move the logic to a helper function:

src/functional_tests/test_list_item_validation.py (ch17l002)
class ItemValidationTest(FunctionalTest):
    def get_error_element(self):
        return self.browser.find_element(By.CSS_SELECTOR, ".invalid-feedback")

    [...]

And we then make three replacements in test_list_item_validation, like this:

src/functional_tests/test_list_item_validation.py (ch17l003)
    self.wait_for(
        lambda: self.assertEqual(
            self.get_error_element().text,
            "You've already got this in your list",
        )
    )
[...]
    self.wait_for(
        lambda: self.assertTrue(self.get_error_element().is_displayed()),
    )
[...]
    self.wait_for(
        lambda: self.assertFalse(self.get_error_element().is_displayed()),
    )

We still have our expected failure:

$ python src/manage.py test functional_tests.test_list_item_validation
[...]
    lambda: self.assertFalse(self.get_error_element().is_displayed()),
            ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: True is not false
I like to keep helper methods in the FT class that’s using them, and only promote them to the base class when they’re actually needed elsewhere. It stops the base class from getting too cluttered. You ain’t gonna need it (YAGNI)!

A Quick Spike

This will be our first bit of JavaScript. We’re also interacting with the Bootstrap CSS framework, which we maybe don’t know very well.

In [chapter_15_simple_form], we saw that you can use a unit test as a way of exploring a new API or tool. Sometimes though, you just want to hack something together without any tests at all, just to see if it works, to learn it or get a feel for it.

That’s absolutely fine! When learning a new tool or exploring a new possible solution, it’s often appropriate to leave the rigorous TDD process to one side, and build a little prototype without tests, or perhaps with very few tests. The Goat doesn’t mind looking the other way for a bit.

It’s actually fine to code without tests sometimes, when you want to explore a new tool or build a throwaway proof-of-concept—as long as you geniunely do throw that hacky code away, and start again with TDD for the real thing. The code always comes out much nicer the second time around.

This kind of prototyping activity is often called a "spike", for reasons that aren’t entirely clear, but it’s a nice memorable name.[1]

Before we start, let’s commit our FT. When embarking on a spike, you want to be able to get back to a clean slate:

$ git diff  # new method in src/tests/functional_tests/test_list_item_validation.py
$ *git commit -am"FT that validation errors disapper on type"
Always do a commit before embarking on a spike.

A Simple Inline Script

I hacked around for a bit, and here’s more or less the first thing I came up with. I’m adding the JavaScript inline, in a <script> tag at the bottom of our base.html template:

src/lists/templates/base.html (ch17l004)
    [...]
    </div>

    <script>
      const textInput = document.querySelector("#id_text");  (1)
      textInput.oninput = () => {  (2) (3)
        const errorMsg = document.querySelector(".invalid-feedback");
        errorMsg.style.display = "none";  (4)
      }
    </script>

  </body>
</html>
1 document.querySelector is a way of finding an element in the DOM, using CSS selector syntax, very much like the Selenium find_element(By.CSS_SELECTOR) method from our FTs. Grizzled readers may remember having to use jQuery’s $ function for this.
2 oninput is how you attach an event listener "callback" function, which will be called whenever the user inputs something into the text box.
3 Arrow functions, () => {...}, are the new way of writing anonymous functions in JavaScript, a bit like Python’s lambda syntax. I think they’re cute! Arguments go in the round brackets and the function body goes in the curly brackets. This is a function that takes no arguments—or I should say, ignores any arguments you try to give it. So, what does it do?
4 It finds the error message element, and then hides it by setting its style.display to "none".

That’s actually good enough to get our FT passing:

$ python src/manage.py test functional_tests.test_list_item_validation.\
ItemValidationTest.test_error_messages_are_cleared_on_input
Found 1 test(s).
[...]
.
 ---------------------------------------------------------------------
Ran 1 test in 3.284s

OK
It’s good practice to put your script loads at the end of your body HTML, as it means the user doesn’t have to wait for all your JavaScript to load before they can see something on the page. It also helps to make sure most of the DOM has loaded before any scripts run. See also Columbo Says: Wait for Onload later in this chapter.

Using the Browser DevTools

The test might be happy, but our solution is a little unsatisfactory. If you actually try it in your browser, you’ll see that although the error message is gone, the input is still red and invalid-looking (see The error message is gone but the input box is still red).

Screenshot of our page where the error `div` is gone but the input is still red.
Figure 2. The error message is gone but the input box is still red

You’re probably imagining that this has something to do with Bootstrap. We might have been able to hide the error message, but we also need to tell Bootstrap that this input no longer has invalid contents.

This is where I’d normally open up DevTools. If level one of hacking is spiking code directly into an inline <script> tag, level two is hacking things directly in the browser, where it’s not even saved to a file!

In Editing the HTML in the browser DevTools, you can see me directly editing the HTML of the page, and finding out that removing the is-invalid class from the input element seems to do the trick. It not only removes the error message, but also the red border around the input box.

Screenshot of the browser devtools with us editing the classes for the input element
Figure 3. Editing the HTML in the browser DevTools

We have a reasonable solution now; let’s write it down:

  • Remove is-invalid Bootstrap CSS class to hide error message and red border.

Time to de-spike!

Do We Really Need to Write Unit Tests for This?

Do we really need to write unit tests for this? By this point in the book, you probably know I’m going to say "yes", but let’s talk about it anyway.

Our FT definitely covers the functionality that our JavaScript is delivering, and we could extend it if we wanted to, to check on the colour of the input box or to look at the input element’s CSS classes. And if I was really sure that this was the only bit of JavaScript we were ever going to write, I probably would be tempted to leave it at that.

But I want to press on for two reasons. Firstly, because any book on web development has to talk about JavaScript and, in a TDD book, I have to show a bit of TDD in JavaScript.

More importantly though, as always, we have the boiled frog problem.[2] We might not have enough JavaScript yet to justify a full test suite, but what about when we come along later and add a tiny bit more? And a tiny bit more again?

It’s always a judgement call. On the one hand YAGNI, but on the other hand, I think it’s best to put the scaffolding in place early so that going test-first is the easy choice later.

I can already think of several extra things I’d want to do in the frontend! What about resetting the input to being invalid if someone types in the exact duplicate text again?

Choosing a Basic JavaScript Test Runner

Choosing your testing tools in the Python world is fairly straightforward. The standard library unittest package is perfectly adequate, and the Django test runner also makes a good default choice. More and more though, people will choose pytest for its assert-based assertions, and its fixture management. We don’t need to get into the pros and cons now! The point is that there’s a "good enough" default, and there’s one main popular alternative.

The JavaScript world has more of a proliferation! Mocha, Karma, Jester, Chai, AVA, and Tape are just a few of the options I came across when researching for the third edition.

I chose Jasmine, because it’s still popular despite being around for nearly a decade, and because it offers a "stand-alone" test runner that you can use without needing to dive into the whole Node.js/NPM ecosystem.

An Overview of Jasmine

By now, we’re used to the way that testing works with Python’s unittest library:

  1. We have a tests file, separate from the code we’re actually testing.

  2. We have a way of grouping blocks of code into a test: it’s a method, whose name starts with test_, on a class that inherits from unittest.TestCase.

  3. We have a way of making assertions in the test (the special assert methods, e.g., self.assertEqual()).

  4. We have a way of grouping related tests together (putting them in the same class).

  5. We can specify shared setup and cleanup code that runs before and after all the tests in a given group, the setUp() and tearDown() methods.

  6. We have some additional helpers that set up our app in a way that simulates what happens “in real life”—whether that’s Selenium and the LiveServerTestCase, or the Django test client. This is sometimes called the "test harness".

There are going to be fairly straightforward equivalents for the first five of these concepts in Jasmine:

  1. There is a tests file (Spec.js).

  2. Tests go into an anonymous function inside an it() block.

  3. Assertions use a special function called expect(), with a syntax based on method chaining for asserting equality.

  4. Blocks of related tests go into a function in a describe() block.

  5. setUp() and tearDown() are called beforeEach() and afterEach(), respectively.

There are some differences for sure, but you’ll see over the course of the chapter that they’re fundamentally the same. What is substantially different is the "test harness" part—the way that Jasmine creates an environment for us to work against.

Because we’re using the browser runner, what we’re actually going to do is define an HTML file (SpecRunner.html), and the engine for running our code is going to be an actual browser (with JavaScript running inside it).

That HTML will be the entry point for our tests, so it will be in charge of importing our framework, our tests file, and the code under test. It’s essentially a parallel, standalone web page that isn’t actually part of our app, but it does import the same JavaScript source code that our app uses.

Setting Up Our JavaScript Test Environment

Let’s download Jasmine now:

$ wget -O jasmine.zip \
  https://github.com/jasmine/jasmine/releases/download/v4.6.1/jasmine-standalone-4.6.1.zip
$ unzip jasmine.zip -d src/lists/static/tests
$ rm jasmine.zip
# if you're on Windows you may not have wget or unzip,
# but i'm sure you can manage to manually download and unzip the jasmine release

# move the example tests "Spec" file to a more central location
$ mv src/lists/static/tests/spec/PlayerSpec.js src/lists/static/tests/Spec.js

# delete all the other stuff we don't need
$ rm -rf src/lists/static/tests/src
$ rm -rf src/lists/static/tests/spec

That leaves us with a directory structure like this:

$ tree src/lists/static/tests
src/lists/static/tests
├── MIT.LICENSE
├── Spec.js
├── SpecRunner.html
└── lib
    └── jasmine-4.6.1
        ├── boot0.js
        ├── boot1.js
        ├── jasmine-html.js
        ├── jasmine.css
        ├── jasmine.js
        └── jasmine_favicon.png

3 directories, 9 files

SpecRunner.html is the file that ties the proverbial room together. So, we need to go edit it to make sure it’s pointing at the right places, to take into account the things we’ve moved around:

src/lists/static/tests/SpecRunner.html (ch17l006)
@@ -14,12 +14,10 @@
   <script src="lib/jasmine-4.6.1/boot1.js"></script>

   <!-- include source files here... -->
-  <script src="src/Player.js"></script>
-  <script src="src/Song.js"></script>
+  <script src="../lists.js"></script>

   <!-- include spec files here... -->
-  <script src="spec/SpecHelper.js"></script>
-  <script src="spec/PlayerSpec.js"></script>
+  <script src="Spec.js"></script>

 </head>

We change the source files to point at a (for-now imaginary) lists.js file that we’ll put into the static folder, and we change the spec files to point at the single Spec.js file, in the static/tests folder.

Our First Smoke Test: Describe, It, Expect

Now, let’s open up that Spec.js file and strip it down to a single minimal smoke test:

src/lists/static/tests/Spec.js (ch17l007)
describe("Superlists JavaScript", () => {  (1)

  it("should have working maths", () => {  (2)
    expect(1 + 1).toEqual(2);  (3)
  });

});
1 The describe block is a way of grouping tests together, a bit like we use classes in our Python tests. It starts with a string name, and then an arrow function for its body.
2 The it block is a single test, a bit like a method in a Python test class. Similarly to the describe block, we have a name and then a function to contain the test code. As you can see, the convention is for the descriptive name to complete the sentence started by it, in the context of the describe() block earlier; so, they often start with "should".
3 Now we have our assertion. This is a little different from assertions in unittest; it’s using what’s sometimes called "expect" style, often also seen in the Ruby world. We wrap our "actual" value in the expect() function, and then our assertions are methods on the resulting expect object, where .toEqual is the equivalent of assertEqual in Python.

Running the Tests via the Browser

Let’s see how that looks. Open up SpecRunner.html in your browser; you can do this from the command line with:

$ firefox src/lists/static/tests/SpecRunner.html
# or, on a mac:
$ open src/lists/static/tests/SpecRunner.html

Or, you can navigate to it in the address bar, using the file:// protocol—something like this: file://home/your-username/path/to/superlists/src/lists/static/tests/SpecRunner.html.

Either way you get there, you should see something like The Jasmine spec runner in action.

Jasmine browser-based spec runner showing one passing test.
Figure 4. The Jasmine spec runner in action

Let’s try adding a deliberate failure to see what that looks like:

src/lists/static/tests/Spec.js (ch17l008)
  it("should have working maths", () => {
    expect(1 + 1).toEqual(3);
  });

Now if we refresh our browser, we’ll see red (Our Jasmine tests are now red).

Jasmine browser-based spec runner showing one failing test, with lots of red.
Figure 5. Our Jasmine tests are now red
Is the Jasmine Standalone Browser Test Runner Unconventional?

Is the Jasmine standalone browser test runner unconventional? I think it probably is, to be honest. Although, the JavaScript world moves so fast, so I could be wrong by the time you read this.

What I do know is that, along with moving very fast, JavaScript things can very quickly become very complicated. A lot of people are working with frameworks these days (React being the main one), and that comes with TypeScript, transpilers, Node.js, NPM, the massive node_modules folder—and a very steep learning curve.

In this chapter, my aim is to stick with the basics. The standalone/browser-based test runner lets us write tests without needing to install Node.js or anything else, and it lets us test interactions with the DOM. That’s enough to give us a basic environment in which to do TDD in JavaScript.

If you decide to go further in the world of frontend, you probably will eventually get into the complexity of frameworks and TypeScript and transpilers, but the basics we work with here will still be a good foundation.

We will actually take things a small step further in this book, including dipping our toes into NPM and Node.js in [chapter_25_CI], where we will get CLI-based JavaScript tests working. So, look out for that!

Testing with Some DOM Content

What do we actually want to test? We want some JavaScript that will hide the .invalid-feedback error div when the user starts typing into the input box. In other words, our code is going to interact with the input element on the page and with the div.invalid-feedback.

Let’s look at how to set up some copies of these elements in our JavaScript test environment, for our tests and our code to interact with:

src/lists/static/tests/Spec.js (ch17l010)
describe("Superlists JavaScript", () => {
  let testDiv;  (4)

  beforeEach(() => {  (1)
    testDiv = document.createElement("div");  (2)
    testDiv.innerHTML = `  (3)
      <form>
        <input
          id="id_text"
          name="text"
          class="form-control form-control-lg is-invalid"
          placeholder="Enter a to-do item"
          value="Value as submitted"
          aria-describedby="id_text_feedback"
          required
        />
        <div id="id_text_feedback" class="invalid-feedback">An error message</div>
      </form>
    `;
    document.body.appendChild(testDiv);
  });

  afterEach(() => {  (1)
    testDiv.remove();
  });
1 The beforeEach and afterEach functions are Jasmine’s equivalent of setUp and tearDown.
2 The document global is a built-in browser variable that represents the current HTML page. So, in our case, it’s a reference to the SpecRunner.html page.
3 We create a new div element and populate it with some HTML that matches the elements we care about from our Django template. Notice the use of backticks (`) to enable us to write multiline strings. Depending on your text editor, it may even nicely syntax-highlight the HTML for you.
4 A little quirk of JavaScript here, because we want the same testDiv variable to be available inside both the beforeEach and afterEach functions: we declare the variable with let in the containing scope outside of both functions.

In theory, we could have just added the HTML to the SpecRunner.html file, but by using beforeEach and afterEach, I’m making sure that each test gets a completely fresh copy of the HTML elements involved, so that one test can’t affect another.

To ensure isolation between browser-based JavaScript tests, use beforeEach() and afterEach() to create and tidy up any DOM elements that your code needs to interact with.

Let’s now play with our testing framework to see if we can find DOM elements and make assertions on whether they are visible. We’ll also try the same style.display=none hiding technique that we originally used in our spiked code:

src/lists/static/tests/Spec.js (ch17l011)
  it("should have a useful html fixture", () => {
    const errorMsg = document.querySelector(".invalid-feedback");
    expect(errorMsg.checkVisibility()).toBe(true);  (1)
  });

  it("can hide things manually and check visibility in tests", () => {
    const errorMsg = document.querySelector(".invalid-feedback");
    errorMsg.style.display = "none";  (2)
    expect(errorMsg.checkVisibility()).toBe(false);  (3)
  });
1 We retrieve our error div with querySelector again, and then use another fairly new API in JavaScript-Land called checkVisibility() to check if it’s displayed or hidden.[3]
2 We manually hide the element in the test, by setting its style.display to "none". (Again, our objective here is to smoke-test, both our ability to hide things and our ability to test that they are hidden.)
3 And we check it worked, with checkVisibility() again.

Notice that I’m being really good about splitting things out into multiple tests, with one assertion each. Jasmine encourages that by deprecating the ability to pass failure messages into individual expect/toBe expressions, for example.

If you refresh the browser, you should see that all passes:

2 specs, 0 failures, randomized with seed 12345      finished in 0.005s


Superlists JavaScript
  * can hide things manually and check visibility in tests
  * should have a useful html fixture

(From now on, I’ll show the Jasmine outputs as text, like this, to avoid filling the chapter with screenshots.)

Building a JavaScript Unit Test for Our Desired Functionality

Now that we’re acquainted with our JavaScript testing tools, we can start to write the real thing:

src/lists/static/tests/Spec.js (ch17l012)
  it("should have a useful html fixture", () => {  (1)
    const errorMsg = document.querySelector(".invalid-feedback");
    expect(errorMsg.checkVisibility()).toBe(true);
  });

  it("should hide error message on input", () => {  (2)
    const textInput = document.querySelector("#id_text");  (3)
    const errorMsg = document.querySelector(".invalid-feedback");

    textInput.dispatchEvent(new InputEvent("input"));  (4)

    expect(errorMsg.checkVisibility()).toBe(false);  (5)
  });
1 As it’s not doing any harm, let’s keep the first smoke test.
2 Let’s change the second one, and give it a name that describes what we want to happen; our objective is that, when the user starts typing into the input box, we should hide the error message.
3 We retrieve the <input> element from the DOM, in a similar way to how we found the error message div.
4 Here’s how we simulate a user typing into the input box.
5 And here’s our real assertion: the error div should be hidden after the input box sees an input event.

That gives us our expected failure:

2 specs, 1 failure, randomized with seed 12345      finished in 0.005s

Spec List | Failures

Superlists JavaScript > should hide error message on input
Expected true to be false.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:38:40
<Jasmine>

Now let’s try reintroducing the code we hacked together in our spike, into lists.js:

src/lists/static/lists.js (ch17l014)
const textInput = document.querySelector("#id_text");
textInput.oninput = () => {
  const errorMsg = document.querySelector(".invalid-feedback");
  errorMsg.style.display = "none";
};

That doesn’t work! We get an unexpected error:

2 specs, 2 failures, randomized with seed 12345      finished in 0.005s
Error during loading: TypeError: can't access property "oninput", textInput is
null in file:///...goat-book/src/lists/static/lists.js line 2
Spec List | Failures

Superlists JavaScript > should hide error message on input
Expected true to be false.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:38:40
<Jasmine>

If your Jasmine output shows Script error instead of textInput is null, open up the DevTools console, and you’ll see the actual error printed in there, as in textInput is null, one way or another.[4]

Screenshot of devtools console showing the textInput is null TypeError
Figure 6. textInput is null, one way or another

textInput is null, it says. Let’s see if we can figure out why.

Fixtures, Execution Order, and Global State: Key Challenges of JavaScript Testing

One of the difficulties with JavaScript in general, and testing in particular, is understanding the order of execution of our code (i.e., what happens when). When does our code in lists.js run, and when do each of our tests run? How do they all interact with global state—that is, the DOM of our web page and the fixtures that we’ve already seen are supposed to be cleaned up after each test?

console.log for Debug Printing

Let’s add a couple of debug prints, or "console.logs":

src/lists/static/tests/Spec.js (ch17l015)
console.log("Spec.js loading");

describe("Superlists JavaScript", () => {
  let testDiv;

  beforeEach(() => {
    console.log("beforeEach");
    testDiv = document.createElement("div");

    [...]

  it("should have a useful html fixture", () => {
    console.log("in test 1");
    const errorMsg = document.querySelector(".invalid-feedback");
    [...]

  it("should hide error message on input", () => {
    console.log("in test 2");
    const textInput = document.querySelector("#id_text");
    [...]

And the same in our actual JavaScript code:

src/lists/static/lists.js (ch17l016)
console.log("lists.js loading");
const textInput = document.querySelector("#id_text");
textInput.oninput = () => {
  const errorMsg = document.querySelector(".invalid-feedback");
  errorMsg.style.display = "none";
};

Rerun the tests, opening up the browser debug console (Ctrl+Shift+I or Cmd+Alt+I) and you should see something like Jasmine tests with console.log debug outputs.

Jasmine tests with console.log debug outputs
Figure 7. Jasmine tests with console.log debug outputs

What do we see?

  1. First, lists.js loads.

  2. Then, we see the error saying textInput is null.

  3. Next, we see our tests loading in Spec.js.

  4. Then, we see a beforeEach, which is when our test fixture actually gets added to the DOM.

  5. Finally, we see the first test run.

This explains the problem: when lists.js loads, the input node doesn’t exist yet.

Using an Initialize Function for More Control Over Execution Time

We need more control over the order of execution of our JavaScript. Rather than just relying on the code in lists.js running whenever it is loaded by a <script> tag, we can use a common pattern: define an "initialize" function and call that when we want to in our tests (and later in real life).[5]

Here’s what that function could look like:

src/lists/static/lists.js (ch17l017)
console.log("lists.js loading");
const initialize = () => {
  console.log("initialize called");
  const textInput = document.querySelector("#id_text");
  textInput.oninput = () => {
    const errorMsg = document.querySelector(".invalid-feedback");
    errorMsg.style.display = "none";
  };
};

And in our tests file, we call initialize() in our key test:

src/lists/static/tests/Spec.js (ch17l018)
  it("should have a useful html fixture", () => {
    console.log("in test 1");
    const errorMsg = document.querySelector(".invalid-feedback");
    expect(errorMsg.checkVisibility()).toBe(true);
  });

  it("should hide error message on input", () => {
    console.log("in test 2");
    const textInput = document.querySelector("#id_text");
    const errorMsg = document.querySelector(".invalid-feedback");

    initialize();  (1)
    textInput.dispatchEvent(new InputEvent("input"));

    expect(errorMsg.checkVisibility()).toBe(false);
  });
});
1 This is where we call initialize(). We don’t need to call it in our fixture sense-check.

And that will actually get our tests passing!

2 specs, 0 failures, randomized with seed 12345      finished in 0.005s


Superlists JavaScript
  * should hide error message on input
  * should have a useful html fixture

And now the console.log outputs should be in a more sensible order:

lists.js loading            lists.js:1:9
Spec.js loading             Spec.js:1:9
beforeEach                  Spec.js:7:13
in test 1                   Spec.js:31:13
beforeEach                  Spec.js:7:13
in test 2                   Spec.js:37:13
initialize called           lists.js:3:11

Deliberately Breaking Our Code to Force Ourselves to Write More Tests

I’m always nervous when I see green tests. We’ve copy-pasted five lines of code from our spike with just one test. That was a little too easy, even if we did have to go through that little initialize() dance.

So, let’s change our initialize() function to deliberately break it. What if we just immediately hide errors?

src/lists/static/lists.js (ch17l019)
const initialize = () => {
  // const textInput = document.querySelector("#id_text");
  // textInput.oninput = () => {
    const errorMsg = document.querySelector(".invalid-feedback");
    errorMsg.style.display = "none";
  // };
};

Oh dear, as I feared—the tests just pass:

2 specs, 0 failures, randomized with seed 12345      finished in 0.005s


Superlists JavaScript
  * should hide error message on input
  * should have a useful html fixture

We need an extra test, to check that our initialize() function isn’t overzealous:

src/lists/static/tests/Spec.js (ch17l020)
  it("should hide error message on input", () => {
    [...]
  });

  it("should not hide error message before event is fired", () => {
    const errorMsg = document.querySelector(".invalid-feedback");
    initialize();
    expect(errorMsg.checkVisibility()).toBe(true);  (1)
  });
1 In this test, we don’t fire the input event with dispatchEvent, so we expect the error message to still be visible.

That gives us our expected failure:

3 specs, 1 failure, randomized with seed 12345      finished in 0.005s

Spec List | Failures

Superlists JavaScript > should not hide error message before event is fired
Expected false to be true.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:48:40
<Jasmine>

This justifies us to restore the textInput.oninput():

src/lists/static/lists.js (ch17l021)

const initialize = () => {
  const textInput = document.querySelector("#id_text");
  textInput.oninput = () => {
    const errorMsg = document.querySelector(".invalid-feedback");
    errorMsg.style.display = "none";
  };
};

Red/Green/Refactor: Removing Hardcoded Selectors

The #id_text and .invalid-feedback selectors are "magic constants" at the moment. It would be better to pass them into initialize(), both in the tests and in base.html, so that they’re defined in the same file that actually has the HTML elements.

And while we’re at it, our tests could do with a bit of refactoring too, to remove some duplication. We’ll start with that, by defining a few more variables in the top-level scope, and populate them in the beforeEach:

src/lists/static/tests/Spec.js (ch17l022)
describe("Superlists JavaScript", () => {
  const inputId = "id_text";  (1)
  const errorClass = "invalid-feedback";  (1)
  const inputSelector = `#${inputId}`;  (2)
  const errorSelector = `.${errorClass}`;  (2)
  let testDiv;
  let textInput;  (3)
  let errorMsg;  (3)

  beforeEach(() => {
    console.log("beforeEach");
    testDiv = document.createElement("div");
    testDiv.innerHTML = `
      <form>
        <input
          id="${inputId}"  (4)
          name="text"
          class="form-control form-control-lg is-invalid"
          placeholder="Enter a to-do item"
          value="Value as submitted"
          aria-describedby="id_text_feedback"
          required
        />
        <div id="id_text_feedback" class="${errorClass}">An error message</div>  (4)
      </form>
    `;
    document.body.appendChild(testDiv);
    textInput = document.querySelector(inputSelector);  (5)
    errorMsg = document.querySelector(errorSelector);  (5)
  });
1 Let’s define some constants to represent the selectors for our input element and our error message div.
2 We can use JavaScript’s string interpolation (the equivalent of f-strings) to then define the CSS selectors for the same elements.
3 We’ll also set up some variables to hold the elements we’re always referring to in our tests (these can’t be constants, as we’ll see shortly).
4 We use a bit more interpolation to reuse the constants in our HTML template. A first bit of de-duplication!
5 Here’s why textInput and errorMsg can’t be constants: we’re re-creating the DOM fixture in every beforeEach, so we need to re-fetch the elements each time.

Now we can apply some DRY ("don’t repeat yourself") to strip down our tests:

src/lists/static/tests/Spec.js (ch17l023)
  it("should have a useful html fixture", () => {
    expect(errorMsg.checkVisibility()).toBe(true);
  });

  it("should hide error message on input", () => {
    initialize();
    textInput.dispatchEvent(new InputEvent("input"));

    expect(errorMsg.checkVisibility()).toBe(false);
  });

  it("should not hide error message before event is fired", () => {
    initialize();
    expect(errorMsg.checkVisibility()).toBe(true);
  });

You can definitely overdo DRY in test, but I think this is working out very nicely. Each test is between one and three lines long, meaning it’s very easy to see what each one is doing, and what it’s doing differently from the others.

We’ve only refactored the tests so far, so let’s check that they still pass:

3 specs, 0 failures, randomized with seed 12345      finished in 0.005s


Superlists JavaScript
  * should hide error message on input
  * should have a useful html fixture
  * should not hide error message before event is fired

The next refactor is wanting to pass the selectors to initialize(). Let’s see what happens if we just do that straight away, in the tests:

src/lists/static/tests/Spec.js (ch17l024)
@@ -40,14 +40,14 @@ describe("Superlists JavaScript", () => {
   });

   it("should hide error message on input", () => {
-    initialize();
+    initialize(inputSelector, errorSelector);
     textInput.dispatchEvent(new InputEvent("input"));

     expect(errorMsg.checkVisibility()).toBe(false);
   });

   it("should not hide error message before event is fired", () => {
-    initialize();
+    initialize(inputSelector, errorSelector);
     expect(errorMsg.checkVisibility()).toBe(true);
   });
 });

Now we look at the tests:

3 specs, 0 failures, randomized with seed 12345      finished in 0.005s


Superlists JavaScript
  * should hide error message on input
  * should have a useful html fixture
  * should not hide error message before event is fired

They still pass!

You might have been expecting a failure to do with the fact that initialize() was defined as taking no arguments—but we passed two! That’s because JavaScript is too chill for that. You can call a function with too many or too few arguments, and JavaScript will just deal with it.

Let’s fish those arguments out in initialize():

src/lists/static/lists.js (ch17l025)
const initialize = (inputSelector, errorSelector) => {
  const textInput = document.querySelector(inputSelector);
  textInput.oninput = () => {
    const errorMsg = document.querySelector(errorSelector);
    errorMsg.style.display = "none";
  };
};

And the tests still pass:

3 specs, 0 failures, randomized with seed 12345      finished in 0.005s

Let’s deliberately use the arguments the wrong way round, just to check we get a failure:

src/lists/static/lists.js (ch17l026)
const initialize = (errorSelector, inputSelector) => {

Phew, that does indeed fail:

3 specs, 1 failure, randomized with seed 12345      finished in 0.005s

Spec List | Failures

Superlists JavaScript > should hide error message on input
Expected true to be false.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:46:40
<Jasmine>

OK, back to the right way around:

src/lists/static/lists.js (ch17l027)
const initialize = (inputSelector, errorSelector) => {

Does it Work?

And for the moment of truth, we’ll pull in our script and invoke our initialize function on our real pages. Let’s use another <script> tag to include our lists.js, and strip down the the inline JavaScript to just calling initialize() with the right selectors:

src/lists/templates/base.html (ch17l028)
    </div>

    <script src="/static/lists.js"></script>
    <script>
      initialize("#id_text", ".invalid-feedback");
    </script>

  </body>
</html>

Aaaand we run our FT:

$ python src/manage.py test functional_tests.test_list_item_validation.\
ItemValidationTest.test_error_messages_are_cleared_on_input
[...]

Ran 1 test in 3.023s

OK

Hooray! That’s a commit!

$ git add src/lists
$ git commit -m"Despike our js, add jasmine tests"
We’re using a <script> tag to import our code, but modern JavaScript lets you use import and export to explicitly import particular parts of your code. However, that involves specifying the scripts as modules, which is fiddly to get working with the single-file test runner we’re using. So, I decided to use the "simple" old-fashioned way. By all means, investigate modules in your own projects!

Testing Integration with CSS and Bootstrap

As the tests flashed past, you may have noticed an unsatisfactory bit of red, still left around our input box. Wait a minute! We forgot one of the key things we learned in our spike!

  • Remove is-invalid Bootstrap CSS class to hide error message and red border.

We don’t need to manually hack style.display=none; we can work with the Bootstrap framework and just remove the .is-invalid class.

OK, let’s try it in our implementation:

src/lists/static/lists.js (ch17l029)
const initialize = (inputSelector, errorSelector) => {
  const textInput = document.querySelector(inputSelector);
  textInput.oninput = () => {
    textInput.classList.remove("is-invalid");
  };
};

Oh dear; it seems like that doesn’t quite work:

3 specs, 1 failure, randomized with seed 12345      finished in 0.005s

Spec List | Failures

Superlists JavaScript > should hide error message on input
Expected true to be false.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:46:40
<Jasmine>

What’s happening here? Well, as hinted in the section title, we’re now relying on the integration with Bootstrap’s CSS, but our test runner doesn’t know about Bootstrap yet.

We can include it in a reasonably familiar way, which is by including it in the <head> of our SpecRunner.html file:

src/lists/static/tests/SpecRunner.html (ch17l030)
  <link rel="stylesheet" href="lib/jasmine-4.6.1/jasmine.css">

  <!-- Bootstrap CSS -->
  <link href="../bootstrap/css/bootstrap.min.css" rel="stylesheet">

  <script src="lib/jasmine-4.6.1/jasmine.js"></script>

That gets us back to passing tests:

3 specs, 0 failures, randomized with seed 12345      finished in 0.005s


Superlists JavaScript
  * should hide error message on input
  * should have a useful html fixture
  * should not hide error message before event is fired

Let’s do a little more refactoring. If your editor is set up to do some JavaScript linting, you might have seen a warning saying:

'errorSelector' is declared but its value is never read.

Great! Looks like we can get away with just one argument to our initialize() function:

src/lists/static/lists.js (ch17l031)
const initialize = (inputSelector) => {
  const textInput = document.querySelector(inputSelector);
  textInput.oninput = () => {
    textInput.classList.remove("is-invalid");
  };
};

Are you enjoying the way the tests keep passing even though we’re giving the function too many arguments? JavaScript is so chill, man. Let’s strip them down anyway:

src/lists/static/tests/Spec.js (ch17l032)
@@ -40,14 +40,14 @@ describe("Superlists JavaScript", () => {
   });

   it("should hide error message on input", () => {
-    initialize(inputSelector, errorSelector);
+    initialize(inputSelector);
     textInput.dispatchEvent(new InputEvent("input"));

     expect(errorMsg.checkVisibility()).toBe(false);
   });

   it("should not hide error message before event is fired", () => {
-    initialize(inputSelector, errorSelector);
+    initialize(inputSelector);
     expect(errorMsg.checkVisibility()).toBe(true);
   });
 });

And the base template, yay. Nothing more satisfying than deleting code:

src/lists/templates/base.html (ch17l033)
    <script>
      initialize("#id_text");
    </script>

And we can run the FT one more time, just for safety:

OK
Trade-offs in JavaScript Unit Testing Versus Selenium

Similarly to the way our Selenium tests and our Django unit tests interact, we have an overlap between the functionality covered by our JavaScript unit tests and our Selenium FTs.

As always, the downside of the FTs is that they are slow, and they can’t always point you towards exactly what went wrong. But they do give us the best reassurance that all our components—​in this case, browser, CSS framework, and JavaScript—​are all working together.

On the other hand, by using the jasmine-browser-runner, we are also testing the integration between our browser, our JavaScript, and Bootstrap. This comes at the expense of having a slightly clunky testing setup.

If you wanted to switch to faster, more focused unit tests, you could try the following:

  • Stop using the browser runner.

  • Switch to a node-based CLI test runner.

  • Change from asserting using checkVisibility() (which won’t work without a real DOM) to asserting what the JavaScript code is actually doing—removing the .is-invalid CSS class.

It might look something like this:

src/lists/static/tests/Spec.js
  it("should hide error message on input", () => {
    initialize(inputSelector);
    textInput.dispatchEvent(new InputEvent("input"));

    expect(errorMsg.classList).not.toContain("is-invalid");
  });

The trade-off here is that you get faster, more focused unit tests, but you need to lean more heavily on Selenium to test the integration with Bootstrap. That could be worth it, but probably only if you start to have a lot more JavaScript code.

Columbo Says: Wait for Onload

Wait, there’s just one more thing…​

— Columbo (fictional trench-coat-wearing American detective known for his persistence)

As always, there’s one final thing. Whenever you have some JavaScript that interacts with the DOM, it’s good to wrap it in some "onload" boilerplate to make sure that the page has fully loaded before it tries to do anything. Currently it works anyway, because we’ve placed the <script> tag right at the bottom of the page, but we shouldn’t rely on that.

The MDN documentation on this is good, as usual.

The modern JavaScript onload boilerplate is minimal:

src/lists/templates/base.html (ch17l034)
    <script>
      window.onload = () => {
        initialize("#id_text");
      };
    </script>

That’s a commit folks!

$ git status
$ git add src/lists/static  # all our js and tests
$ git add src/lists/templates  # changes to the base template
$ git commit -m"Javascript to hide error messages on input"

JavaScript Testing in the TDD Cycle

You may be wondering how these JavaScript tests fit in with our "double loop" TDD cycle (see Double-loop TDD reminder).

Diagram showing an inner loop of red/green/refactor, and an outer loop of red-(inner loop)-green.
Figure 8. Double-loop TDD reminder

The answer is that the JavaScript unit-test/code cycle plays exactly the same role as the Python unit one:

  1. Write an FT and see it fail.

  2. Figure out what kind of code you need next: Python or JavaScript?

  3. Write a unit test in either language, and see it fail.

  4. Write some code in either language, and make the test pass.

  5. Rinse and repeat.

Phew. Well, hopefully some sense of closure there. The next step is to deploy our new code to our servers.

There is more JavaScript fun in this book too! Have a look at the Online Appendix: Building a REST API), when you’re ready for it.

Want a little more practice with JavaScript? See if you can get our error messages to be hidden when the user clicks inside the input element, as well as just when they type in it. You should be able to FT it too, if you want a bit of extra Selenium practice.
JavaScript Testing Notes
Selenium as the outer loop

One of the great advantages of Selenium is that it enables you to test that your JavaScript really works, just as it tests your Python code. But, as always, FTs are a very blunt tool, so it’s often worth pairing them with some lower-level tests.

Choosing your testing framework

There are many JavaScript test-running libraries out there. Jasmine has been around for a while, but the others are also worth investigating.

Idiosyncrasies of the browser

No matter which testing library you use, if you’re working with Vanilla JavaScript (i.e., not a framework like React), you’ll need to work around the key "gotchas" of JavaScript:

  • The DOM and HTML fixtures

  • Global state

  • Understanding and controlling execution order

Frontend frameworks

An awful lot of frontend work these days is done in frameworks, React being the 1,000-pound gorilla. There are lots of resources on React testing out there, so I’ll let you go out and find them if you need them.


1. This chapter shows a very small spike. We’ll come back and look at the spiking process again, with a weightier Python/Django example, in [chapter_19_spiking_custom_auth] .
2. For a reminder, read back on this problem in [trivial_tests_trivial_functions].
3. Read up on the checkVisibility() method in the MDN documentation.
4. Some users have also reported that Google Chrome will show a different error, to do with the browser preventing loading local files. If you really can’t use Firefox, you might be able to find some solutions on Stack Overflow.
5. Have you been enjoying the British English spelling in the book so far and are shocked to see the z in “initialize”? By convention, even us Brits often use American spelling in code, because it makes it easier for international colleagues to read, and to make it correspond better with code samples on the internet.

Comments