Skip to content

Latest commit

 

History

History
1710 lines (1299 loc) · 51.9 KB

chapter_17_javascript.asciidoc

File metadata and controls

1710 lines (1299 loc) · 51.9 KB

A Gentle Excursion Into JavaScript

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

— Geoffrey Willans
English author and journalist
Warning, Fresh rewrite

This chapter is a very fresh rewrite for the third edition.

I’ve switched from QUnit to Jasmine for the JavaScript testing, and updated all the listings to use modern ES6 JavaScript.

It’s a first draft, so I’d love your feedback! [email protected]

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? For that 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.

Note
I’m going to assume you know the basics of JavaScript syntax. If not, I used to recommend JavaScript: The Good Parts, which was the best guide, once upon a time. Nowadays many of the "bad parts" it warns against have actually been fixed, so it’s a little out of date. I’d say it’s still a good guide, but if you find it too anachronistic, I’ve heard good things about Eloquent JavaScript.

Starting with an FT

Let’s add a new functional test to the ItemValidationTest class:

Example 1. src/functional_tests/test_list_item_validation.py (ch16l001)
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 DOM altogether.

The functional test 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
90, 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:

Example 2. src/functional_tests/test_list_item_validation.py (ch16l002)
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:

Example 3. src/functional_tests/test_list_item_validation.py (ch16l003)
    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

And we can commit this as the first cut of our FT.

Tip
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. 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.

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]

Tip
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 code inline, in a <script> tag at the bottom of our base.html template:

Example 4. src/lists/templates/base.html (ch16l004)
    </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>
  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, the function body goes in the curly braces. So this is a function that takes no arguments, or I should say, ignores any arguments you try to give it. 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
Tip
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 the Columbo Says: wait for Onload section, 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 1. 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 the browser devtools. If level 1 of hacking is spiking code directly into an inline <script> tag, level 2 is hacking things directly in the browser, where it’s not even saved to a file!

Screenshot of the browser devtools with us editing the classes for the input element
Figure 2. Editing the HTML in the Browser Devtools

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.

We have a reasonable solution now, time to de-spike!

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 this functionality, 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. 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, and 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 re-setting the input to being invalid if someone types in the exact duplicate text again?

Setting Up 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 the Third Edition.

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

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

2 directories, 9 files

We need to go edit the SpecRunner.html file to take into account the things we’ve moved around:

src/lists/static/tests/SpecRunner.html (ch16l006)
@@ -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.

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

