Zulip Chat Archive

Stream: lean4

Topic: Introducing the nest


Henrik Böving (Aug 26 2023 at 15:26):

I started implementing a lean variant of the haskell testing library/eco-system tasty (https://hackage.haskell.org/package/tasty-1.4.3).

It is a library that provides an extensible way to both declare and execute tests. The implementation of the core data structures lives here: https://github.com/hargoniX/nest-core and I wrote a basic assert based test provider here: https://github.com/hargoniX/nest-unit. Taking an example from the README you write your tests like so:

import NestCore
import NestUnit

open Nest.Core
open Nest.Unit

def fileRes (path : System.FilePath) (mode : IO.FS.Mode) : ResourceSpec IO.FS.Handle where
  get := IO.FS.Handle.mk path mode
  release handle := handle.flush
  description := s!"A file handle to {path}"

def tests : TestTree := [nest|
  group "Self Tests"
    group "Basic"
      test "succeeds on true" : UnitTest := do
        assert true
      test "fails on false (expected to fail)" : UnitTest := do
        assert false
    group "Resource based"
      with resource fileRes "/dev/zero" .read as res
        test "assertion 3" : UnitTest := do
          let data  res.read 12
          assert data.size = 12
    group "Option based"
      with options fun x => x.insert `Hello "foo"
        with options as x
          test "assertion 4" : UnitTest := do
            assert x.contains `Hello
]

def main : IO UInt32 := Nest.Core.defaultMain tests

(If you are scared of the lots of custom syntax, it is just a very thin layer on top of TestTree which you can easily write out without the syntax as well, it is just a little cleaner this way).

The output of the above test would look as follows:

Running group Self Tests:
  Running group Basic:
    succeeds on true: all assertions succeeded [OK]
    fails on false (expected to fail): assertion failure: boolean was false [FAIL]
      file: './././Main.lean', line: 18, col: 8
  Running group Resource based:
    Acquiring resource: A file handle to /dev/zero
      assertion 3: all assertions succeeded [OK]
    Releasing resource
  Running group Option based:
    assertion 4: all assertions succeeded [OK]

Any feedback or feature wishes welcome^^

I was thinking about adding additional test providers for:

  • golden tests, i.e. ones that compare to files
  • property tests based on SlimCheck
  • performance tests

Joachim Breitner (Aug 26 2023 at 15:32):

Pretty neat! It’s great to have all these tools around.

How bad would that example look like without custom syntax? I know custom syntax is fun to come up with and can often make a big difference, but it does add to the mental complexity of using a new tools, and maybe normal syntax (with good combinators) is good enough?

Henrik Böving (Aug 26 2023 at 15:45):

If you want to write it a singular decl like above it gets eh..rough:

def tests2 : TestTree :=
  .group "Self Tests" [
    .group "Basics" [
      .single "succeeds on true" (assert true),
      .single "fails on false" (assert false)
    ],
    .group "Resource based" [
      .withResource (fileRes "/dev/zero" .read) (fun res =>
        .single "assertion 3" (do
          let data  res.read 12
          assert data.size = 12 : UnitTest)
      )
    ]
  ]

Although a lot of this can of course be mitigated by splitting it up into more declarations as i've seen tasty seems to do regularly?

def basics : TestTree :=
  .group "Basics" [
    .single "succeeds on true" (assert true),
    .single "fails on false" (assert false)
  ]

def resTest (res : IO.FS.Handle) : TestTree :=
 .single "assertion 3" (do
   let data  res.read 12
   assert data.size = 12 : UnitTest) -- This is particularly annoying but I feel it can be fixed

def resBased : TestTree :=
  .group "Resource based" [
    .withResource (fileRes "/dev/zero" .read) resTest
  ]

def tests2 : TestTree :=
  .group "Self Tests" [
    basics,
    resBased
  ]

def main : IO UInt32 := Nest.Core.defaultMain tests2

Henrik Böving (Aug 26 2023 at 15:46):

@Joachim Breitner

Joachim Breitner (Aug 26 2023 at 16:09):

I find the first of these not particularly rough, and especially when reading it I'll have a much easier time understanding what's going on (in particular that there isn't much going on; these are just constructors), and not significantly less pretty than the custom syntax. And I'll make it easier for me to define my little helper functions if I don't have syntax to see through. But I am of course very much biased by having worked with tasty a lot :-)

Joachim Breitner (Aug 26 2023 at 16:10):

(Also sorry for following Wadler's law - you post a cool tool and the first feedback you get is on syntax :-))

Joachim Breitner (Aug 26 2023 at 16:11):

You don't need the parents around do and fun, do you? Without them it's even closer to the custom syntax. Ah, but there is a type annotation. I see.

Henrik Böving (Aug 26 2023 at 16:13):

Joachim Breitner said:

You don't need the parents around do and fun, do you? Without them it's even closer to the custom syntax. Ah, but there is a type annotation. I see.

The parens around the do in assertion 3 are necessary for the type annotation so that type inference works out properly (this is one of the reasons I added the custom syntax). I don't understand how you think it is possible to get rid of the fun in the first example though?

Henrik Böving (Aug 26 2023 at 16:15):

Oh or do you mean getting rid of the parens around fun?

Joachim Breitner (Aug 26 2023 at 16:18):

Yes, just a minor thing, but it increases prettiness if you don't have these closing parentheses flyting around code that's properly indented anyways (in my humble opinion)

Henrik Böving (Aug 26 2023 at 16:22):

Oh I also found a way to get rid off the annotation on do!

Henrik Böving (Aug 26 2023 at 16:22):

Let me update the readme

Henrik Böving (Aug 26 2023 at 16:27):

def tests : TestTree :=
  .group "Self Tests" [
    .group "Basics" [
      .single "succeeds on true" (assert true),
      .single "fails on false" (assert false)
    ],
    .group "Resource based" [
      .withResource (fileRes "/dev/zero" .read) fun res =>
        .single (t := UnitTest) "assertion 3" do
          let data  res.read 12
          assert data.size = 12
    ],
    .group "Option based" [
      .withOptions (fun x => x.insert `Hello "foo") <|
        .getOptions fun x =>
          .single "assertion 4" (assert x.contains `Hello)
    ]
  ]

@Joachim Breitner \o/

Joachim Breitner (Aug 26 2023 at 17:06):

Just looking at this example, it seems assert isn't a plain function, is it?

Henrik Böving (Aug 26 2023 at 17:15):

Yes this is explained in nest-unit. I need it to be not a plain function for two reasons:
a) I want to print the location where it failed and I need a meta program snippet to figure out the location
b) I want to be able to also assert decidable propositions but if you look in the implementation of NestUnit you will see this requires a little trick to make compatible with the typeclass mechanism.

Henrik Böving (Aug 26 2023 at 17:18):

We dont have the HasCallStack thing that haskell has so a) is necessary. I tried to work around b) by using a coercion but that didn't kick in properly. You can write something explicit like assert <| Proxy.mk <| x = y but that seemed too much of a hassle

