Testing your Unison code

We write tests in Unison as special watch expressions in scratch files. They're added to the codebase using the add or update commands, and re-run using the test namespace.to.test command in the UCM.

What's special about Unison tests?

Unison caches test results unless the functions in a particular test's dependency graph receive a new hash! When you issue the test command any tests that have been run before will simply report the cached results. For this reason, regular unit tests can't access any abilities that would cause the test to give different results each time it's run, like IO.

Basic unit test workflow

Here's a simple test in a scratch file:

square : Nat -> Nat
square x = x * x

test> square.tests.ex1 = check (square 4 == 16)

The test> line is called a "test watch expression." Like other watch expressions it will get evaluated (or looked up in the evaluation cache) on every file save. You'll see the results for simple tests in the console:

⍟ These new definitions are ok to `add`:

 square.tests.ex1 : [Test.Result]

1 | test> square.tests.ex1 = check (square 4 == 16)

✅ Passed
🤓

The check function turns a Boolean expression into a list of test.Result. A test can be Ok or Result.Fail.

Any test watch expression must have the type [Result].

Tests are regular Unison terms, so the name following the watch expression square.tests.ex1 is what the test will be saved as in the UCM. By convention, tests for a definition called square are placed in the square.tests namespace.

To run only the tests within the square.tests namespace, you can use the test command with a namespace argument:

scratch/main> test square.tests

Cached test results (`help testcache` to learn more)

 1. square.tests.ex1   ◉ Passed

 ✅ 1 test(s) passing

Richer test-runner functionality

You can also write tests that run multiple times with different inputs, or tests that fail if an Exception is thrown, or tests with scoped labels. All of these are handled by the test.verify function.

This function takes a block of code that generates test cases and checks that the property holds for all of them. For example:

This will generate 100 test cases, each consisting of a new random number, and check that the number is equal to itself when converted to Text and back. If the property fails for any of the test cases, the test will return Result.Fail containing the failing test case.

The test passed to test.verify can use various abilities:

  • Random for generating random test cases.
  • Each for generating a range of test cases or repeating a test a certain number of times.
  • Exception for failing a test.
  • Label for adding scoped labels to tests that will be displayed in the results of failing tests.

Writing test expectations

test.verify will return a Result.Fail if the computation passed to it raises an Exception.

You can use test.raiseFailure to fail a test explicitly:

test.verify do
  b = Random.boolean()
  when b do test.raiseFailure "This test sometimes fails" b

The first argument to test.raiseFailure is a message that will be displayed in the test results, and the second argument is a payload that will be displayed in the test results if the test fails.

There are also functions for checking properties that give more detailed information about the failure:

  • ensure - check that a value is true.
  • ensureWith - check that a value is true, with a custom payload for the failure.
  • test.ensureEqual - check that two values are equal.
  • ensureNotEqual - check that two values are not equal.
  • ensureLess - check that the first value is less than the second.
  • ensureLessOrEqual - check that the first value is at most the second.
  • ensureGreater - check that the first value is greater than the second.
  • ensureGreaterOrEqual - check that the first value is at least the second.
  • ensuring - check that a given computation returns true.
  • { ensuringWith} - check that a given computation returns true, with a custom payload for the failure case.

For example, here is a test that the Base library uses for the Text.head function:

head.tests : [test.Result]
head.tests = test.verify do
  use Text head
  use test ensureEqual
  ensureEqual Optional.None (head "")
  ensureEqual (Some ?a) (head "a")
  ensureEqual (Some ?a) (head "a🅱️c")
  ensureEqual (Some ?😈) (head "😈⚠️🙈")

Generating test cases

Use a combination of the Random and Each abilities to generate test cases for use by test.verify.

Use Each.repeat to repeat a test a certain number of times. This is particularly useful for tests that generate random test cases:

test.verify do
  use Random natIn
  use Text ++ reverse
  Each.repeat 100
  size1 = natIn 0 100
  size2 = natIn 0 100
  text1 = ofChars unicode size1
  text2 = ofChars unicode size2
  test.ensureEqual
    (reverse text1 ++ reverse text2) (reverse (text2 ++ text1))

The above test generates 100 random test cases, each consisting of two random Text values, and checks that reversing the concatenation of the two values is equal to the concatenation of the reversed values. It's using test.gen.natIn for the size of the Text values, and ofChars to generate the Text values themselves.

We can also use Each to generate test cases that cover a range or list of values instead of random values. For example, here's a test that checks that converting a number to base 16 gives the expected result:

See Random for more information on generating random values and Each for more on repetition and ranges.

A few convenience functions are provided for common test case generation patterns:

  • arbitrary.ints - generate a specific number of random integers, checking corner cases like 0, 1, -1, maxInt, and minInt first, then generating random integers.
  • arbitrary.nats - generate a specific number of random natural numbers, checking corner cases like 0, 1, and maxNat first, then generating random natural numbers.
  • arbitrary.floats - generate a specific number of random floating-point numbers, checking corner cases like 0.0, 1.0, -1.0, maxFloat, minFloat, Infinity, and NaN first, then generating random floating-point numbers.
  • unspecialFloats - generate a specific number of random floating-point numbers that are not NaN or infinity. Checks corner cases first, then generates random floating-point numbers.

Scoped labels for test failures

You can use the Label ability to add scoped labels to tests that will be displayed in the results of failing tests. For example, here is an erroneous test that fails where the labels help to identify the problem:

test.verify do
  labeled "Tests for empty Text" do
    use test ensureEqual
    labeled "head" do ensureEqual (Text.head "") Optional.None
    labeled "isEmpty" do ensure (Text.isEmpty "")
    labeled "size" do ensureEqual (Text.size "") 1

This test fails with:

🚫 FAILED
Tests for empty Text:
  size:
    elements not equal
    (0, 1)

Within a test you can use label to add a label to the test. The labels will be displayed in the results of failing tests. For example:

test.verify do
  labeled "check multiplication" do
    use Nat *
    use arbitrary nats
    x = nats 100
    y = nats 100
    label "x * y should be greater than x" (x, y)
    ensureGreater (x * y) x

Due to a false assumption in the test, it fails with:

🚫 FAILED
check multiplication:
  x * y should be greater than x: (0, 0)
  0 is not greater than 0