#Testing
Status: standard library specification draft.
This chapter defines the part of std.testing that lib/std and the test
runner implement over the language tier:
- how a property test draws, discards, reports, and replays its inputs;
- the draw limit;
- how
@derive(Arbitrary)builds a type's default generator; - what the
timeoutoption does; - how an
it_eachcall expands and names its rows; - how snapshots compare text, and where snapshot files live;
- how an integration test runs one of the package's executables or tasks;
- the temporary directory of a test case;
- which std fakes stand in for host providers in a unit test;
- the host capabilities through which
std.testingreaches the test runner.
The language tier keeps the assertion functions and the literal expect of
snapshot. It also keeps it, it_each, it_prop, and it_prop_with,
the functions a test registration call may call. Their options, position rules, and
diagnostics stay in the language tier too
(Standard Testing,
Test Cases,
Registration Calls,
Snapshots).
#Registration Calls
The language tier declares the functions of the test registration calls
it_each, it_prop, and it_prop_with, and checks their names, options,
and positions (Registration Calls).
This chapter defines what their test cases do when they run.
Note. The
timeoutparameter of each has the type thatit's has,std.time.Duration?(Test Timeout).
See also: Table-Test Rows, for how an it_each call
expands and names its rows. Property Tests,
Draw Limit, and Derived Arbitrary
show how the runner generates a property's inputs.
#Property Tests
A property test draws its inputs from a Choices source, the only
randomness a generator sees:
trait Arbitrary:
fn arbitrary(c: &mut Choices) -> Self| Rule | Member of Choices |
Draws |
|---|---|---|
| std-testing.choices.int-rangeInteger | fn int[N < Integer](&mut self, range: Range[N]) -> N |
an integer of range |
| std-testing.choices.float-rangeFloat | fn float[F < Float](&mut self, range: Range[F]) -> F |
a finite float of range |
| std-testing.choices.boolBoolean | fn bool(&mut self) -> bool |
a bool |
| std-testing.choices.chooseChoose | fn choose[T](&mut self, items: List[T]) -> T |
one of items; earlier items shrink first |
| std-testing.choices.list-max-refList | fn list[T](&mut self, max: usize, item: fn(&mut Choices) -> T) -> List[T] |
at most max items, each drawn by item |
| std-testing.choices.map-max-refMap | fn map[K < Eq & Hash, V](&mut self, max: usize, key: fn(&mut Choices) -> K, value: fn(&mut Choices) -> V) -> Map[K, V] |
at most max entries, each key drawn by key and its value by value |
| std-testing.choices.string-max-charsString | fn string(&mut self, max_chars: usize) -> string |
a string of at most max_chars chars |
| std-testing.choices.assumeAssume | fn assume(&mut self, ok: bool) -> void |
nothing; a false ok discards the case |
| std-testing.choices.drawDraw | fn draw[T < Arbitrary](&mut self) -> T |
T::arbitrary(self), the type's default |
- std-testing.choices.declare-ref
std.testingdeclaresChoicesandArbitrary. Neither is a prelude name.Choicesis a struct that a generator takes behind&mut, as&mut Choices, and each member above takes&mut self, so every draw advances the one source. - std-testing.choices.from-case
std.testingcreates theChoicesof each generated case from thePropertyCasethat the runner returns. It reports each draw to the runner, so the runner can replay and shrink a case. - std-testing.choices.no-size
Choiceshas no size: no member reads or sets one, and no option ofit_proporit_prop_withsets one. - std-testing.choices.string-limitThe limit of
stringcountscharvalues, not bytes. - std-testing.choices.map.duplicateWhen
keydraws a key that the map already holds, the later value replaces the earlier one. So the map may hold fewer entries than were drawn. - std-testing.choices.choose.empty
chooseon an empty list panics. Panic:explicit-panic. - std-testing.choices.int-range.emptyAn
intrange that holds at most one value, such as5..5,5..=5, or6..=5, returns itsstartand records no draw. - std-testing.choices.int-range.convert
intworks through theIntegerconversions, so any integer type is a validN, and the range may span the whole type,0..=255foru8or0..=18446744073709551615foru64. It never converts a value to text. - std-testing.choices.float-range.endA
floatrange whoseinclusiveisfalsenever returns itsend, and one whoseinclusiveistruemay. A range whosestartis not below itsendreturnsstartand records no draw. - std-testing.arbitrary
Arbitrarygives a type its default generator, whichit_propandChoices.drawuse. - std-testing.arbitrary.std
stdimplementsArbitraryfor the primitives,string,List[T],Map[K, V],T?,Result[T, E], and tuples, each when its type arguments implement it. - std-testing.arbitrary.reference
stdalso implementsArbitraryfor&Tand&mut TwhenTimplements it. Each draws aTand refers to a new object that holds it. - std-testing.arbitrary.floatThe
Arbitraryimplementations off32andf64draw any value of the type, including NaN, both infinities,-0.0, and subnormal values, as Hypothesis'sfloats()does. - std-testing.prop.generate-and-shrinkThe runner generates inputs for
it_propandit_prop_with, shrinking a failure only after observing it in a fresh instance. - std-testing.prop.failure-inputWhen a property test fails, the runner prints its failing input with
Debug. - std-testing.prop.examplesEach input in
examplesruns first on every run, before the saved regression streams and the generated cases. - std-testing.prop.discardA case that
assumediscards does not count towardcases. The runner generates another case in its place. - std-testing.prop.body-no-discardOnly a generator discards a case, through
Choices.assume. A property body has noChoices, so it cannot discard one. - std-testing.prop.discard-limitA property test fails when more than 10 times
casesof its cases are discarded, as Hypothesis'sfilter_too_muchhealth check does. - std-testing.prop.regression-fileThe runner saves a failing property's shrunk choice stream in
<package root>/__regressions__/<module>/<test-slug>.<module>and<test-slug>are as for a snapshot file. - std-testing.prop.regression-formatThe file holds the stream's draws in order, one decimal number per line.
- std-testing.prop.regression-replayOn the next run, the runner replays a property's saved stream before it generates new cases.
- std-testing.prop.shared-instanceWithin a property test, each case reuses the preceding case's instance when that case completed successfully.
- std-testing.prop.shrink-freshEach shrink attempt runs in a fresh program instance.
- std-testing.prop.state-dependent-failureA failure absent from fresh-instance replay fails with its input, seed, and explanation of earlier cases' state, without shrinking or saving a regression.
use std.testing.{Arbitrary, Choices}
struct Point:
x: i64
y: i64
impl Arbitrary for Point:
fn arbitrary(c: &mut Choices) -> Point:
Point { x: c.int(0..=9), y: c.int(-5..=5) }
fn small_counts(c: &mut Choices) -> List[i64]:
c.list(3, fn(inner: &mut Choices) -> i64: inner.int(0..=10))
fn ratio(c: &mut Choices) -> f64:
c.float(0.0..=1.0)
fn label(c: &mut Choices) -> string:
c.string(max_chars=8)
fn stock(c: &mut Choices) -> Map[string, i32]:
c.map(5, key=label, value=fn(inner: &mut Choices) -> i32: inner.int(0..=99))use std.testing.{Arbitrary, Choices, it_prop}
struct Reading:
level: i64
impl Arbitrary for Reading:
fn arbitrary(c: &mut Choices) -> Reading:
Reading { level: c.int(0..=9) }
tests:
it_prop("levels stay small", prop=fn!(reading: Reading): # error: unsatisfied-trait-bound
pass
)A property's input type must implement Debug, by
module.testing.reg.prop-debug,
so the example above is an error.
Why. A generator draws the parts of its value in order, so a later draw may depend on an earlier one. The runner shrinks the recorded draws, not the value, so it needs no size.
#Draw Limit
- std-testing.limitEach case has a draw limit. Once the case's draws have spent it, every draw returns its simplest value.
- std-testing.limit.no-apiNo member of
Choicesreads or changes the limit. - std-testing.limit.every-drawThe limit applies to every draw from the case's
Choices, including a hand-written generator's. So a recursive generator whose simplest draws choose a leaf ends.
| Rule | Draw | Simplest value |
|---|---|---|
| std-testing.limit.simplest.intInteger | int and the default integer generators |
0, or the bound nearest 0 when 0 is out of range |
| std-testing.limit.simplest.floatFloat | float and the default f32 and f64 generators |
0.0, or the bound nearest 0.0 when 0.0 is out of range |
| std-testing.limit.simplest.boolBoolean | bool |
false |
| std-testing.limit.simplest.chooseChoose | choose |
the first item |
| std-testing.limit.simplest.emptyCollections | list, map, and string |
an empty list, map, or string |
| std-testing.limit.simplest.optionalOptional | the default T? generator |
.None |
| std-testing.limit.simplest.resultResult | the default Result[T, E] generator |
.Ok of T's simplest value |
| std-testing.limit.simplest.tupleTuple | the default tuple generators | each element's simplest value |
use std.testing.Choices
enum Tree:
Leaf
Node(left: *Tree, right: *Tree)
fn tree(c: &mut Choices) -> Tree:
match c.int(0..=2):
0 => .Leaf
_ => .Node(&tree(c), &tree(c))Once the limit is spent, c.int(0..=2) returns 0, so tree returns
.Leaf.
#Edge Values
The first generated cases of a property run draw edge values. So a
property meets 0, the bounds of a range, the smallest and largest value
of an integer type, and an empty collection early. Each edge case takes
one edge, which its seed picks:
| Rule | Edge | Choices.int(range) returns |
A default integer generator returns | list, map, and string return |
|---|---|---|---|---|
| std-testing.edge.lowSeed modulo 4 is 0: low | the range's first value | the smallest value of the type | an empty collection | |
| std-testing.edge.highSeed modulo 4 is 1: high | the range's last value | the largest value of the type | any collection | |
| std-testing.edge.zeroSeed modulo 4 is 2: zero | 0 when the range holds 0, else any value |
0 |
any collection | |
| std-testing.edge.noneSeed modulo 4 is 3: none | any value | any value | any collection |
- std-testing.edge.casesA generated case is an edge case when its
sizeis below 4. Every other case takes no edge. - std-testing.edge.planAn edge case takes the edge of its seed modulo 4 in the table above, for each fresh draw it makes.
- std-testing.edge.replayAn edge changes fresh draws only. A replayed draw returns its recorded value, by
std-testing.runner.replay. - std-testing.edge.coverageWhen a run discards no case, its first four generated cases take the low, high, zero, and no edge once each. This holds because their seeds are consecutive.
- std-testing.edge.int-typeA default generator of an integer type returns every value from the type's smallest to its largest. So the low and high edges reach the type's own bounds. For
i32,i64,u32,u64, andusizethey may reach them outside an edge case as well. - std-testing.edge.limitA spent draw limit still returns the simplest value, by
std-testing.limit.simplest.int, whatever the edge.
use std.testing.{assert, Choices, it_prop_with}
fn span(c: &mut Choices) -> i64:
c.int(-10..=10)
tests:
# the first generated cases draw the edges: -10, 10, and 0
it_prop_with("the span holds", gen=span, cases=8, prop=fn!(n: i64):
assert(n >= -10 && n <= 10, reason="in range")
)Why. Hypothesis and QuickCheck bias draws toward
0, the bounds, and empty collections, since off-by-one and overflow bugs sit there. A property that never sees the largesti32cannot find an overflow. The seed picks the edge, and seeds are consecutive, so four cases cover the edges without a counter, and a discarded case's retry still varies.
#Derived Arbitrary
@derive(Arbitrary) gives a struct or enum its default generator
through the template of Arbitrary.
- std-testing.arbitrary.derive
@derive(Arbitrary)derivesArbitrarythrough its template. The derivedarbitrarydraws each member with its type'sArbitrary. For an enum, it draws a variant, then that variant's payload. - std-testing.arbitrary.derive.templateThat template is ordinary
std.testingcode overstd.structure: it reads each variant's and member'sself_ref, and the compiler supplies nothing forArbitraryitself. - std-testing.arbitrary.derive.member-boundThe template requires the type of every member to implement
Arbitrary, whether or notarbitrary.withtunes the member. It does not require a member to be inspectable. - std-testing.arbitrary.derive.params-arbitraryFor a generic type, the derived implementation gets
T < Arbitraryfor each type parameterTthat a member's type uses, byannot.bound.params. So@derive(Arbitrary)onstruct Box[T]with a membervalue: Tneeds no hand-written block. - std-testing.arbitrary.derive.not-derivableA type with a member whose type fails that bound, such as a function-typed member, is not derivable.
@derive(Arbitrary)on it is an error, reported at the opt-in and naming the member. Error:member-not-derivable. - std-testing.arbitrary.derive.manualSuch a type gets its default generator only from a hand-written
impl Arbitrary. - std-testing.arbitrary.derive.simplestA derived enum's simplest choice is its first non-recursive variant, whatever the declaration order.
- std-testing.arbitrary.derive.recursiveA variant is recursive when its
self_refis.Required, as Self References computes it from the member types. - std-testing.arbitrary.derive.recursive.containersA
List,Map, or optional member does not make its variant recursive. Itsself_refis at most.Optional: its simplest value is empty or.None. - std-testing.arbitrary.derive.no-finiteWhen every variant of a derived enum is recursive, the derived
arbitrarypanics on the property's first case, with a message that names the type. Panic:explicit-panic. - std-testing.arbitrary.derive.no-finite.messageThe message is
"${T::name()} has no finite value", whereT::name()is the type's declared name. - std-testing.arbitrary.derive.no-finite.dataWhen a member of a derived struct has
self_ref.Required, its derivedarbitrarypanics the same way. The compiler does not reject the type either. Panic:explicit-panic. - std-testing.arbitrary.derive.no-finite.uncheckedThe compiler does not reject such an enum, because no derivation check reports it.
- std-testing.arbitrary.withA member whose facts hold an
arbitrary.with(gen)value is drawn bygeninstead of its type'sArbitrary. - std-testing.arbitrary.with.module-typed-structThe module
std.testing.arbitrarydeclareswithand its result typeWith[F], a struct value and a typed fact type, as shown below. Code imports the module, as inuse std.testing.arbitrary, and writes@arbitrary.with(gen). - std-testing.arbitrary.with.checked
genmust draw the member's declared type. A generator of another type is an error, reported on its decorator, byannot.typed-fact.check. Error:type-mismatch. - std-testing.arbitrary.with.typed-readThe derived
arbitraryreads the member'sWith[F]through its handle, withh.fact, and draws the member by itsgen. - std-testing.arbitrary.with.only
arbitrary.withis the only fact that derivedArbitraryreads.
@annotate::[F](.Field)
pub struct With[F]:
pub gen: fn(&mut Choices) -> F
pub fn with[F](gen: fn(&mut Choices) -> F) -> With[F]Note. The member's declared type is the expected type of
F, byannot.typed-fact.check.inferred. So a generic generator such asfn any_text[T](c: &mut Choices) -> Tneeds no type argument:@arbitrary.with(any_text)onname: stringsolvesT = string.
use std.testing.{Arbitrary, Choices, assert, it_prop}
use std.testing.arbitrary
fn cents(c: &mut Choices) -> i32:
c.int(0..=10_000)
@derive(Arbitrary, Debug)
struct Item:
name: string
@arbitrary.with(cents)
price: i32
@derive(Arbitrary, Debug)
enum Expr:
Add(left: *Expr, right: *Expr)
Num(value: i32)
tests:
it_prop("prices are never negative", examples=[Item { name: "", price: 0 }], prop=fn!(item: Item):
assert(item.price >= 0, reason="cents draws from 0 to 10_000")
)Expr's simplest choice is Num, although Add comes first.
use std.testing.{Arbitrary, Choices}
@derive(Arbitrary) # error: member-not-derivable
struct Task:
run: fn() -> i32
struct Job:
run: fn() -> i32
impl Arbitrary for Job:
fn arbitrary(c: &mut Choices) -> Job:
n := c.int(0..=9)
Job { run: fn() -> i32: n }Task's member run has a function type, which does not implement
Arbitrary, so Task is not derivable. Job writes
its own impl Arbitrary instead.
use std.testing.Arbitrary
@derive(Arbitrary, Debug)
struct Box[T]:
value: T
fn needs[T < Arbitrary](value: T) -> T: value
fn check(item: Box[i32]) -> Box[i32]:
needs(item)
fn invalid(item: Box[fn() -> i32]) -> Box[fn() -> i32]:
needs(item) # error: unsatisfied-trait-boundThe derived implementation is for Box[T] with T < Arbitrary.
Box[i32] meets it; Box[fn() -> i32] does not, since a function type
does not implement Arbitrary.
use std.testing.Arbitrary
@derive(Arbitrary, Debug)
enum Tree:
Node(children: List[Tree])
@derive(Arbitrary, Debug)
enum Loop:
More(next: *Loop)
@derive(Arbitrary, Debug)
struct Ring:
next: *RingTree's one variant is not recursive: its self_ref is .Optional,
because an empty list holds no Tree. Every variant of Loop is
recursive, so its derived arbitrary panics on the first case with the
message Loop has no finite value. So does Ring's, because its member
next is .Required.
Why. One fact that holds a whole generator covers every range, length, and shape, so derived
Arbitraryneeds no range or length facts.With[F]is a typed fact type. So a generator of the wrong type is caught where it is written, and the derived code reads it at the member's type.
Why. A template states one bound for all of a type's members, and no fact can lift it from one member. So every member meets the bound, and a type whose members cannot is written by hand.
Note. A newtype gets no
Structure(trait.derive.newtype.templated). A newtype that derivesArbitrarythrough a base with no finite value panics with the base type's name.
Note. These are runner behavior, not rules of this chapter. One is how often a draw outside an edge case returns a small value. So are any small-first order of cases and the size of the draw budget. So are which chars
stringdraws and how the runner shrinks a failing case.
#Test Timeout
it, it_each, it_prop, and it_prop_with each take a timeout
option of type Duration?
(Test Cases).
| Rule | Option | Value | Effect |
|---|---|---|---|
| std-testing.option.timeout-any-durationTimeout | timeout |
any std.time.Duration value, such as 5s or a call that returns one |
The runner fails the test case when its body runs longer than the duration. |
- std-testing.option.timeout-at-runA
timeoutvalue is an ordinary argument, not a literal. It is evaluated when the test case runs, in its program instance, asit_eachrows are. - std-testing.option.timeout-importA suffix in a
timeoutvalue is imported like any other, as inuse std.time.s;itadds no suffix of its own.
use std.time.{Duration, s}
fn budget() -> Duration: 30s
tests:
it("fetches the index", timeout=5s):
pass
it("loads the archive", timeout=budget()):
pass#Table-Test Rows
A call of it_each in test position is registered as
Registration Calls specifies.
- std-testing.it-eachA top-level call of
std.testing.it_eachregisters one test case for each element ofrows, which runsbodywith that element. - std-testing.it-each.nameThe test case for the element at index
iis namedname[i]. - std-testing.it-each.rows-at-run
rowsis evaluated when the test runs, in its program instance, not when test cases are listed.
fn double(value: i32) -> i32: value * 2
tests:
use std.testing.{assert_equal, it_each}
it_each("doubles", [1, 2, 3], body=fn!(value: i32):
assert_equal(double(value), value + value, reason="doubling adds the value to itself")
)The call runs the test cases doubles[0], doubles[1], and doubles[2].
#Snapshot Files
std.testing declares a second snapshot function, beside snapshot
(Snapshots):
pub fn snapshot_file(text: string) -> void $ TestRunner:
problem := $.use(TestRunner).snapshot_check(text)
assert(problem == "", problem)- std-testing.snapshot
snapshotcomparestextwithexpect, the expected text written in the source. - std-testing.snapshot-file
snapshot_filecomparestextwith a snapshot file, which the test runner names from the running test case. - std-testing.snapshot.importNeither is a prelude name; code imports them from
std.testing. - std-testing.snapshot-file.path
snapshot_filekeeps its file at<package root>/__snapshots__/<module>/<test-slug>-<n>.snap, whose parts the table below defines. - std-testing.snapshot-file.missingWhen that file does not exist, the test case fails, except in an update run, as
hd test --updatemakes, which records the file. - std-testing.snapshot.mismatchWhen
textdiffers from the expected text,snapshotorsnapshot_filefails as a failed assertion does. Panic:assertion-failed. - std-testing.snapshot.updateIn an update run, as
hd test --updatemakes, of anittest case, asnapshotcall whosetextdiffers from the expected text recordstextand the place of the call with the test runner, and returns instead of failing. The runner then rewrites the call'sexpectbycli.test.snapshot.update. - std-testing.snapshot-file.missing-panicA missing snapshot file outside an update run fails the same way. Panic:
assertion-failed. - std-testing.snapshot-file.runner
snapshot_filehas the requirement row$ TestRunner. It callssnapshot_checkon theTestRunnerprovider that covers the call, and fails as above when the result is not empty, showing it.
| Rule | Part | Value |
|---|---|---|
| std-testing.snapshot-file.folderFolder | __snapshots__/ |
one folder at the package root, beside hd.toml; it has no mod.hd, so it is never a module |
| std-testing.snapshot-file.moduleModule | <module> |
the test's module path, such as billing; a module under tests/ is tests.<name> |
| std-testing.snapshot-file.module.rootRoot module | pkg |
the <module> of src/lib.hd, the package root module that pkg names by module.path.lib-file |
| std-testing.snapshot-file.slugSlug | <test-slug> |
the test case name, lowercased, with each run of characters other than ASCII letters and digits turned into - |
| std-testing.snapshot-file.counterCounter | <n> |
the count of snapshot_file calls within one test case run, from 1 |
| std-testing.snapshot-file.rowTable row | <test-slug>.<i> |
the slug of an it_each row adds the row's index |
Why. The test picks the rendering, such as
debug(value). A missing snapshot file fails outside an update run, so a test that was never recorded cannot pass by accident.
See also: Debug Trait.
#Running Executables
hd_run! runs a Command naming one of the package's executables or
tasks from an integration test, and returns its ProcessOutput.
It is plain hd over the host trait
Process:
use std.process.{Command, Process, ProcessOutput}
pub fn hd_run!(command: Command) -> ProcessOutput $ Process:
match $.use(Process).run!(command):
.Ok(output) => output
.Err(.NotFound(_)) => panic("hd_run!: the package has no executable or task named '${command.program}'")
.Err(error) => panic("hd_run!: '${command.program}' did not start: ${error}")-
std-testing.hd-run.command-importCode imports
hd_runfromstd.testing, andCommandandProcessOutputfromstd.process. -
std-testing.hd-run.command-runs
hd_run!(command: Command) -> ProcessOutput $ Processruns the executable named bycommand.program, bycli.exe.table. -
std-testing.hd-run.default-nameThe default executable is named after the package, by
cli.exe.default-name. -
std-testing.hd-run.command-task
command.programmay also name a task of the package, which runs ashd run NAMEruns it. No name names both, bycli.task.name-clash. -
std-testing.hd-run.waitsThe call completes when the program exits.
-
std-testing.hd-run.process-outputIts result is the
ProcessOutputreturned by the coveringProcessprovider, with stdout, stderr, and any exit status unchanged. -
std-testing.hd-run.row
hd_run!has the requirement row$ Process, the host capability to start a process. It callsrun!on theProcessprovider that covers the call. -
std-testing.hd-run.bindingIn an integration test, the test runner binds
Processto the package's executables and tasks, bycli.test.process. A test body's row takesProcessfrom the call, so a test case writes no row for it. -
std-testing.hd-run.unknown-commandWhen
command.programnames neither an executable nor a task of the package,hd_run!panics at run time, whether or notcommand.programis a literal. Its category is that of apaniccall. Panic:explicit-panic. -
std-testing.hd-run.start-failureWhen the program exists but does not start, so
run!returnsPermissionDenied,NotGranted, orOther,hd_run!panics too, with the error's text in its message. Panic:explicit-panic. -
std-testing.hd-run.integration-onlyA call of
hd_run!outside an integration test module is an error. Error:test-only-use. -
std-testing.hd-run.command-fields
hd_run!passes the command unchanged, including its arguments, environment, current directory, and standard input.
The integration test that hd new --app writes for a package hello:
# tests/hello.hd
use std.process.Command
use std.testing.{assert_equal, hd_run}
it("prints a greeting"):
let out = hd_run!(*Command::new("hello"))
assert_equal(out.stdout, "hello, world\n", reason="the greeting")
assert_equal(out.status, 0, reason="a clean exit")# src/greeting.hd
tests:
use std.process.Command
use std.testing.hd_run
it("runs the binary"):
_ := hd_run!(*Command::new("hello")) # error: test-only-useNote.
hd testbuilds the package's executables before it runs any test case (cli.test.builds-executables).
Why. An integration test checks a program as its user sees it, as Cargo's integration tests do through
CARGO_BIN_EXE_<name>. A unit test runs on fakes alone, so it never starts a process. A real row, not a hidden grant, letslib/stdwritehd_run!in plain hd.
#Temporary Directories
std.testing declares a function that returns the running test case's own
temporary directory, as Go's t.TempDir does:
pub fn temp_dir() -> Path $ TestRunner:
Path($.use(TestRunner).temp_dir())- std-testing.temp-dir.decl
std.testingdeclarestemp_dirwith the signature above. It is not a prelude name; code imports it, as inuse std.testing.temp_dir. - std-testing.temp-dir.fresh
temp_dir()returns the absolute path of a fresh, empty directory that belongs to the running test case alone, bycli.test.env.temp-dir. - std-testing.temp-dir.sameEvery call within one run of a test body returns the same path.
- std-testing.temp-dir.per-runEach row of an
it_eachcall and each generated case of a property gets a directory of its own. - std-testing.temp-dir.removedThe runner removes the directory once the test body's run ends, by
cli.test.env.temp-dir.removed.
An integration test that reads a fixture of the package and writes its output into its own directory:
# tests/export.hd
use std.fs.{read_text, write_text}
use std.path.Path
use std.testing.{assert_equal, temp_dir}
it("exports the orders into its own directory"):
orders := read_text!(Path("fixtures/orders.csv")).expect("the orders fixture")
out := Path("${temp_dir()}/export.csv")
write_text!(out, orders).expect("the export")
assert_equal(read_text!(out).expect("the written export"), orders, reason="the export holds every order")Note. A unit test may call
temp_dirtoo, but it reaches no real file. Only an integration test case getsFsReadandFsWritefrom the runner.
Why. A shared output path makes tests that run at once overwrite each other's files. A directory per run of a body, which the runner removes, leaves nothing behind in the package.
#Unit Test Providers
A unit test gets TestRunner alone
(module.testing.unit-row.anywhere).
Each host capability trait has a deterministic std provider that the
test binds with $.with instead:
| Host trait | Provider | Defined in |
|---|---|---|
Console |
MemoryConsole |
Buffer Console |
ConsoleInput |
ScriptedInput |
Scripted Input |
Args, Env |
ScriptedArgs, ScriptedEnv |
Map Providers |
Clock |
ManualClock |
Manual Clock |
Random |
SeededRandom |
Seeded Random |
FsRead, FsWrite |
MemoryFs |
Memory File System |
Process |
ScriptedProcess |
Scripted Process |
Http |
ScriptedHttp |
Scripted Http |
Net |
ScriptedNet |
Scripted Net |
Sys |
ScriptedSys |
Map Sys |
use std.time.{Clock, ManualClock, Timestamp, now}
fn receipt_header(store: string) -> string $ Clock:
"${store} at ${now().to_rfc3339()}"
tests:
use std.testing.assert_equal
it("stamps the receipt with the clock's time"):
$.with(Clock=ManualClock::new(Timestamp::from_unix_milliseconds(0))):
assert_equal(receipt_header("Main St"), "Main St at 1970-01-01T00:00:00Z", reason="a fixed clock")
it("reads the real clock"):
_ := receipt_header("Main St") # error: missing-requirement- std-testing.unit.hintThe
missing-requirementerror for a host trait of this table, inside a unit test, names the trait's provider and$.with. It also suggests moving the test case to the test root.
Why. An agent that meets the error learns both fixes at once. A fake keeps the test a unit test, and the test root makes it an integration test.
#Runner Capabilities
std.testing reaches the test runner through two host capability traits,
and describes each property case with a struct value:
pub trait TestRunner:
fn row(&mut self, count: usize) -> usize
fn report_timeout(&mut self, millis: i64) -> void
fn snapshot_check(&mut self, text: string) -> string
fn temp_dir(&mut self) -> string
pub trait PropertyRunner:
fn start(&mut self, cases: usize, shrink: usize, examples: usize) -> PropertyCase
fn record(&mut self, value: i64) -> void
fn show(&mut self, text: string) -> void
pub struct PropertyCase:
pub example: usize?
pub seed: i64
pub size: i32
pub replay: List[i64]- std-testing.runner.declares-ref.reference-values
std.testingdeclares the host capability traitsTestRunnerandPropertyRunnerwith the methods above. Each method takes&mut self, so$.use(TestRunner)is a&mut dyn TestRunnerbyreq.ref.use.reference-values. Code imports them, as inuse std.testing.TestRunner. - std-testing.runner.case-type-struct
std.testingdeclares the public struct valuePropertyCasewith the fields above. Code imports it, as inuse std.testing.PropertyCase. - std-testing.runner.plainEvery method is a plain call, not a bang call.
- std-testing.runner.bindingWhen it runs a test case, the test runner binds
TestRunnerfor thestd.testingcode that runs the case around its body. For a property test case, it also bindsPropertyRunner. - std-testing.runner.body-propertyA test body's row may hold
TestRunner, bymodule.testing.unit-row.test-runner, but notPropertyRunner. So a body that usesPropertyRunnerwithout a provider scope is an error. Error:missing-requirement.
| Rule | Method | What it does |
|---|---|---|
| std-testing.runner.rowRow | row(count) |
reports that an it_each call has count rows, and returns the index of the row that the test case runs |
| std-testing.runner.timeoutTimeout | report_timeout(millis) |
reports the test case's timeout in milliseconds, before its body runs |
| std-testing.runner.snapshot-checkSnapshot | snapshot_check(text) |
compares text with the running test case's next snapshot file, and records the file in an update run; returns "" when the text matches or was recorded, else a message that says how the text differs or that the file is missing |
| std-testing.runner.temp-dirTemporary directory | temp_dir() |
returns the absolute path of the running test case's temporary directory, and makes the directory on the case's first call |
| std-testing.runner.start-caseStart | start(cases, shrink, examples) |
reports a property's cases, shrink, and count of examples, and returns the PropertyCase that the case runs |
| std-testing.runner.recordRecord | record(value) |
records the case's next draw, a value from 0 to that draw's bound |
| std-testing.runner.showShow | show(text) |
reports the Debug text of the case's input, which a failure report prints |
| Rule | Field of PropertyCase |
Holds |
|---|---|---|
| std-testing.runner.case.exampleExample | example |
the index of the example that the case runs, or .None for a generated case |
| std-testing.runner.case.seedSeed | seed |
the seed of the case's fresh draws |
| std-testing.runner.case.sizeSize | size |
how far the case's fresh draws reach, which grows from case to case |
| std-testing.runner.case.replayReplay | replay |
the recorded draws that the case replays or shrinks, in order; empty for a fresh case |
- std-testing.runner.examples-doneOnce every example has run,
startends the case instead of returning, and the runner counts the case as discarded. - std-testing.runner.show-before-body
std.testingcallsshowonce for each case, after it has drawn or taken the input and before the property body runs. - std-testing.runner.choicesEach
Choicesholds its case'sPropertyRunnerprovider, a&mut dyn PropertyRunner, so it records its draws through it with no requirement row, byreq.use.value.flow. - std-testing.runner.replayThe draw at position
iof a case returnsreplay[i], limited to the draw's bound, whenreplayholds a value ati. Every other draw is fresh. - std-testing.runner.record-every
Choicescallsrecordonce for every draw, replayed or fresh, before it uses the value. So the runner holds the case's draws even when the case then panics. - std-testing.runner.randomThe fresh draws of a case come from a
Randomprovider thatstd.testingseeds with the case'sseed. So the same seed draws the same cases. - std-testing.runner.case.size-countThe
sizeof a run's first generated case is 0. Each later generated case has one more than the case before it, a discarded case not counting. - std-testing.runner.case.seed-stepThe
seedof each case of a run, a discarded one included, is one more than theseedof the case before it.
use std.testing.{PropertyRunner, TestRunner}
struct FirstRow:
rows: usize
impl TestRunner for FirstRow:
fn row(&mut self, count: usize) -> usize:
self.rows = count
0
fn report_timeout(&mut self, millis: i64) -> void:
pass
fn snapshot_check(&mut self, text: string) -> string:
""
fn temp_dir(&mut self) -> string:
"/tmp/first-row"
fn pick(rows: List[string]) -> string $ TestRunner:
rows[$.use(TestRunner).row(rows.len())]
fn label(text: string) -> void $ PropertyRunner:
$.use(PropertyRunner).show(text)
tests:
it("picks a row"):
_ := pick(["a"])
it("labels the input"):
label("a") # error: missing-requirementWhy. The runner is a host, as a console is, so
std.testingreaches it through a capability in a row, not a primitive. Recording and shrinking stay on the runner's side, and replay is ordinary hd. The generator is ordinary hd, so a seed means the same draws on every host.
Note. Which generator
std.testingseeds is runner behavior, as is howsizegrows.lib/stduses xoshiro128**, seeded through SplitMix32.
See also: Host Capabilities, Table-Test Rows, Property Tests.
#Discarding A Case
- std-testing.runner.discard-panic
Choices.assume(false)ends the case with a panic whose category is that of apaniccall and whose message is exactlystd.testing: case discarded. Panic:explicit-panic. - std-testing.runner.discard-readThe runner reads such a panic, raised before the case calls
show, as a discard. The case neither passes nor fails, and it does not count towardcases. - std-testing.runner.discard-after-showThe same panic after
showis an ordinary failure, so a property body cannot discard a case, asstd-testing.prop.body-no-discardstates.
use std.testing.{assert, Choices, it_prop_with}
fn even(c: &mut Choices) -> i64:
let value: i64 = c.int(0..=100)
c.assume(value % 2 == 0) # a false assumption discards the case
value
tests:
it_prop_with("only even values are checked", gen=even, cases=20, prop=fn!(n: i64):
assert(n % 2 == 0, reason="assume discards odd values")
)Why. A discard is a panic, so it ends the case through the panic rules every case already has. The runner needs no method that does not return. Each draw was recorded before the panic, so the runner still holds the stream.
#Stateful Command Histories
commands draws a bounded history of commands by advancing a pure model during generation.
| Function | Signature |
|---|---|
std.testing.commands |
fn commands[M, C](c: &mut Choices, max: usize, initial: M, next: fn(&mut Choices, &M) -> C?, advance: fn(&M, &C) -> M) -> List[C] |
use std.testing.{Choices, commands, it_prop_with}
fn next_count(_c: &mut Choices, model: &usize) -> usize?:
if *model == 4: .None else: .Some(*model + 1)
fn advance_count(_model: &usize, command: &usize) -> usize:
*command
fn counts(c: &mut Choices) -> List[usize]:
commands(c, max=8, initial=0usize, next=next_count, advance=advance_count)
tests:
it_prop_with("counter follows its model", gen=counts, prop=fn!(history: List[usize]):
let system: usize = 0
for expected in history:
system += 1
assert_equal(system, expected, reason="the command's expected count")
)- std-testing.commands.declare
std.testingdeclarescommandswith the signature above. - std-testing.commands.length
commandsdraws its target length withc.int(0..=max). - std-testing.commands.initialEach invocation starts its model at
initialand its history empty. - std-testing.commands.nextUntil the target length is reached,
next(c, &model)chooses a command valid for the current model or returns.None. - std-testing.commands.stopA
.Nonefromnextends generation and returns the history drawn so far. - std-testing.commands.appendEach
.Some(command)appendscommandto the history. - std-testing.commands.advanceAfter appending a command,
advance(&model, &command)supplies the model for the next call tonext. - std-testing.commands.deterministic
nextandadvancemust depend only on the supplied choices, model, and command. - std-testing.commands.replayReplay regenerates the history from
initialthroughnextandadvance, including when the property runner shrinks the choices. - std-testing.commands.executeA stateful property's body must execute the generated history without filtering commands whose preconditions no longer hold.
- std-testing.commands.fresh-systemA stateful property's body must construct fresh model and system state for each invocation.
The same recipe can be written directly over Choices and it_prop_with:
use std.testing.{Choices, it_prop_with}
fn increments(c: &mut Choices) -> List[usize]:
let mut history: List[usize] = []
let model: usize = 0
count := c.int(0..=8usize)
for _ in 0..count:
model += 1
history.push(model)
history
tests:
it_prop_with("counter follows the direct recipe", gen=increments, prop=fn!(history: List[usize]):
let system: usize = 0
for expected in history:
system += 1
assert_equal(system, expected, reason="the command's expected count")
)Why. Regenerating from a pure model keeps a shrunk history valid. Local fakes can be constructed inside the property body, so existing shrinking and saved choice streams need no execution journal.
See also: Property Tests, Unit Test Providers.