Kyle Miller (Aug 26 2023 at 17:31):

What's the purpose of Proxy? Is it to turn a value into a type so you can add Prop instances piecemeal?

It seems to me you could change Assertable to

class Assertable {α : Sort u} (x : α) where
  assertThis : UnitM Unit

and then for example you could write

instance [forall (x : t), Assertable x] (xs : List t) : Assertable xs where
  assertThis := xs.forM assertThis

and

instance [DecidableEq α] [Repr α] {x y : α} : Assertable (x = y) where
  assertThis := do
    unless x = y do
      assertFailure s!"equality failed, left: '{repr x}', right: '{repr y}'"

With this design, you don't need to conditionally insert Proxy.

Henrik Böving (Aug 26 2023 at 17:34):

Oh! That's awesome yes, I'll refactor to this approach

Sebastian Ullrich (Aug 26 2023 at 17:38):

HasCallStack could be simulated with auto params

Henrik Böving (Aug 26 2023 at 17:43):

Sebastian Ullrich said:

HasCallStack could be simulated with auto params

You mean that I inject the position via some custom elaborated pos% that gets auto paramed to the assert function?

Henrik Böving (Aug 26 2023 at 17:57):

Kyle Miller said:

What's the purpose of Proxy? Is it to turn a value into a type so you can add Prop instances piecemeal?

It seems to me you could change Assertable to

class Assertable {α : Sort u} (x : α) where
  assertThis : UnitM Unit

and then for example you could write

instance [forall (x : t), Assertable x] (xs : List t) : Assertable xs where
  assertThis := xs.forM assertThis

and

instance [DecidableEq α] [Repr α] {x y : α} : Assertable (x = y) where
  assertThis := do
    unless x = y do
      assertFailure s!"equality failed, left: '{repr x}', right: '{repr y}'"

With this design, you don't need to conditionally insert Proxy.

interestingly just

instance [forall (x : t), Assertable x] (xs : List t) : Assertable xs where
  assertThis := xs.forM assertThis

