Unit Testing
Learning Objectives
- You know of unit testing and you can create unit tests with Deno.
Unit testing involves testing individual functions and components to verify that they work as expected. This is typically done by verifying that specific inputs produce expected outputs. Unit tests are often created with the help of a testing framework that provides functionality for defining and running tests.
First unit test
We’ll start with an empty project folder and a failing unit test. First, create a deno.json file in the project root folder and add the following dependency to it.
{
"imports": {
"@std/assert": "jsr:@std/assert@1.0.15"
}
}
The above imports the @std/assert module from Deno’s standard library, which provides assertion functions that are useful when writing tests.
Then, create a file called app_test.js in the project root folder, and add the following content to it.
import { assertEquals } from "@std/assert";
const fun = () => {
return "hello world!";
};
Deno.test("Fun should return 'hello world'", () => {
assertEquals(fun(), "hello world");
});
The above code defines a simple function fun that returns the string hello world!. The test named “Fun should return ‘hello world’” checks whether the output of the function fun equals the string hello world using the assertEquals function from the @std/assert module.
The
assertEqualsfunction checks whether two values are equal. The first parameter is the actual value (the output of the functionfun), and the second parameter is the expected value ("hello world").
Using Deno, run the tests by executing the command deno test in the project root folder. The output should be similar to the following.
$ deno test
running 1 test from ./app_test.js
Fun should return 'hello world' ... FAILED (4ms)
ERRORS
Fun should return 'hello world' => ./app_test.js:7:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- hello world!
+ hello world
throw new AssertionError(message);
^
at assertEquals ...
FAILURES
Fun should return 'hello world' => ./app_test.js:7:6
FAILED | 0 passed | 1 failed (12ms)
error: Test failed
The test tells that it failed because the actual value returned from the function fun ("hello world!") is not equal to the expected value ("hello world"). The test output shows both the failed test (“Fun should return ‘hello world’”) and the difference between the actual and expected values:
[Diff] Actual / Expected
- hello world!
+ hello world
We can fix the failing test by modifying the function fun to return the expected value. Change the return value of the function fun from "hello world!" to "hello world" as follows.
import { assertEquals } from "@std/assert";
const fun = () => {
return "hello world";
};
Deno.test("Fun should return 'hello world'", () => {
assertEquals(fun(), "hello world");
});
Now, when the command deno test is run again, no errors are reported.
$ deno test
running 1 test from ./app_test.js
Fun should return 'hello world' ... ok (0ms)
ok | 1 passed | 0 failed (6ms)
Writing unit tests
Individual tests are defined with Deno’s Deno.test function, which is given a test name and a function that contains the test code. In unit testing, the test code calls specific functions of the tested components and checks that the outputs are as expected.
Deno’s standard library contains assertions, which are used to check whether the outputs of the functions match the expected values, verifying the expected functionality of the components. As an example, the assertEquals function is used to check whether an output from a function or a component matches an expected value. Similarly, the assertNotEquals function would be used to check that the output does not match an expected value.
In the following, we define two unit tests for a simple function that adds two numbers.
import { assertEquals, assertNotEquals } from "@std/assert";
const sum = (a, b) => {
return a + b;
};
Deno.test("3 + 5 = 8", () => {
assertEquals(sum(3, 5), 8);
});
Deno.test("3 + 5 != 9", () => {
assertNotEquals(sum(3, 5), 9);
});
Running the tests using deno test, we see that both tests pass.
$ deno test
running 2 tests from ./app_test.js
3 + 5 = 8 ... ok (0ms)
3 + 5 != 9 ... ok (0ms)
ok | 2 passed | 0 failed (8ms)
When the deno test command is run, Deno identifies test files based on file names. If the name of a file ends with _test.js, Deno assumes that the file contains tests.
Following the convention, we do not have to provide information to Deno on which tests should be run. Instead, we can just run the deno test command and Deno finds the test files for us.
Separating tests from application logic
The previous examples demonstrate the core idea of unit testing: we have a function that we want to test, and we write a test that verifies that the function works as expected.
Unit tests are typically written in separate files from the application logic. Let’s separate the above functionality into two parts, a file called app.js and a file called app_test.js.
The file app.js defines the function sum and exports it for others to use. This is done as follows.
const sum = (a, b) => {
return a + b;
};
export { sum };
Now, app_test.js needs to import the function sum from the file app.js for testing. Like before, the app_test.js imports assertions from Deno’s test library, and has the tests that are used to verify whether the function works as expected. After extracting the function sum from the file, and importing it from app.js, the file app_test.js looks as follows.
import { assertEquals, assertNotEquals } from "@std/assert";
import { sum } from "./app.js";
Deno.test("3 + 5 = 8", () => {
assertEquals(sum(3, 5), 8);
});
Deno.test("3 + 5 != 9", () => {
assertNotEquals(sum(3, 5), 9);
});
Now, when we run the tests, we again see that they pass.
running 2 tests from ./app_test.js
3 + 5 = 8 ... ok (0ms)
3 + 5 != 9 ... ok (0ms)
ok | 2 passed | 0 failed (1ms)
Separate folders for tests
When the application grows, it is common to separate the tests into a separate folder. This separation prevents tests from being mixed with application logic, making the codebase easier to maintain. As an example, we could place the application source code into a folder called src and the tests into a folder called tests.
With this, the structure would be as follows.
$ tree --dirsfirst
.
├── src
│ └── app.js
├── tests
│ └── app_test.js
├── deno.json
└── deno.lock
The tests would have to import the function sum from the file in the src folder. The following example demonstrates this.
import { assertEquals, assertNotEquals } from "@std/assert";
import { sum } from "../src/app.js";
Deno.test("3 + 5 = 8", () => {
assertEquals(sum(3, 5), 8);
});
Deno.test("3 + 5 != 9", () => {
assertNotEquals(sum(3, 5), 9);
});
Now, the tests would be run from the project root directory (the folder that contains the folders src and tests), and the tests would be found from the tests folder.
$ deno test
running 2 tests from ./tests/app_test.js
3 + 5 = 8 ... ok (0ms)
3 + 5 != 9 ... ok (0ms)
ok | 2 passed | 0 failed (10ms)
Import mappings
Similar to working with the client-side project, we can define import mappings to deno.json. For example, the mapping "@src/": "./src/" maps the src folder to @src. Modify the deno.json to match the following:
{
"imports": {
"@std/assert": "jsr:@std/assert@1.0.15",
"@src/": "./src/"
}
}
Now, modify app_test.js to use the @src/ path instead of tracing backwards to find the file to test:
import { assertEquals, assertNotEquals } from "@std/assert";
import { sum } from "@src/app.js";
Deno.test("3 + 5 = 8", () => {
assertEquals(sum(3, 5), 8);
});
Deno.test("3 + 5 != 9", () => {
assertNotEquals(sum(3, 5), 9);
});
When we run the tests, we see that they still pass.
$ deno test
running 2 tests from ./tests/app_test.js
3 + 5 = 8 ... ok (1ms)
3 + 5 != 9 ... ok (0ms)
ok | 2 passed | 0 failed (10ms)
The import mappings are often project-specific. In the course assignments, we have not used a separate tests and src folders, unless explicitly stated.
Adding more tests
Let’s look at another example. Create a folder called services to the src folder, and create the file helloService.js into it. Place the following code to the file.
let message = "Hello service world!";
const getHello = () => {
return message;
};
const setHello = (newMessage) => {
message = newMessage;
};
export { getHello, setHello };
Then, create a folder called services under tests, and create a file called helloService_test.js to the folder. Place the following content to the file.
import { assertEquals } from "@std/assert";
import { getHello } from "@src/services/helloService.js";
Deno.test("Function getHello returns 'Hello service world!'", () => {
assertEquals(getHello(), "Hello service world!");
});
Now, the structure of the project should be as follows.
$ tree --dirsfirst
.
├── src
│ ├── services
│ │ └── helloService.js
│ └── app.js
├── tests
│ ├── services
│ │ └── helloService_test.js
│ └── app_test.js
├── deno.json
└── deno.lock
When we run the tests, we see that all the test files are being tested.
$ deno test
running 2 tests from ./tests/app_test.js
3 + 5 = 8 ... ok (0ms)
3 + 5 != 9 ... ok (0ms)
running 1 test from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (0ms)
ok | 3 passed | 0 failed (158ms)
Let’s add another test for the helloService.js file. In the following newly added test, we test both the getHello and the setHello functions.
import { assertEquals } from "@std/assert";
import { getHello, setHello } from "@src/services/helloService.js";
Deno.test("Function getHello returns 'Hello service world!'", () => {
assertEquals(getHello(), "Hello service world!");
});
// new test
Deno.test("Message set using setHello returned from getHello", () => {
const hello = getHello();
setHello("hello world!");
assertEquals(getHello(), "hello world!");
setHello(hello);
});
Again, when we run the tests, we see that the tests pass.
$ deno test
running 2 tests from ./tests/app_test.js
3 + 5 = 8 ... ok (0ms)
3 + 5 != 9 ... ok (0ms)
running 2 tests from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (0ms)
Message set using setHello returned from getHello ... ok (0ms)
ok | 4 passed | 0 failed (137ms)
In the example above, we avoid a situation where running a test results in the internal state of a tested component being changed after the test. If tests would leave the internal state of a component changed, we might by mistake create dependencies between tests — unit tests should always test functionality in isolation.
What to test
When writing unit tests, the focus is on testing individual functions and components. Tests should cover the default behavior of the function as well as edge cases.
Edge cases are scenarios where the function behaves differently from its default behavior. For example, if a function is expected to return a value when the input is a number, one possible edge case could be having the input as a string. Edge cases are often where the function is most likely to fail.
In this chapter, we focused on unit testing of functions and components in Deno. When working with Svelte, testing differs somewhat — for a starter, see the Svelte documentation on testing at https://svelte.dev/docs/svelte/testing.
Summary
In summary:
- Unit testing involves testing individual functions and components by verifying that specific inputs produce expected outputs.
- Deno provides built-in testing support through the
Deno.test()function and the@std/assertmodule for assertion functions likeassertEqualsandassertNotEquals. - Tests should be separated from application logic; a common practice is to organize tests in a separate
tests/folder and source code undersrc/folder. - Tests should avoid changing the internal state of components to prevent dependencies between tests - each test should run in isolation.