Docs (4.0.0)
Documentation

Tests

Learn how to write and run tests using the Desk test library.

Overview

The Desk framework provides a complete test library, that facilitates running tests using your actual application code from the command line.

This library consists the following elements:

  • Functions for describing test scopes and cases
  • Functions for asserting values and objects during and after each test case
  • Functions for running all tests and collating results
  • The test context — a runtime environment that includes simulations for framework functions such as UI rendering and user navigation, allowing for the application to run normally after which a test case can ‘query’ the resulting state (including UI)

Using the Desk test library, you can include tests alongside your source code, which can then be run using a separate entry point directly from the command line. Refer to the sections below to learn how to add tests to your application.

Importing the test package

From all of your test-related files, you’ll need to import functions for describing tests and performing assertions from the @desk-framework/frame-test package.

You’ll need to install this package separately, e.g. from NPM. The use of this package is similar to how the web application entry point needs to be imported from @desk-framework/frame-web. For more information, refer to the platform-specific tutorials documentation.

// top of a test-related file:
import { assert, describe, test } from "@desk-framework/frame-test";

describe("My first test scope", () => {
  // ...
});

Describing test scopes

A test scope is simply a group of test cases that are either run one after another or in parallel (async). Before you can run any test cases, they need to be described as part of a scope, using a callback that’s passed to the describe() function.

The specified callback is run immediately, provided with a single parameter that represents the scope itself. This object is of type TestScope and can be used for some advanced functionality (see below).

  • class TestScopeA class that represents a test scope, containing a number of test cases.

Each test file (usually with a .test.js or .test.ts extension) typically describes at least one test scope, making it easier to trace errors back to the source file.

Describing tests — Within the scope callback, you can use the test() function to add tests to the enclosing test scope. Before we go into detail, let’s look at a simple example: the following code creates a test scope with multiple tests.

describe("Example test scope", (scope) => {
  test("First test", () => {
    // test code goes here...
  });

  test("Second test", () => {
    // another test goes here...
  });
});

The code within each test callback is not run immediately — tests are only described and added to the scope, until they’re started (see Running tests below). In the example above, the first test is completed first, followed by the second one.

Asynchronous tests and nested scopes — While the callback passed to describe() must run synchronously, each test can be asynchronous. Normally, the scope waits for each test to be completed (using await) before running the next one. However, using describe.parallel() you can create a scope that runs all test in one go, and then waits for all of them to be completed before finishing the scope.

To combine multiple scopes, whether parallel or not, you can call describe() or describe.parallel() from within each callback to create a nested scope.

describe("Example test scope", (scope) => {
  test("First test", async () => {
    // test code goes here...
  });

  describe.parallel("Nested scope", (nestedScope) => {
    test("Second test", async () => {
      // this test is run after first, parallel with third
    });

    test("Third test", async () => {
      // ...
    });
  });
});

Note that tests within multiple scopes are run in the order they’re described, but always after all tests in the parent scope.

Note: Parallel tests are still run within the same process, so any change to global state (e.g. a global variable, or UI rendering) can affect other tests. Use parallel tests only for asynchronous unit tests that have no side effects, or are otherwise completely isolated from each other.

Skipped and exclusive scopes — You can skip all tests in a scope temporarily, without removing the actual test code, using the describe.skip() function. Conversely, you can run only the tests in a scope, using describe.only().

describe.skip("Example test scope", (scope) => {
  // ... all tests and nested scopes here are skipped temporarily
});

// or:
describe.only("Example test scope", (scope) => {
  // ... ONLY these tests are run, ignoring all other scopes temporarily
});

Specifying a test timeout — By default, tests are expected to complete within a certain time limit, and are considered failed if they don’t. You can change this limit using the TestScope.setTimeout() function.

describe("Example test scope", (scope) => {
  scope.setTimeout(5000); // 5 seconds

  test("Some test", async () => {
    // ... this test fails if it takes longer than 5 seconds
  });
});

