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
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.
test.verify : '{g, Exception, Each, Random, Label} ()
->{g} [test.Result]
This function takes a block of code that generates test cases and checks that the property holds for all of them. For example:
test.verify do
Each.repeat 100
n = Random.nat()
test.ensureEqual (Nat.fromText (Nat.toText n)) (Some n)
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:
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 returnstrue
.- { 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:
test.verify do
(n, c) = each (List.zip (Nat.range 10 16) (toCharList "ABCDEF"))
test.ensureEqual (Nat.toTextBase 16 n) (Some (Char.toText c))
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
, andminInt
first, then generating random integers.arbitrary.nats
- generate a specific number of random natural numbers, checking corner cases like 0, 1, andmaxNat
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
, andNaN
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