This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.
To download the projects, go to the GitHub repository learning-web-dev-code
and follow the instructions there.
I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.
So far, all of our JavaScript code resided in a single file – be it an .html
file or a .js
file. In this chapter, we learn how to split it up into multiple files. And how to automatically test if the code we write is correct.
Let’s start with the module simple-module/library.js
:
export const add = (x, y) => x + y;
Roughly, module is just another name for JavaScript file. Normally, everything we create inside a module is only accessible within that module. That changes if we write export
before (e.g.) a const
variable declaration – as we did above. Now we can import the function add()
from another module – e.g., the module simple-module/main.js
:
import { add } from './library.js';
console.log('One plus one is ' + add(1, 1));
These are the parts of the import
statement in the first line:
from './library.js'
: What file are we importing from? It can be a full URL with a protocol or a relative path (the segment separators must be slashes – like in Unix). Relative paths start with either one or two dots.
import { add }
: What are we importing from library.js
? If we import individual items, they are enclosed in curly braces and separated by commas. The import statement is similar to a variable declaration in that it also creates a variable in the current module. However, the value of the variable comes from another module.
You can run main.js
with Node.js to see that it works as expected:
cd learning-web-dev-code/projects/
node simple-module/main.js
A library is a collection of one or more modules that provides functionality to other modules. Unsurprisingly, simple-module/library.js
is a library. Many libraries are delivered via npm and used in many projects. But most projects also have internal libraries – often single library modules such as library.js
.
Another way of importing creates a so-called namespace object for a module that we import. If we implement main.js
with a namespace import, it looks like simple-module/main-ns.js
import * as lib from './library.js';
console.log('One plus one is ' + lib.add(1, 1));
In other words: The new variable lib
refers to an object that has one property for each export that module library.js
has.
Node.js has a number of built-in modules whose names start with node:
– e.g., this is the name of a module that lets us access the file system: node:fs
. All of these modules are listed on a web page, with links to more information.
Consider the following function from module library.js
:
const add = (x, y) => x + y;
In order to check that it works for numbers and strings, we’d do something like this in a JavaScript console:
> add(1, 2)
3
> add('yes', 'no')
'yesno'
However, there is one downside: As our code evolves and changes, we have to perform these checks again and again, to ensure that nothing was broken. The idea behind automated testing is: What if the computer performs these checks for us? Then we can repeat that easily and often.
If we are to automate checks, we need a way to check if values are equal. Node.js has the built-in module node:assert
for that. It exports assertion functions because they assert things that must be true. In the following code, we check equality via an assertion (line A):
import * as assert from 'node:assert/strict';
const toUpper = str => str.toUpperCase();
assert.equal( // (A)
toUpper('yes'), 'YES'
);
The assertion in line A says: “We assert that the result of the function call is 'YES'
”. If that is the case, nothing happens and execution moves on to the next statement. Otherwise, the program stops and an error is reported (there are ways to prevent that but that’s the topic of a future chapter).
If we use the Node.js REPL, all node:
modules are already imported – which is why we can easily try out assert.equal()
:
> assert.equal(1 + 1, 2);
Once again, the check succeeds and nothing happens. If the check fails, an AssertionError
is reported:
> assert.equal(1 + 1, 0);
AssertionError
With equality checks, we once again encounter the issue of primitive values vs. objects: Just like the ===
operator, assert.equal()
only considers two objects to be equal if they have the same identity:
> assert.equal(['a'], ['a']);
AssertionError
However, there is also an assertion function that looks at the contents of objects when comparing them:
> assert.deepEqual(['a'], ['a']);
Now the check succeeds.
Assertions would already be enough to replicate our console interactions with add()
. However, if there are more than a few assertions, our code profits from more structure. That and other features are provided by Node.js’s built-in “test runner”. We use it in simple-module/library_test.js
:
import { test } from 'node:test';
import * as assert from 'node:assert/strict';
import { add } from './library.js';
test('add() must work for numbers and strings', () => { // (A)
assert.equal(
add(1, 2),
3
);
assert.equal(
add('yes', 'no'),
'yesno'
);
});
In line 1, we import the function test()
from the test runner module. A test is a function that we register via test()
(line A). That is similar to how we added event listeners to HTML elements. When we register a test, we also give it a descriptive name; via the first argument of test()
. We can also say that we defined a test.
The suffix _test
in the filename library_test.js
is a common way of marking files that contain tests. That enables the Node.js test runner to run all tests in a project.
Let’s use the test runner:
cd learning-web-dev-code/projects/
node --test simple-module/library_test.js
The shell command option --test
switches Node.js to a test runner mode.
In this section, we learn things that we need for your next project. Some of what we’ll learn is a bit complicated, so don’t worry if you don’t understand everything completely. A rough understanding is good enough!
JavaScript has three important units of text.
First, characters are something we have worked with already. .length
counts characters and we can access characters by index (similarly to arrays):
> 'abc'.length
3
> 'abc'[1]
'b'
Second, code points are the characters of the Unicode standard – which is the standard for displaying plain text written in many scripts (Latin, Greek, Cyrillic, Chinese, Korean, etc.). It is supported by virtually all operating systems and web browsers. Sometimes, we need two JavaScript characters to represent a single code point:
> '🙂'.length
2
We can use Array.from()
to split a string into code points:
> Array.from('a🙂b')
[ 'a', '🙂', 'b' ]
However, things go even further than that: Unicode has the concept of a grapheme cluster. It represents a written symbol as displayed on screen or paper. Some grapheme clusters consist of more than one code point:
> Array.from('😵💫')
[ '😵', '\u200D', '💫' ]
> '😵💫'.length
5
Handling grapheme clusters is beyond the scope of this book, you can read about them in “Exploring JavaScript”.
Strictly speaking, a code point is a number. We can use string.codePointAt()
to get the code point for the 1–2 JavaScript characters at a given index (where the index is for JavaScript characters):
> 'a'.codePointAt(0)
97
> '🙂'.codePointAt(0)
128578
Method String.fromCodePoint()
converts a code point to a string with one or two JavaScript characters:
> String.fromCodePoint(97)
'a'
> String.fromCodePoint(128578)
'🙂'
while
loops A while loop is similar to an if
statement. However, where if
runs its true branch at most once, while
runs its branch as long as its condition is true. The following code demonstrates how that works:
const logNTimes = (n, str) => {
while (n > 0) {
console.log(n + ' ' + str);
n -= 1; // (A)
}
};
logNTimes(3, 'Beetlejuice');
Note that in line A, we used the -=
operator. The following two statements are equivalent:
n -= 1;
n = n - 1;
This is the output of the function call in the last line:
3 Beetlejuice
2 Beetlejuice
1 Beetlejuice
Sometimes we want to go through (e.g.) an array of values repeatedly and continue at the beginning after we have reached the end. In that case, the index is said to rotate. The following function increments an index (adds one to it), with the length of an array as an upper limit:
const inc = (len, index) => {
index += 1;
while (index >= len) {
index -= len;
}
return index;
};
We use while
instead of if
because index
may already be much larger than len
.
With inc()
, we can cycle through the indices of an array:
> inc(3, 0)
1
> inc(3, 1)
2
> inc(3, 2)
0
> inc(3, 0)
1
We can simplify inc()
if we use JavaScript remainder operator (%
). If we perform an integer division, we have:
quotient = dividend / divider
The multiple of the divider can’t always completely “fill” the dividend. The remainder is the gap that is left:
dividend = (divider × quotient) + remainder
Let’s try out JavaScript’s remainder operator:
> 8 % 4 // 4 fits without a gap
0
> 8 % 5 // 5 leaves a gap of 3
3
With the remainder operator, rotating indices becomes much simpler:
> (0 + 1) % 3
1
> (1 + 1) % 3
2
> (2 + 1) % 3
0
> (0 + 1) % 3
1
This is what a simpler inc()
looks like:
const inc = (len, index) => {
return (index + 1) % len;
};
encode-decode-text/
ROT13 is a simple way of encrypting and decrypting text. It is also called “Rotate13” or “rotate by 13 places”. Encryption means converting a letter to the letter that comes 13 positions later (starting from the beginning after the end):
Considering that the Latin alphabet has 26 letters, decrypting a text is the same as encrypting it. You can see that in the previous list: Encrypting “a” produces “n”. Encrypting “n” produces “a” (therefore decrypting it).
Let’s use a function rot13()
(whose code we’ll see soon) to encrypt and decrypt a text:
> rot13('Let’s party like it’s 1999!')
'Yrg’f cnegl yvxr vg’f 1999!'
> rot13('Yrg’f cnegl yvxr vg’f 1999!')
'Let’s party like it’s 1999!'
Note that only letters are encrypted.
ROT13 is not a safe way of encrypting text. However, it’s sometimes used online to make some half-secret text initially unreadable – e.g., a text with spoilers or a dirty joke. There are many websites online that let us ROT13-encode text and most Unix operating systems even have a built-in rot13
shell command.
encode-decode-text/rot13.js
Let’s explore how we can implement rot13()
. The following function rotates characters:
export const rot13Char = (baseChar, char) => {
const baseCodePoint = baseChar.codePointAt(0);
let relCodePoint = char.codePointAt(0) - baseCodePoint; // (A)
relCodePoint = (relCodePoint + 13) % 26; // (B)
return String.fromCodePoint(
baseCodePoint + relCodePoint
);
};
This is how we rotate a lowercase letter such as 'c'
:
We compute the “position” of the character 'c'
(zero being the position of 'a'
) by subtracting the code point of 'a'
from its code point (line A). The result is 2
.
Then we add 13 to the position and make sure that the result rotates and therefore remains smaller than 26 (line B).
Finally, we add the new position to the code point of 'a'
so that we once again have an actual character (and not a position in the alphabet).
For lowercase letters such as 'c'
, the baseChar
is 'a'
. For uppercase letters, the baseChar
is 'A'
.
Note that rot13Char
is exported. That enables us to test via another module.
This is how rot13()
is implemented:
export const rot13 = (str) => {
let result = '';
for (const char of Array.from(str)) {
if (char.length === 1 && char >= 'A' && char <= 'Z') { // (A)
result += rot13Char('A', char);
} else if (char.length === 1 && char >= 'a' && char <= 'z') {
result += rot13Char('a', char);
} else {
result += char;
}
}
return result;
};
We only rotate letters and handle uppercase letters and lowercase letters separately. To be completely safe, we check char.length
because, e.g., the string Banana
fulfills the condition in line A but is not a letter:
> 'Banana' >= 'A'
true
> 'Banana' <= 'Z'
true
encode-decode-text/rot13_test.js
These are the tests for rot13Char()
and rot13()
:
import test from 'node:test';
import assert from 'node:assert/strict';
import { rot13, rot13Char } from './rot13.js';
test('rot13Char()', () => {
assert.equal(
rot13Char('a', 'a'), 'n'
);
assert.equal(
rot13Char('a', 'n'), 'a'
);
assert.equal(
rot13Char('A', 'A'), 'N'
);
assert.equal(
rot13Char('A', 'N'), 'A'
);
});
test('rot13(): once', () => {
assert.equal(
rot13('This is a secret!'),
'Guvf vf n frperg!'
);
assert.equal(
rot13('Guvf vf n frperg!'),
'This is a secret!'
);
});
test('rot13(): twice', () => {
const rot13Twice = (str) => {
assert.equal(
rot13(rot13(str)),
str
);
};
rot13Twice('');
rot13Twice('one space');
rot13Twice('UPPERCASE lowercase');
rot13Twice('Non-letters: 1 ! * /');
});
In the last test, we check if rotating twice gets us back to the original.
In tests, it is important to check all kinds of values – e.g., if a function accepts strings strings, it is a good idea to invoke it with an empty string because it may not have accounted for that value. That’s what we did in the last test.
rot13()
: encode-decode-text/encode-decode-text.html
So far, we have seen the following two files of project encode-decode-text
:
rot13.js
: the implementation of rot13()
rot13_test.js
: the tests for rot13()
(and its helper function rot13Char()
)We will only ever run rot13_test.js
via Node.js – or rather, its test runner. There is one more file in this project:
encode-decode-text.html
: a web user interface for rot13()
We’ll only ever “run” this file via a web browser. Therefore, rot13.js
is a library that we use from browser code and from Node.js code.
This time we use a <textarea>
for the input, so that there is more room for text. But using them is not much different from using a text field:
<div>
<textarea cols="80" rows="5"></textarea>
</div>
<div>
<button>Encode/decode</button>
</div>
There isn’t much new in the JavaScript code:
import { rot13 } from './rot13.js';
const textarea = document.querySelector('textarea');
document.querySelector('button')
.addEventListener(
'click',
() => {
textarea.value = rot13(textarea.value);
}
);
This is the first time we import a function in browser code (line 1)!
encode-decode-text/encode-decode-text.html
Alas, browsers don’t let use import things if the web page has a file:
URL. Therefore, we need to start a web server:
cd learning-web-dev-code/projects/
npx http-server
Now the web app is available here:
http://127.0.0.1:8080/encode-decode-text/encode-decode-text.html