At Jane Street, an “expect test” is a test where you don’t manually write the output you’d like to check your code against – instead, this output is captured automatically and inserted by a tool into the testing code itself. If further runs produce different output, the test fails, and you’re presented with the diff.
For example, we might write:
let%expect_test "foo" = printf "Mean: %F" (mean 1. 4.)
When compiled we will get a corrected file with the following output:
let%expect_test "foo" =
printf "Mean: %F" (mean 1. 4.)
[%expect {| 2.5 |}]
Since this is correct, we accept the output and this becomes the
contents of our test file. Subsequent runs of the test produce the
same output, so there are no further diffs. If, however, we change the
definition of mean
to, say, the geometric mean, our tool generates a
build error and a diff:
let%expect_test "foo" =
printf "Mean: %F" (mean 1. 4.)
-| [%expect {| 2.5 |}]
+| [%expect {| 2.0 |}]
The most interesting aspect of this style is the workflow: we write the initial code, our build system computes the correct output, and our editor integration means it takes just a couple of keypresses to accept the result if we’re happy with it. Then we check it into our source code repository, at which point continuous integration systems automatically ensure our tests continue to work in the presence of every developer’s bugbear – other developers.
Expecting things from hardware
When we develop a hardware design we always do so in the presense of a testbench. A testbench provides stimulus for the design, monitors the computed outputs and ensures that they are correct.
At Jane Street, developing and testing hardware designs is performed using an Embedded Domain Specific Language called Hardcaml, which unsurprisingly is written in OCaml.
One of the key features of Hardcaml is that it provides a cycle-accurate simulator. This allows us to develop both the hardware design and testbench in OCaml.
The following is a simple 8-bit counter. It resets back to 0 when the
clear
signal is high, and counts up when the incr
signal is high.
Otherwise it holds its previous value.
open Hardcaml
open Hardcaml.Signal
open Hardcaml_waveterm
module I = struct
type 'a t =
{ clock : 'a
; clear : 'a
; incr : 'a
}
[@@deriving sexp_of, hardcaml]
end
module O = struct
type 'a t =
{ dout : 'a[@bits 8]
}
[@@deriving sexp_of, hardcaml]
end
let create (i : _ I.t) =
{ O.dout =
reg_fb
(Reg_spec.create ~clock:i.clock ~clear:i.clear ())
~enable:i.incr
~w:8
(fun d -> d +:. 1)
}
;;
val create : t I.t -> t O.t = <fun>
The following is a simple testbench for the counter which shows its
behaviour for different values of clear
and incr
.
module Simulator = Cyclesim.With_interface(I)(O)
let testbench () =
let sim = Simulator.create create in
let inputs = Cyclesim.inputs sim in
let outputs = Cyclesim.outputs sim in
let step ~clear ~incr =
inputs.clear := if clear=1 then Bits.vdd else Bits.gnd;
inputs.incr := if incr=1 then Bits.vdd else Bits.gnd;
Printf.printf "clear=%i incr=%i dout=%i\n"
clear incr (Bits.to_int !(outputs.dout));
Cyclesim.cycle sim
in
step ~clear:0 ~incr:0;
step ~clear:0 ~incr:1;
step ~clear:0 ~incr:1;
step ~clear:1 ~incr:0;
step ~clear:0 ~incr:0;
step ~clear:0 ~incr:0
;;
val testbench : unit -> unit = <fun>
testbench ();;
clear=0 incr=0 dout=0
clear=0 incr=1 dout=0
clear=0 incr=1 dout=1
clear=1 incr=0 dout=2
clear=0 incr=0 dout=0
clear=0 incr=0 dout=0
- : unit = ()
We can now capture this behaviour as an expect test.
let%expect_test "counter" =
testbench ();
[%expect {|
clear=0 incr=0 dout=0
clear=0 incr=1 dout=0
clear=0 incr=1 dout=1
clear=1 incr=0 dout=2
clear=0 incr=0 dout=0
clear=0 incr=0 dout=0
|}]
;;
Waveform expect tests
Digital waveforms are commonly used during hardware development to capture the time-varying behaviour of signals relative to one another. Using the Hardcaml_waveterm library we can print waveforms from Hardcaml simulations.
let testbench () =
let sim = Simulator.create create in
let waves, sim = Waveform.create sim in
let inputs = Cyclesim.inputs sim in
let step ~clear ~incr =
inputs.clear := if clear=1 then Bits.vdd else Bits.gnd;
inputs.incr := if incr=1 then Bits.vdd else Bits.gnd;
Cyclesim.cycle sim
in
step ~clear:0 ~incr:0;
step ~clear:0 ~incr:1;
step ~clear:0 ~incr:1;
step ~clear:1 ~incr:0;
step ~clear:0 ~incr:0;
step ~clear:0 ~incr:0;
waves
;;
val testbench : unit -> Waveform.t = <fun>
let waves = testbench ();;
val waves : Waveform.t = <abstr>
Waveform.print ~display_height:12 waves;;
┌Signals────────┐┌Waves──────────────────────────────────────────────┐
│clock ││┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌──│
│ ││ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │
│clear ││ ┌───────┐ │
│ ││────────────────────────┘ └─────────────── │
│incr ││ ┌───────────────┐ │
│ ││────────┘ └─────────────────────── │
│ ││────────────────┬───────┬───────┬─────────────── │
│dout ││ 00 │01 │02 │00 │
│ ││────────────────┴───────┴───────┴─────────────── │
│ ││ │
└───────────────┘└───────────────────────────────────────────────────┘
- : unit = ()
Since they are just text, these can also be captured with expect tests:
let%expect_test "counter" =
let waves = testbench () in
Waveform.print ~display_height:12 waves;
[%expect {|
┌Signals────────┐┌Waves──────────────────────────────────────────────┐
│clock ││┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌──│
│ ││ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │
│clear ││ ┌───────┐ │
│ ││────────────────────────┘ └─────────────── │
│incr ││ ┌───────────────┐ │
│ ││────────┘ └─────────────────────── │
│ ││────────────────┬───────┬───────┬─────────────── │
│dout ││ 00 │01 │02 │00 │
│ ││────────────────┴───────┴───────┴─────────────── │
│ ││ │
└───────────────┘└───────────────────────────────────────────────────┘
|}]
;;
Expect test workflow
Using expect tests in this way makes the hardware development process a lot more similar to developing software. It allows us to leverage years’ worth of tool development and integrate with the Iron workflow that is central to development at Jane Street.
It is also simply a lot nicer to write testbenches in OCaml than in traditional RTL languages.
There are without doubt some drawbacks – embedding a waveform in source code means you can’t reasonably display that much information. This means you have to think carefully about what information to expose (some might argue this is an advantage). Also, while we do have an interactive viewer application for waveforms, it can be a bit of work to factor code so that it can work both in the interactive viewer and expect tests.
Finally, we have yet to teach our diff tools about waveforms – the current diffs are not terrible, but they could be better.
Even with these issues, using expect tests while developing hardware has become very common within our team. This gives us a good incentive to spend some time improving our tooling.
One final note: while we do all of this using our internal tooling, all of the underlying code is available on Github as open source: expect tests and hardcaml. And while the external tooling isn’t quite as good as the internal tools (yet!), Dune provides pretty good support for working with expect tests.