Skip to main content

​Naming Conventions

info

This section currently only covers client tests written in Typescript. It is not intended to be used for server tests written in C# in its current form, even if the underlying philosophy might still be applicable.

The main goal of the name is to describe the test in a way that makes it easy to understand what the test does and which cases it is intending to cover. A good list of test names gives developers the ability to quickly review that the suite covers all important use cases, and to easily identify gaps when it does not.

There are many ways of describing a test but what most methods have in common is that they include the following:

  • Unit under test
  • State under test
  • Expected behavior

We've therefore chosen to use the following format ([] means optional)

<unit> [given <prerequisite>] <behavior> when <state>

Basic example

Example 1

  • Unit: FormSelectionList.deselectItem
  • State: called with a valid id
  • Behavior: creates new copies of the selectedItems and deselectedItems arrays

Example 2

  • Unit: FormSelectionList.deselectItem
  • State: called with an invalid id
  • Behavior: does nothing

In practice

As you have probably noticed our testing framework jest encourages writing tests that start with it, we've therefore chosen a convention that follows this pattern. Here is how the examples above would look like when written using this convention:

FormSelectionList
deselectItem
✓ creates new copies of the selectedItems and deselectedItems arrays when called with a valid id
✓ does nothing when called with a invalid id

In code this would look something like this:

describe("FormSelectionList", () => {
describe("deselectItem", () => {
it("creates new copies of the selectedItems and deselectedItems arrays when called with a valid id", () => {...});
it("does nothing when called with an invalid id", () => {...});
})
})

State arrangement and complex states under test

Sometimes our tests require a lot of code to setup the state, and commonly parts of that code is not relevant for understanding the tests themselves, e.g. complex constructors for global objects.

Duplicated code and utility functions

Most times it is enough to create test utility functions that get rid of the duplicated code and irrelevant details. Take for example the createCipher function in the snippet below:

describe("VaultFilter", () => {
describe("filterFunction", () => {

it("returns true when cipher is deleted and function is filtering for trash", () => {
const cipher = createCipher({ deletedDate: new Date() });
const filterFunction = createFilterFunction({ status: "trash" });

const result = filterFunction(cipher);

expect(result).toBe(true);
});

it("returns false when cipher is deleted and function is filtering for favorites", () => {
const cipher = createCipher({ deletedDate: new Date() });
const filterFunction = createFilterFunction({ status: "favorites" });

const result = filterFunction(cipher);

expect(result).toBe(false);
});

})
})

function createCipher(options: Partial<CipherView> = {}) {
const cipher = new CipherView();

cipher.favorite = options.favorite ?? false;
cipher.deletedDate = options.deletedDate;
cipher.type = options.type;
cipher.folderId = options.folderId;
cipher.collectionIds = options.collectionIds;
cipher.organizationId = options.organizationId;

return cipher;
}

function createFilterFunction(...) {...}

As you can see the createCipher utility function hides a lot of code that would otherwise be duplicated in both tests.

Notice also how the function allows us hide details that are irrelevant for the behavior we are trying to verify. The tests make it obvious that the deletedDate field is the only field that is expected to have any effect on the behavior of the unit under test.

Shared state and common setup blocks

In certain cases, parts of the state is the same across multiple tests because they all share a set of common prerequisites. In those cases we can use a describe and beforeEach block to group tests together and to setup the common parts of the state that they share. As you may have noticed the previous snippet is a good example of this, where both tests require a deleted cipher. We can group these tests together like this (notice the given keyword):

describe("VaultFilter", () => {
describe("filterFunction", () => {

describe("given a deleted cipher", () => {
let cipher;

beforeEach(() => {
cipher = createCipher({ deletedDate: new Date() });
})

it("returns true when filtering for trash", () => {
const filterFunction = createFilterFunction({ status: "trash" });

...
});

it("returns false when filtering for favorites", () => {
const filterFunction = createFilterFunction({ status: "favorites" });

...
});
});

})
})

function createCipher(...) {...}
function createFilterFunction(...) {...}

The result would be test names that look like this:

VaultFilter
filterFunction
given a deleted cipher
✓ returns true when filtering for trash
✓ returns false when filtering for favorites

Pitfalls

Verify one behavior

If you find yourself writing long names by stringing together multiple expectations by using the word and, consider breaking up the test. A test should ideally verify a single behavior to make it easier for developers to identify issues and for reviewers to understand why tests have been modified.

Example

  • adds item to selectedItems, removes from deselectedItems, and creates a form control when called with a valid id

Could instead be written as three different tests

  • adds item to selectedItems when called with a valid id
  • removes item from deselectedItems when called with a valid id
  • creates a form control when called with a valid id

Keep in mind that it is ultimately up to the developers to decide what counts as “verifying a single behavior” on a case by case basis.

Include state under test

A common pattern that's easy to fall into is writing test names without taking state under test into account. Take the following examples for an imaginary sorting function:

Example without state

  • returns items in alphabetic order
  • returns empty array
  • throws error

It is not immediately obvious how many behaviors we are actually testing, instead we are grouping our tests based on the expected output. To understand how the sorting function works and if we have sufficient coverage we would have to dive into the source code for the test. You might ask questions such as:

  • What happens if I input an array with non-string values? Does it throw an error?
  • What could make the function return an empty array?
  • What if I input null?

If we instead add state into the mix we become forced to split up the tests and properly answer the questions above:

Example when taking state into consideration

  • returns empty array when input is empty
  • returns empty array when input contains non-string values
  • returns item when input only contains one item
  • returns items in alphabetic order when input contains multiple items
  • throws error when input is not an array