doesn't type check bu

instance [forall (x : t), Assertable x] (xs : List t) : Assertable xs where
  assertThis := xs.forM (assertThis ·)

does?

Henrik Böving (Aug 26 2023 at 18:04):

@Sebastian Ullrich I tried this now:

open Lean in
scoped elab "pos%" : term => do
  let some pos := ( getRef).getPos?
    | throwError "no source info"
  let pos := ( getFileMap).toPosition pos
  let posExpr := toExpr ( getFileName, pos)
  return posExpr

def assert (t : α) [Assertable t] (pos := pos%) : UnitM Unit :=
  try
    assertThis t
  catch e =>
    throw <| { e with pos := some pos }

but this gives me the wrong position (namely the location of the def assert) did you have something else in mind?

Kyle Miller (Aug 26 2023 at 18:07):

I think it might be (pos := by exact pos%) to defer it

Kyle Miller (Aug 26 2023 at 18:10):

Henrik Böving said:

interestingly just [...] doesn't type check but [...] does?

I'd guess it's because the new assertThis now has the instance argument coming after the x argument. Writing (assertThis ·) applies the function so it unlocks filling in the instance argument.

Henrik Böving (Aug 26 2023 at 18:14):

Kyle Miller said:

I think it might be (pos := by exact pos%) to defer it

indeed, makes sense.

Henrik Böving (Aug 26 2023 at 18:18):

https://github.com/hargoniX/nest-unit pushed, now with 90% less meta magic and 90% more type theory magic :P

Schrodinger ZHU Yifan (Aug 26 2023 at 18:35):

I wonder if we can also have a DSL for benchmark similar to google/benchmark or criterion-rs

Henrik Böving (Aug 26 2023 at 18:38):

sure, in general I would claim Lean's DSL capabilities are strictly stronger than what is possible in rust

Henrik Böving (Aug 26 2023 at 18:39):

With large parts of a functioning software eco system for Lean the question is IMO not "can we do it" but rather "where is the person willing to do it" :)

MangoIV (Aug 26 2023 at 18:47):

There’s tasty-bench so I’d say yes

Henrik Böving (Aug 27 2023 at 21:39):

Now with property tests! https://github.com/hargoniX/nest-slimcheck

import NestCore
import NestSlimCheck

open Nest.Core
open Nest.SlimCheck

def tests : TestTree := [nest|
  group "examples"
    group "examples positive"
      test "rfl" : PropTest := .mk <|  (x : Nat), x = x
      test "reverse_append" : PropTest := .mk <|  (xs ys : List Nat), (xs ++ ys).reverse = ys.reverse ++ xs.reverse
    group "examples negative"
      test "lt" : PropTest := .mk <|  (x y : Nat), x < y
      test "append_com" : PropTest := .mk <|  (xs ys : List Nat), xs ++ ys = ys ++ xs
]

def main : IO UInt32 := Nest.Core.defaultMain tests
Running group examples:
  Running group examples positive:
    rfl: Unable to find counter examples (seed : 17067480472798704719) [OK]
    reverse_append: Unable to find counter examples (seed : 411235305205085820) [OK]
  Running group examples negative:
    lt: Found a counter example (seed : 13691387637133543223) [FAIL]
      x := 0, y := 0, issue: 0 < 0 does not hold
    append_com: Found a counter example (seed : 10601116111013419514) [FAIL]
      xs := [0], ys := [1], issue: [0, 1] = [1, 0] does not hold

James Gallicchio (Aug 27 2023 at 22:51):

now to write a fuzzer macro :big_smile:

Schrodinger ZHU Yifan (Aug 27 2023 at 22:52):

seems hard to make it coverage guarded with C backend (not sure

Henrik Böving (Aug 27 2023 at 22:54):

Schrodinger ZHU Yifan said:

seems hard to make it coverage guarded with C backend (not sure

I don't think so. We could in theory use coverage tools made by C people. That said once the LLVM backand goes live as the default code generation facility we can use LLVMs coverage mechanism which should make this even nicer for us I believe

Henrik Böving (Aug 27 2023 at 22:56):

Using built-in C coverage tools will certainly always have an issue though. There are branches that might simply be impossible to hit with common tests without thinking a lot about stuff. For example the uniqueness checks by the RC optimizations. If you write performant code you will always want those to say stuff is unique so it is imaginable you will not hit the false branch. That's why having an LLVM based approach could be quite helpful I think


Last updated: Dec 20 2023 at 11:08 UTC