Testing with Deno — Part 1 Basics

Mayank C
Tech Tonic

--

Introduction

Deno is not only a runtime for TypeScript and JavaScript applications. It is also a complete tool chain. Apart from being a runtime, the second most common use case is to do testing with Deno. The test framework or test runner is mostly used for unit testing, but it’s flexible enough to do integration tests too.

Deno’s core runtime comes with a good feature-rich test runner. The test runner used to be very basic, but since v1.10, the runner has been updated with more functionality. In this article and some more following this, we’ll go over the test runner in detail.

Test runner features

Despite being feature rich, Deno’s test runner isn’t perfect (not even after the upgrade). It has a number of useful features and some limitations as well.

Here are the pros and cons of the test runner:

Features

Here are the features in detail:

  • Parallel execution: The updated test runner can run the unit tests in parallel. This significantly reduces the CI/CD time.
  • Asserts: The standard library’s testing module comes with numerous asserts that are very useful in validating test results.
  • Recursive: The test runner recursively runs finds and runs all tests present in a given directory. This means that a module could be tested completely given its root directory.
  • Permissions: The tests run within sandbox, thereby checking more error scenarios in the code.
  • Conditional: The tests can be marked conditional to either run or skip them based on the outcome of pattern matching. This is especially useful in running environment specific tests.

Limitations

The test runner does have some limitations (hopefully they’d be fixed in future):

  • Console: The output and result of the test is sent only to console. This poses an issue when running tests through scripts (like CI/CD).
  • Text output: The test runner produces only a textual output. There is no support for JSON. A JSON formatted output would have been useful to analyze later and/or generate stats.

Despite the limitations, the test runner is very useful. There is no need to install anything extra.

Test runner

Let’s go over the basics of the test runner in detail.

Imports

As the test runner is part of the Deno core (available through a command), there is no need to import anything. Only for using asserts, there is an import required as asserts are part of the testing module of the standard library:

import * as asserts from "https://deno.land/std/testing/asserts.ts";

We’ll go over all the asserts in another article of this series.

Running tests

To run tests, the test command is used. The input directory or list of files is optional. If unspecified, it runs the tests present in the current directory.

deno test myApp/
//runs tests present inside the myApp directory
deno test myApp/test/a_test.ts
//runs tests present in a_test.ts file only
deno test
//runs all tests present inside the current directory

This would recursively find and run all the test files present in the myApp directory. The test files are the ones that match the following pattern:

{*_,*.,}test.{js,mjs,ts,jsx,tsx}//a_test.ts
//a.test.ts
//a_test.js

The test command has a number of other options:

  • — allow-YYY: Set permissions like allow-read, allow-write, allow-net, etc. These permissions would be useful in testing of the actual code, like if the code tries to access outside the sandbox, an error should be thrown, etc.
  • — fail-fast: Aborts test execution as soon as the first error is encountered
  • — filter: Runs tests whose names match with the filter
  • — jobs: Runs these number of tests in parallel (We’ll see this in the next article of this series)

Location of tests

The placement of unit tests is a personal/organizational preference. There are two usual places where tests could go:

  • With code: This is Deno’s style. The tests reside next to the source file.
  • Separate: This is another style where all the tests are present in a separate test directory

There is no particular advantage with any of the approach. It all depends on personal/organizational preference/policy.

Test definition

To create a test, Deno.test needs to be called with a test name and a test function (at minimum).

Deno.test('test 1', () => {
getLongUuid(2);
});
//orDeno.test('test 1', async () => {
await getReallyLongUuid(50);
});

The function is the test case. It could be sync or async.

The other inputs to Deno.test function are:

  • ignore: This is a condition that, if evaluated to true, would cause the test case to be ignored by runner
  • only: This is a condition that, if evaluated to true, would cause only that test case to be executed by runner

Test result

The test is considered passed if the test function finishes without raising any exception.

Pass = No exception

The test writer needs to use asserts to make sure that test output is checked properly. If any of the asserts fail, it’d cause an exception and the test would fail. If a test is supposed to raise an exception, then an error assert can be used that would not raise an exception if it caught an exception (expected exception).

Test phases

Deno’s test runner goes through two phases:

  • Phase 1: Collect & validate all tests (aka registration)
  • Phase 2: Executed all collected tests (aka execution)

Registration

In phase 1 i.e. registration phase, Deno does the following:

  • Recursively scan all directories under given directory or current directory
  • Get all the files matching the test pattern {*_,*.,}test.{js,mjs,ts,jsx,tsx}
  • Reads the tests present in each of the file (Deno.test)
  • Parse each test (should be a valid TS/JS code)
  • Add tests to the pending list

If any test case fails parsing, test runner doesn’t move to phase 2.

Execution

In the execution phase, Deno does the following:

  • Go through the pending list
  • Execute all the tests one-by-one or in parallel (depends on the jobs parameter)
  • If any test case fails & fail-fast isn’t set, record the error and continue
  • Else, raise error and exit and test runner
  • At the end, print the test results on console

