Playing computer
Learning Objectives
To understand how convertToPercentage works we must build a mental model of how the computer executes our code. To build this model, we use a method called
We will use an interactive code visualiser to play computer.
🕹️👣 Step through
In a JavaScript program, each line is an instruction that will have some effect. For example, a line of code with a variable declaration means “store a new variable with this value in memory”. In the interactive widget, arrows are used to show which line just executed and which line is next to be executed.
Click next to see what happens when the computer executes the following program. Pay particular attention to what happens when the function convertToPercentage is called.
🖼️ Global frame
As we step through the program, we keep track of two things: memory and the line that is being currently executed. We keep track of this information using a
The global frame is always the first frame that gets created when our program starts executing. It is like the starting point for our program, the place where code gets executed first. When we run the code above, decimalNumber and convertToPercentage are both stored in the global frame.
🖼️ Local frame
💡recall
Whenever we call a function a new frame is created for executing the code inside that function. In the example above, we call the function convertToPercentage on line 7 and then a new frame is created for convertToPercentage. Inside the convertToPercentage frame, the computer executes the instructions inside convertToPercentage, storing new variables in memory and keeping track of the current line that is being executed.
Introduction to Testing
Learning Objectives
In the last module we introduced the idea of writing functions to help us reuse blocks of code. We can build a complete application using these blocks, and even incorporate blocks built by other people. That’s a lot of things which all have to work together correctly - how can we make sure that happens?
Testing
We make sure by testing our code! Testing doesn’t have any special meaning in software - we are going to check that a program does the right thing at the right time. There are many different ways for us to do that though.
In this module we are going to concentrate on unit testing. That means we are testing the individual components of a program - our functions - to ensure they work correctly. In a real project we would also consider how these components work together and with other systems (known as integration testing).
Testing code is something that every developer should be doing, but it wouldn’t make much sense for every developer to write their own tools to test their code. We’re going to use a package to help us write our tests.
Our function
Before we start writing tests we’re going to write the function which we will be testing. In this example we’re going to create a function which will take a time in 24-hour format (eg. 15:00) and convert it to 12-hour format (3:00 pm). We will name our function formatAs12HourClock. Create a new directory to store your files and a new timeConverter.js file.
Stating our problem in the given-when-then structure:
- Given a time in 24-hour format
- When we call
formatAs12HourClock - Then we get back a string representing the same time in 12-hour
To do the conversion we will need to examine the input and determine if the part of it representing the hour is over or under 12. If it’s under we don’t need to change it, if it’s over we need to subtract 12 to get the 12-hour equivalent. Finally we need to add am or pm and return the new value.
Converting that to pseudocode:
// function receives a string representing time in 24-hour format as an argument
// extract digits representing hours
// if hour value over 12, subtract 12
// if hour value under 12, continue
// add am or pm
// return new value
We can write our function as:
// function receives a string representing time in 24-hour format as an argument
function formatAs12HourClock(time) {
// extract digits representing hours
const hours = Number(time.slice(0, 2));
// if hour value over 12, subtract 12
// if hour value under 12, continue
if (hours > 12) {
// add pm and return value
return `${hours - 12}:00 pm`;
}
// add am and return value
return `${hours} am`;
}✍️Exercise: Research new functions
There are two functions used here which you may not have seen before:
Number()String.slice()
Use the MDN docs to research these functions and understand what they are doing here.
We can check that our function works by calling it a couple of times and using console.log() to print the results.
// ...
console.log(formatAs12HourClock("23:00"));
console.log(formatAs12HourClock("14:00"));This does the job, but it doesn’t scale well at all. Imagine we have a lot of functions to test - that would mean lots of console.log() calls cluttering up our files. It also relies on people running the file using Node, so if our tests require anything more complex like a database integration it won’t be possible to run them properly. We’re going to move our tests into an environment where it’s much easier to keep track of everything.
Writing Our First Test
Learning Objectives
It’s time to write our first test! We’re going to start off by checking something we know already works: formatAs12HourClock("23:00").
❗Testing in practice
We wouldn’t usually test our code by writing the tests after we have written the code. We’re doing it here so that we’re only covering one new concept at a time, but in practice it can lead to us writing tests which just tell us what we want to hear.
Instead developers aim to write the tests first according to the product specification, then write the code to make the tests pass. This is called test-driven development and we’ll look at it in the next sprint.
We’re going to need a file to write our tests in. Create a new file called timeConverter.test.js.
💡Directory structure
testing directory to keep things organisedWe need to access our function from our test file which means we’ll need to import it, but before we can do that we need to make it accessible using export. Add the following line to the bottom of timeConverter.js:
// ...
export {formatAs12HourClock};Now we can import it at the top of our test file using the import keyword:
import {formatAs12HourClock} from "./timeConverter.js";Now we can call the function from within timeConverter.test.js, even though it is defined somewhere else. We can export and import multiple functions at the same time by comma-separating them inside the braces.
Testing tools
Node has some built-in tools which can help us with our testing. Using third-party tools like this is common practice for developers, otherwise we would need to write our own. By using industry-standard tools we give other developers confidence in our tests and in our code. In the next sprint we’ll see some other examples.
We need to import two functions from Node into our test file: test and assert:
import {formatAs12HourClock} from "./timeConverter.js";
import assert from "node:assert";
import test from "node:test";Now we have access to everything we need to get started.
Defining a test
We’re going to use the test() function to define our test. Every time we use test() we need to pass it two arguments:
- A string describing what we’re testing
- A function where we will call the function we are testing and define the expected outcome
Passing a function into another function like this may look strange but is a very common pattern in JavaScript. We will look at it in more detail in a future module.
We’ll start by providing the string and an empty function.
import {formatAs12HourClock} from "./timeConverter.js";
import assert from "node:assert";
import test from "node:test";
test("correctly convert time after 12:00", function(){
// TODO
});Inside the function we are going to use assert to compare two values:
- the actual value returned when we call the function we are testing
- the expected value we would see if everything is working correctly
import {formatAs12HourClock} from "./timeConverter.js";
import assert from "node:assert";
import test from "node:test";
test("correctly convert time after 12:00", function(){
assert.equal(formatAs12HourClock("23:00"), "11:00 pm");
});When we run our test:
- The value
"23:00"will be passed toformatAs12HourClock - The code in the function will be executed and the returned value will be passed to
assert.equal()as its first argument, representing the actual value - The actual value will be compared to the second argument, representing the expected value
- The test will pass if the two values match. If they don’t it will fail.
Run the test from the terminal using node timeConverter.test.js. You should see the string we passed to test() printed in green with a check mark next to it, meaning our test passed! Success!
Failing Tests
Learning Objectives
We have written a test and it passed, so can we say that our function works?
The answer is no! We have only tested one aspect of our function: converting an afternoon time. We need to check that it works for morning times as well. We’re going to need a second test.
✍️Exercise: Write another test
Write another test to check that "08:00" will be correctly converted to "08:00 am"
Solution:
test("can correctly convert morning time", function(){
assert.equal(formatAs12HourClock("08:00"),"08:00 am");
});We have a problem when we run the test though - it fails! How can we make sense of the output and figure out what we need to fix?
Interpreting the output
The first change we see is on the second line of the output. We still have our previous test with a check mark next to it but now we also have our new test with a cross. This tells us which test has caused the failure. We also see some summary statistics telling us how many tests have passed or failed in total.
💡Multiple failures
The next section tells us exactly what has caused our test to fail.
AssertionError [ERR_ASSERTION]: '8 am' == '08:00 am'
This is an example of an assertion error. Our function is returning a value, but not the one that it should be. Recall from the last section that our actual value is what is returned to us by the function and in this example it’s "8 am". The expected value was "08:00 am". Our function is returning the wrong thing.
Fixing the bug
It can be surprisingly hard to identify the root cause of an assertion error. It could be the case that there is a flaw in our logic, for example a condition in an if-statement is not defined correctly, but it could just as easily be a typo. We should start by examining the two values and seeing if there are any obvious errors to fix.
💡Debugging tools
- The numbers match, which indicates that we aren’t accidentally subtracting 12 from the value.
- We have added the correct suffix. We have “am” at the end of t string, which means we followed the correct branch of the
if-statement. - Spacing and casing are correct, so we haven’t made a typo formatting the string.
None of these checks are a guarantee that there isn’t a problem with any of these steps, but they do suggest that the problem is somewhere else.
If we look closely at the output we see that the main difference is that the actual output is missing the :00 part of the string. Compare the two branches of the if-statement: In the first branch we add :00 pm to the value of hours but in the second we only add am. Update the second branch:
function formatAs12HourClock(time) {
const hours = Number(time.slice(0, 2));
if (hours > 12) {
return `${hours - 12}:00 pm`;
}
return `${hours}:00 am`;
}We still aren’t quite there! Our values are closer to matching but still not quite there. We’re still missing a leading 0 from the actual value.
This is where the debugging tools would be particularly useful. Without them we can’t see what’s happening inside the function while it runs, but by adding a breakpoint we would be able to check that the value of hours is actually what we think it is. In this case it is 8 rather than 08, so we insert the wrong value into the string literal.
✍️Exercise: Research the problem
Think back to your research on Number() earlier in the sprint. What did you find out about it? Can you find anything in the documentation that would explain why lose the first digit in this case, but it worked in the first test?
Solution:
The `Number()` converts a string into a number, but this isn't always straight-forward. When we call the `.slice()` function we extract the first two characters of the string representing the time. For `"23:00"` this was `"23"` and everything was fine, but for `"08:00"` it is `"08"`. We don't usually write numbers with a leading 0, so what should `Number()` do with it here? It simply ignores it, returning the value `8` that we are more familiar with.We could now write some complex logic to add a 0 back to the front of the string if we have a single-digit number, but before we do that we should revisit our list of requirements. If we look back at what we defined when we wrote the function we see that we don’t need to change the value if it is before 12:00. Writing the logic would be unnecessary. Instead we can simply append “am” to the value passed into the function.
function formatAs12HourClock(time) {
const hours = Number(time.slice(0, 2));
if (hours > 12) {
return `${hours - 12}:00 pm`;
}
return `${time} am`;
}Both tests now pass. It’s important to run all of our tests whenever we make changes to the code, even if we have only been editing a small part of it. It can be difficult to predict how changes in one function will affect the behaviour of others and tests will help us spot any side-effects.
Knowing What to Test
Learning Objectives
We have multiple tests, so now can we confidently say that our function works?
The answer is still no! We need to be sure that our function does everything that the specification says it should, but we also need to think about how it handles unusual inputs or internal errors in the logic. We often need to write lots of tests for each function to be sure they won’t break. It’s a lot of work, but the payoff is reliable code which we can be sure won’t fail in production.
What do we still need to test?
It can be difficult to know when we’re done writing tests, but at a minimum you should be able to cover every scenario covered by the requirements of your project. Another approach is to ask yourself a series of “what if?” questions and see if your tests cover that scenario. In our case we might ask “What if…
- …the expected value is a single-digit afternoon time, eg
"02:00 pm"? - …the argument is not a valid time, eg
"25:00"? - …the argument isn’t a time at all, eg.
"hello"? - …the argument isn’t a string?
- …and many more questions like these
The assert library has other functions available to support tests like these, eg. assert.throws() checks that an error is thrown by a function at an appropriate time. As your applications get more complex you will likely need to bring in external tools to test specific elements of your code, eg. simulating a button being clicked in a web browser. We will look at how we can add additional testing tools in the next sprint.
An application’s test coverage gives us an indication of how many of the functions in a program have been tested and how extensively. More test coverage is always better!
Edge cases
A lot of the strange behaviour we see from programs is cause by a small subset of possible inputs. Think about our time conversion function: how should it handle "00:00"? As a human reader we know that this should be converted to "12:00 am", but the logic we have written would convert it to "00:00 am". We need to think about how we handle this special case.
This is an example of an edge case, where we have a possible value which needs special consideration. Often these values don’t need any adjustments to the code, but in others (like this one) we need to make changes to ensure they are handled correctly. Many of the tests you write will be designed to handle these edge cases.
✍️Exercise: Testing edge cases
Update formatAs12HourClock to handle this edge case and write a test to ensure it does.
Solution:
function formatAs12HourClock(time) {
const hours = Number(time.slice(0, 2));
// This is not the only way to complete this check.
// If you did it a different way why not share your solution in Slack?
if (time === "00:00"){
return `12:00 am`;
}
if (hours > 12) {
return `${hours - 12}:00 pm`;
}
return `${time} am`;
}
export {formatAs12HourClock}//...
test("can correctly convert midnight", function(){
assert.equal(formatAs12HourClock("00:00"),"12:00 am");
});If a function has multiple inputs then it’s possible that two or more of these could represent edge cases. We call these scenarios corner cases - multiple edges are meeting each other.
Anonymous functions
Learning Objectives
We have seen functions written like this:
function convertToPercentage(decimalNumber) {
return `${decimalNumber * 100}%`;
}In our tests we wrote the functions differently:
function(){
assert.equal(formatAs12HourClock("23:00"), "11:00 pm");
}Note the difference between the two: we didn’t give a name to the function in our test.
This is ok, because we don’t need it to have a name. We don’t call the function by name. We passed the function as an argument to the test function. When we execute the code Node will create its own label internally and use that when it needs to reference the function.
We can imagine the test function is defined like this:
function test(label, testFunction) {
// Call the passed test function
testFunction();
}The internal label attached to the function by Node doesn’t matter because the function will only ever be called by Node. We will never need to use it again outside of this test.
Otherwise, these two functions act the same. The only difference between them is whether we created a variable name for the function in the scope where we defined it.
Arrow functions
Learning Objectives
As we progress through this course we will find lots of situations where we can use anonymous functions. In this section we’ll see how we can make them even shorter by removing the function keyword and in some cases reducing everything to a single line.
Types of functions
We have already seen lots of examples of named functions. These are functions defined like we did in the previous module.
function convertToPercentage(decimalNumber) {
return `${decimalNumber * 100}%`;
}In the last section we introduced the concept of anonymous functions where we don’t need to assign a name to the function.
function (decimalNumber) {
return `${decimalNumber * 100}%`;
}The function keyword isn’t the only way for us to define a function. In modern versions of JavaScript we can leave it out, but we still need a way of linking the list of parameters to the function body. We use an arrow symbol (=>) to do so and this is why we call anonymous functions defined this way arrow functions.
(decimalNumber) => {
return `${decimalNumber * 100}%`;
};When using arrow functions we can go a step further and omit the braces and return keyword too. This is called an implicit return but it can only be used when the function body contains a single expression.
(decimalNumber) => `${decimalNumber * 100}%`;This can make it easier and quicker to write functions. It also reduces the number of things we need to read in a function.
✍️Exercise: Using arrow functions
Rewrite your tests in timeConverter.test.js to use arrow functions.
Solution:
test("correctly convert time after 12:00", () => assert.equal(formatAs12HourClock("23:00"), "11:00 pm"));
test("can correctly convert morning time", () => assert.equal(formatAs12HourClock("08:00"),"08:00 am"));
test("can correctly convert midnight", () => assert.equal(formatAs12HourClock("00:00"),"12:00 am"));We can use the implicit return syntax here because the assert.equal() call is the only expression in the function body.
Assigning functions to a variable
Our anonymous functions don’t need to stay anonymous - we can assign them to a variable if we need to. When we want to call the function we can do so using the variable name, just like we would if it was a named function.
Create a new file to try this in.
const doubleNumber = function(number){
return number *2;
}
const halfNumber = (number) => number / 2;
console.log("doubled number:", doubleNumber(2));
console.log("halved number:", halfNumber(2));Running the file prints:
doubled number: 4
halved number: 1