Example 5. src/lists/static/tests/Spec.js (ch16l007)
describe("Superlists tests", () => {  //(1)

  it("smoke test", () => {  //(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 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.

  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.

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 using 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 3. The Jasmine Spec runner in action

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

Example 6. src/lists/static/tests/Spec.js (ch16l008)
  it("smoke test", () => {
    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 4. Our Jasmine tests are now red
Is the Jasmine Standalone Browser Test Runner Unconventional?

I think it probably is, to be honest. Although the JavaScript world moves so fast, 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 is the main one), and along with that comes TypeScript, transpilers, to say nothing of Node.js, npm, the 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 or anything else, and it lets us tests 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.

If you want to take a small step further, look into installing the jasmine-browser-runner npm package, and a bit of fiddling with its config file should let you run our tests from the command-line instead of with a browser.

Testing with some DOM content

What do we 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 the div.invalid-feedback.

Let’s see how to set up some copies of these elements in our JS test environment, for our tests and our code to interact with.

Example 7. src/lists/static/tests/Spec.js (ch16l010)
describe("Superlists tests", () => {
  let testDiv;  //(3)

  beforeEach(() => {  //(1)
    testDiv = document.createElement("div");
    testDiv.innerHTML = `  //(2)
      <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. 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 allow us to write multi-line strings. Depending on your text editor, it may even nicely syntax-highlight the HTML for you.

  3. 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 this let in the containing scope outside of both of them.

In theory, we could just add 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.

Let’s now have a 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.

Example 8. src/lists/static/tests/Spec.js (ch16l011)
  it("sense-check our html fixture", () => {
    const errorMsg = document.querySelector(".invalid-feedback");
    expect(errorMsg.checkVisibility()).toBe(true);  //(1)
  });

  it("check we know how to hide things", () => {
    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().

  2. We manually hide the element in the test, by setting its style.display to "none".

  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, for example, by deprecating the ability to pass on-failure messages into individual expect/toBe expressions.

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

Example 9. Expected results from Jasmine in the browser
2 specs, 0 failures, randomized with seed 12345      finished in 0.005s


Superlists tests
  * check we know how to hide things
  * sense-check our html fixture

(I’ll show the Jasmine outputs as text, as in Expected results from Jasmine in the browser, from now on, 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 switch back to just one test and start to write the real thing:

Example 10. src/lists/static/tests/Spec.js (ch16l012)
  it("sense-check our html fixture", () => {  //(1)
    const errorMsg = document.querySelector(".invalid-feedback");
    expect(errorMsg.checkVisibility()).toBe(true);
  });

  it("error message should be hidden 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. Let’s keep the first smoke test, it’s not doing any harm.

  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 tests > error message should be hidden on input
Expected true to be false.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:38:40
<Jasmine>

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

Example 11. src/lists/static/lists.js (ch16l014)
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: textInput is null in
file:///...goat-book/src/lists/static/lists.js line 2
Spec List | Failures

Superlists tests > error message should be hidden on input
Expected true to be false.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:38:40
<Jasmine>
Note
If your Jasmine output shows Script error instead of textInput is null, open up the dev tools console, and you’ll see the actual error printed in there.

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

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

One of the difficulties with JavaScript in general, and testing in particular, is in understanding the order of execution of our code (i.e., what happens when). When does our code in lists.js run, and when does each of our tests run? And how does that 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":

Example 12. src/lists/static/tests/Spec.js (ch16l015)
console.log("Spec.js loading");

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

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

    [...]

  it("sense-check our html fixture", () => {
    console.log("in test 1");
    const errorMsg = document.querySelector(".invalid-feedback");
    [...]

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

And the same in our actual JS code:

Example 13. src/lists/static/lists.js (ch16l016)
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 5. Jasmine tests with console.log debug outputs

What do we see?

  • lists.js loads first

  • then we see the error saying textInput is null

  • then we see our tests loading in Spec.js

  • then we see a beforeEach, which is when our test fixture actually gets added to the DOM

  • then 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, which is to define an "initialize" function, and call that when we want to in our tests (and later in real life):

Example 14. src/lists/static/lists.js (ch16l017)
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:

Example 15. src/lists/static/tests/Spec.js (ch16l018)
  it("sense-check our html fixture", () => {
    console.log("in test 1");
    const errorMsg = document.querySelector(".invalid-feedback");
    expect(errorMsg.checkVisibility()).toBe(true);
  });

  it("error message should be hidden 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. Here. We don’t need to call it in our sense-check.

And that will actually get our tests passing!

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


Superlists tests
  * error message should be hidden on input
  * sense-check our html fixture

And now the console.log outputs should make more sense:

lists.js loading    lists.js:1:9
Spec.js loading     Spec.js:1:9
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 despite that little initialize() dance.

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

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

Oh dear, sure enough the tests just pass:

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


Superlists tests
  * error message should be hidden on input
  * sense-check our html fixture

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

Example 17. src/lists/static/tests/Spec.js (ch16l020)
  it("error message should be hidden on input", () => {
    [...]
  });

  it("error message should not be hidden before input 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 tests > error message should not be hidden before input is fired
Expected false to be true.
<Jasmine>
@file:///...goat-book/src/lists/static/tests/Spec.js:48:40
<Jasmine>

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

Example 18. src/lists/static/lists.js (ch16l021)
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 in to 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:

Example 19. src/lists/static/tests/Spec.js (ch16l022)
describe("Superlists tests", () => {
  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 deduplication!

  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 to strip down our tests:

Example 20. src/lists/static/tests/Spec.js (ch16l023)
  it("sense-check our html fixture", () => {
    expect(errorMsg.checkVisibility()).toBe(true);
  });

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

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

  it("error message should not be hidden before input 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, let’s check they still pass:

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


Superlists tests
  * error message should be hidden on input
  * sense-check our html fixture
  * error message should not be hidden before input 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:

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

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

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

   it("error message should not be hidden before input 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 tests
  * error message should be hidden on input
  * sense-check our html fixture
  * error message should not be hidden before input 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? But JavaScript is too chill for that. You can call a function with too many or too few arguments, and JS will just deal with it.

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

Example 22. src/lists/static/lists.js (ch16l025)
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:

Example 23. src/lists/static/lists.js (ch16l026)
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 tests > error message should be hidden 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:

Example 24. src/lists/static/lists.js (ch16l027)
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:

Example 25. src/lists/templates/base.html (ch16l028)
    </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"
Note
We’re using <script> tag to import our code, but modern JavaScript lets you use import and export to explicitly import particular parts of your code. But 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! We don’t need to manually hack style.display=none, we can work with the Boostrap framework, and just remove the .is-invalid class.

OK let’s try it in our implementation:

Example 26. src/lists/static/lists.js (ch16l029)
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 tests > error message should be hidden 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, and 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:

Example 27. src/lists/static/tests/SpecRunner.html (ch16l030)
  <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 tests
  * error message should be hidden on input
  * sense-check our html fixture
  * error message should not be hidden before input 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:

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

Enjoy the way the tests keep passing even though we’re giving the function too many arguments! Let’s strip them down anyway:

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

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

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

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

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

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

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

Columbo Says: wait for Onload

Wait, there’s just one more thing…​

— Columbo
fictional American detective

Finally, whenever you have some JavaScript that interacts with the DOM, it’s always 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 modern js onload boilerplate is minimal:

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

JavaScript Testing in the TDD Cycle

You may be wondering how these JavaScript tests fit in with our "double loop" TDD cycle. The answer is that they play exactly the same role as our Python unit tests.

  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.

We’re almost ready to move on to [part3]. The last step is to deploy our new code to our servers. Don’t forget to do a final commit including base.html first!

There is more JavaScript fun in this book too! Have a look at the Rest API appendix when you’re ready for it.

Note
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.
JavaScript Testing Notes
  • One of the great advantages of Selenium is that it allows 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.

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

  • 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.

  • 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] .