The tests could fail. The failure handling is decided by the test executor. If fail-fast is set, then Deno would abort the test execution at first error.

That’s all about the theory part. Let’s move on to some examples.

Examples

Recall that the primary use of test runner is to do unit tests. This means that all the exported functionality must be tested (as much as possible). Usually, there would be an equivalent test file for each source file. For example:

src/a.ts should have an equivalent src/a_test.ts or test/a_test.ts

The a_test.ts file would import all functionality exported by the module a. It’d regress through the functionality including expected exceptions.

First, if there are no test files found under a given directory, Deno would print a message on console:

> deno test
No matching test modules found

Before we go further, let’s write a small module a.ts. For simplicity, this module exports a single function: getLongUuid. It’d generate a long UUID depending on the len parameter (must be between 1 and 5) provided as input. If len is 0, it’d raise an error. This example is good enough to demonstrate the basics of the test runner.

import {generate} from "https://deno.land/std/uuid/v4.ts";export function getLongUuid(len: number): string {
if(len===0 || len>5)
throw new Deno.errors.InvalidData('len must be between 1 and 5');

let uuid:string="";
for(let i=0; i<len; i++)
uuid+=`${generate()}-`;
return uuid.slice(0, -1);
}

Let’s write a simple test case that tests an error condition (len=0).

import {getLongUuid} from "../lib/a/a.ts";Deno.test('len is 0', () => {
getLongUuid(0);
});

Running this test raises an exception, meaning the test case has failed. This is because the function getLongUuid raises an exception if len is 0. The code works fine, still test case fails.

running 1 test from file:///Users/mayankc/Work/source/utApp/test/a_test.ts
test len is 0 ... FAILED (2ms)
failures:len is 0
InvalidData: len must be between 1 and 5
at getLongUuid (file:///Users/mayankc/Work/source/utApp/lib/a/a.ts:5:15)
at file:///Users/mayankc/Work/source/utApp/test/a_test.ts:4:5
at asyncOpSanitizer (deno:runtime/js/40_testing.js:21:15)
at resourceSanitizer (deno:runtime/js/40_testing.js:58:13)
at exitSanitizer (deno:runtime/js/40_testing.js:85:15)
at runTest (deno:runtime/js/40_testing.js:199:13)
at Object.runTests (deno:runtime/js/40_testing.js:244:13)
at file:///Users/mayankc/Work/source/utApp/test/$deno$test.js:1:27
failures:len is 0test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out (47ms)

This has failed because the exception was an expected one, but the test didn’t catch it. The test can be modified to pass if an exception is raised. To do that, we need to import assertThrows from the standard library’s testing module. Instead of calling getLongUuid directly, it’d be called from the assertThrows function that would compare the exception with expected exception. If they match, the test is considered as passed.

import {getLongUuid} from "../lib/a/a.ts";
import {assertThrows} from "https://deno.land/std/testing/asserts.ts";
Deno.test('len is 0', () => {
assertThrows(() => getLongUuid(0), Deno.errors.InvalidData);
});

Let’s run it (it passed this time!):

running 1 test from file:///Users/mayankc/Work/source/utApp/test/a_test.ts
test len is 0 ... ok (3ms)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (29ms)

Now, let’s add some more tests like len>5, len is between 1 and 5. The test cases are a combination of positive and negative test cases. The positive tests have an assert to check the length of the output. Asserts are a must in unit test code. Here is the complete test suite for a.ts:

import {getLongUuid} from "../lib/a/a.ts";
import {assert, assertThrows} from "https://deno.land/std/testing/asserts.ts";
const SIZE_OF_SINGLE_UUID=36;Deno.test('len is 0', () => {
assertThrows(() => getLongUuid(0), Deno.errors.InvalidData);
});
Deno.test('len is 6', () => {
assertThrows(() => getLongUuid(6), Deno.errors.InvalidData);
});
Deno.test('len is 1', () => {
assert(getLongUuid(1).length===SIZE_OF_SINGLE_UUID*1);
});
Deno.test('len is 3', () => {
assert(getLongUuid(3).length===SIZE_OF_SINGLE_UUID*3+2);
});
Deno.test('len is 5', () => {
assert(getLongUuid(5).length===SIZE_OF_SINGLE_UUID*5+4);
});

Here is the output of a run:

> deno test 
running 5 tests from file:///Users/mayankc/Work/source/utApp/test/a_test.ts
test len is 0 ... ok (5ms)
test len is 6 ... ok (1ms)
test len is 1 ... ok (3ms)
test len is 3 ... ok (1ms)
test len is 5 ... ok (2ms)
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (93ms)

That’s all for the first part of the testing series. Here are the other parts:

  • In the second part, we go over advanced features like parallel execution, ignore, only, fail fast
  • In the third part, we go over how to convert test results to JSON (useful for CI/CD, stats, etc.)
  • In the fourth part, we go over all the asserts in detail
  • In the fifth part, we go over doing integration testing with test runner (includes running a load test)

--

--