Running code before and after tests — You can run code before and after each test, or before and after the entire scope, using the TestScope.beforeEach(), TestScope.afterEach(), TestScope.beforeAll(), and TestScope.afterAll() methods.

  • beforeEach(f)Adds a callback that’s called before each test in this scope, and nested scopes.
  • afterEach(f)Adds a callback that’s called after each test in this scope, and nested scopes.
  • beforeAll(f)Sets a callback that’s called before the first test in this scope.
  • afterAll(f)Sets a callback that’s called after the last test in this scope.
describe("Example test scope", (scope) => {
  scope.beforeEach(async (test) => {
    // ... this code runs before each test
  });

  // ...
});

Note: The beforeAll and afterAll callbacks are considered part of each test case. Any errors thrown from these callbacks are caught and reported as part of the test results. This makes these callbacks useful for validating the global state repeatedly before and after each test.

Adding test cases

Within a test scope, you can add individual test cases using the test() function.

When a test case is run, all errors are caught and reported as part of the test results. The test case is considered successful if no errors are thrown, and failed otherwise. Within a test case, you typically use assertions to validate the expected behavior of your application code, and throw a descriptive error if the test fails.

The specified callback is invoked with a single parameter that represents the test case itself. This object is of type TestCase and can be used for some advanced functionality (see below). Test callbacks can be asynchronous; any promises returned from the callback are awaited before the test is considered completed.

Skipped and exclusive tests — You can skip a test temporarily, without removing the actual test code, using the test.skip() function. Conversely, you can run only the test, using test.only().

Tip: When a test fails, it’s good practice to run the test in isolation to ensure that the failure is not caused by other tests in the same scope. You can use test.only to do this temporarily.

describe("Example test scope", (scope) => {
  test.skip("Some test", async (test) => {
    // ... this test is skipped temporarily
  });

  // or:
  test.only("Some test", async (test) => {
    // ... ONLY this test is run, ignoring all others temporarily
    // (even those in other scopes)
  });
});

Marking a test as to-do — You can mark a test as to-do, using the test.todo() function. This is useful when you want to describe a test case that you haven’t implemented yet, but don’t want to forget about it. The test case isn’t run, but is reported as a to-do item in the test results.

describe("Example test scope", (scope) => {
  test.todo("Some test", async (test) => {
    // ... this code is not run, test is reported as to-do
  });

  // you can also leave out the callback:
  test.todo("Test error conditions");
  test.todo("Validate output");
});

Using counters — You can use the TestCase.count() method during a test to count the number of times a certain condition is met. The counter with a specific name is incremented each time the method is called. At any time, you can validate the counter’s value using the TestCase.expectCount() method, with returns an assertion.

describe("Example test scope", (scope) => {
  test("Some test", async (test) => {
    for (let i = 0; i < 5; i++) {
      test.count("loop");
    }

    // ... later in the test
    test.expectCount("loop").toBe(5);
  });
});

Other test case methods — You can use the following methods for more advanced functionality:

In addition, the test case object provides several methods for validating rendered content and navigation state, using the test context. For more information, refer to the following article:

  • Test contextUnderstand how the Desk test library simulates an interactive runtime environment for testing, including UI interaction and navigation.

Running tests

To run all tests in your application, you’ll need to create a file that serves as an entry point. From this file, import all files that describe your tests, and then call the runTestsAsync() function.

// run-test.ts
import "./some/test.js";
import "./another/test.js";

import { runTestsAsync, formatTestResults } from "@desk-framework/frame-test";
let results = await runTestsAsync();
console.log(formatTestResults(results));
if (results.failed) process.exit(1);

The runTestsAsync() function returns a promise that resolves to a TestResultsData object, which contains the results of all tests that were run.

After running all tests, you can output the results to a file (e.g. a JSON file). Additionally, you can use the formatTestResults() function to format the results as a string, which can be printed to the console or used in other ways.

At any point in time, you can also retrieve partial test results synchronously using the getTestResults() function. Before completion, the results may include tests that are still running, as indicated by the TestResult.state property.

Further reading

Learn more about assertions in the following article:

  • AssertionsLearn how to create and use assertions using the expect() function.

Learn how to go beyond basic tests with simulated rendering and navigation, and query the resulting state, in the following article:

  • Test contextUnderstand how the Desk test library simulates an interactive runtime environment for testing, including UI interaction and navigation.