You can never understand one language until you understand at least two.
English author and journalist
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. |
Let’s add a new functional test to the ItemValidationTest
class:
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)
)
)
-
We use another of our
wait_for
invocations, this time withassertTrue
. -
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:
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:
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. |
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. |
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:
</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>
-
document.querySelector
is a way of finding an element in the DOM, using CSS selector syntax, very much like the Seleniumfind_element(By.CSS_SELECTOR)
method from our FTs. Grizzled readers may remember having to use jQuery’s$
function for this. -
oninput
is how you attach an event listener "callback" function, which will be called whenever the user inputs something into the text box. -
Arrow functions
() ⇒ {…}
are the new way of writing anonymous functions in JavaScript, a bit like Python’slambda
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? -
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. |
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.
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!
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!
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?
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:
@@ -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:
describe("Superlists tests", () => { //(1)
it("smoke test", () => { //(2)
expect(1 + 1).toEqual(2); //(3)
});
});
-
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. -
The
it
block is a single test, a bit like a method in a Python test class. Similarly to thedescribe
block, we have a name and then a function to contain the test code. -
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 ofassertEqual
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.
Let’s try adding a deliberate failure to see what that looks like:
it("smoke test", () => {
expect(1 + 1).toEqual(3);
});
Now if we refresh our browser, we’ll see red (Our Jasmine tests are now red):
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.
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.
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();
});
-
The
beforeEach
andafterEach
functions are Jasmine’s equivalent ofsetUp
andtearDown
. -
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.
-
A little quirk of JavaScript here, because we want the same
testDiv
variable to be available inside both thebeforeEach
andafterEach
functions, we declare the variable with thislet
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.
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)
});
-
We retrieve our error div with
querySelector
again, and then use another fairly new API in JavaScript-Land calledcheckVisibility()
. -
We manually hide the element in the test, by setting its
style.display
to "none". -
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:
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.)
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:
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)
});
-
Let’s keep the first smoke test, it’s not doing any harm.
-
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.
-
We retrieve the
<input>
element from the DOM, in a similar way to how we found the error message div. -
Here’s how we simulate a user typing into the input box.
-
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:
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.
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?
Let’s add a couple of debug prints, or "console.logs":
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:
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.
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.
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):
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:
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);
});
});
-
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 [...]
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?
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:
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)
});
-
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()
:
const initialize = () => {
const textInput = document.querySelector("#id_text");
textInput.oninput = () => {
const errorMsg = document.querySelector(".invalid-feedback");
errorMsg.style.display = "none";
};
};
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
:
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)
});
-
Let’s define some constants to represent the selectors for our input element and our error message div.
-
We can use JavaScript’s string interpolation (the equivalent of f-strings) to then define the css selectors for the same elements.
-
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).
-
We use a bit more interpolation to reuse the constants in our HTML template. A first bit of deduplication!
-
Here’s why
textInput
anderrorMsg
can’t be constants: we’re re-creating the DOM fixture in everybeforeEach
, so we need to re-fetch the elements each time.
Now we can apply some DRY to strip down our tests:
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:
@@ -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()
:
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:
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:
const initialize = (inputSelector, errorSelector) => {
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:
</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!
|
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:
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:
<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:
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:
@@ -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:
<script>
initialize("#id_text");
</script>
And we can run the FT one more time, just for safety.
Wait, there’s just one more thing…
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:
<script>
window.onload = () => {
initialize("#id_text");
};
</script>
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.
-
Write an FT and see it fail.
-
Figure out what kind of code you need next: Python or JavaScript?
-
Write a unit test in either language, and see it fail.
-
Write some code in either language, and make the test pass.
-
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. |
-
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.