UniTest
UniTest is a lightweight testing framework for Scala 3. It provides a simple, expressive API for writing tests with minimal boilerplate while supporting cross-platform development (JVM, Scala.js, Scala Native).
Quick Start
Add the dependency to your build:
libraryDependencies += "org.wvlet.uni" %% "uni-test" % "2026.1.39" % Test
testFrameworks += new TestFramework("wvlet.uni.test.Framework")Write your first test:
import wvlet.uni.test.UniTest
class MyTest extends UniTest:
test("addition works") {
(1 + 1) shouldBe 2
}Run tests:
sbt testAssertions in UniTest
| Syntax | Meaning |
|---|---|
assert(x == y) | Check x equals y |
assertEquals(a, b, delta) | Check Float/Double equality with tolerance |
intercept[E] { ... } | Catch an exception of type E |
x shouldBe y | Check x == y (supports deep equality for Array, Seq) |
x shouldNotBe y | Check x != y |
x shouldBe null | Check x is null |
x shouldNotBe null | Check x is not null |
x shouldBe defined | Check x.isDefined == true (Option, Seq, String) |
x shouldBe empty | Check x.isEmpty == true (Option, Seq, String) |
x shouldBeTheSameInstanceAs y | Check x eq y (reference equality) |
x shouldNotBeTheSameInstanceAs y | Check x ne y (reference inequality) |
x shouldMatch { case .. => } | Check x matches the pattern |
x shouldContain y | Check x contains y |
x shouldNotContain y | Check x does not contain y |
fail("reason") | Fail the test |
ignore("reason") | Ignore the test |
cancel("reason") | Cancel the test (e.g., setup failure) |
pending("reason") | Mark test as pending |
pendingUntil("reason") | Pending until condition is fixed |
skip("reason") | Skip the test (e.g., platform-specific) |
Writing Tests
Basic Test Structure
import wvlet.uni.test.UniTest
class CalculatorTest extends UniTest:
test("add two numbers") {
val result = 2 + 3
result shouldBe 5
}
test("subtract two numbers") {
val result = 5 - 3
result shouldBe 2
}Nested Tests
Tests can be nested for better organization:
class MathTest extends UniTest:
test("arithmetic operations") {
val x = 10
test("addition") {
(x + 5) shouldBe 15
}
test("multiplication") {
(x * 2) shouldBe 20
}
}Nested test names include their parent context: "arithmetic operations / addition".
Assertions
Equality
| Syntax | Description |
|---|---|
a shouldBe b | Assert a equals b |
a shouldNotBe b | Assert a does not equal b |
test("equality assertions") {
1 + 1 shouldBe 2
"hello" shouldBe "hello"
List(1, 2, 3) shouldBe List(1, 2, 3)
1 shouldNotBe 2
"hello" shouldNotBe "world"
}Null Checks
test("null checks") {
val value: String = null
value shouldBe null
val nonNull = "hello"
nonNull shouldNotBe null
}Defined and Empty
| Syntax | Description |
|---|---|
a shouldBe defined | Assert Option is Some, collection/string is non-empty |
a shouldBe empty | Assert Option is None, collection/string is empty |
defined and empty are objects in the wvlet.uni.test package, so import wvlet.uni.test.* (not only UniTest) to use them.
test("defined and empty") {
val some: Option[Int] = Some(42)
val none: Option[Int] = None
some shouldBe defined
none shouldBe empty
some shouldNotBe empty
none shouldNotBe defined
List(1, 2, 3) shouldBe defined
List.empty[Int] shouldBe empty
"hello" shouldBe defined
"" shouldBe empty
}Collection Assertions
| Syntax | Description |
|---|---|
collection shouldContain element | Assert collection contains element |
collection shouldNotContain element | Assert collection does not contain element |
test("collection assertions") {
val list = List(1, 2, 3)
list shouldContain 2
list shouldNotContain 5
}String Assertions
test("string assertions") {
val str = "Hello, World!"
str shouldContain "World"
str shouldNotContain "Goodbye"
str.startsWith("Hello") shouldBe true
str.endsWith("!") shouldBe true
}Pattern Matching
Use shouldMatch for type-safe pattern matching assertions:
case class User(name: String, age: Int)
test("pattern matching") {
val result: Any = User("Alice", 30)
result shouldMatch { case User(name, age) =>
name shouldBe "Alice"
age shouldBe 30
}
// Type checking
result shouldMatch { case _: User => }
}Prefer shouldMatch over asInstanceOf for type-safe assertions.
Reference Equality
test("reference equality") {
val a = List(1, 2, 3)
val b = a
val c = List(1, 2, 3)
a shouldBeTheSameInstanceAs b
a shouldNotBeTheSameInstanceAs c
}Floating Point Comparisons
test("floating point with delta") {
assertEquals(0.1 + 0.2, 0.3, 0.0001)
assertEquals(3.14159f, 3.14f, 0.01f)
}Exception Testing
test("exception handling") {
val e = intercept[IllegalArgumentException] {
require(false, "invalid argument")
}
e.getMessage shouldContain "invalid argument"
}Boolean Assertions
For comparisons, use boolean expressions with shouldBe true:
test("comparisons") {
val value = 42
(value > 10) shouldBe true
(value < 100) shouldBe true
(value >= 42) shouldBe true
(value <= 42) shouldBe true
(value >= 0 && value <= 100) shouldBe true
}Testing Error Paths
A test that makes code fail on purpose, such as a handler that throws, makes the code log a warning with a stack trace. Wrap the code in captureLogs to keep those records out of the test output and to check that they were logged:
import wvlet.uni.log.LogSupport
class Importer extends LogSupport:
def parse(line: String): Option[Int] =
val n = line.toIntOption
if n.isEmpty then
warn(s"Skipping an invalid line: ${line}")
n
test("skip invalid lines with a warning") {
val logs = captureLogs {
Importer().parse("abc") shouldBe None
}
logs.warnings shouldContain "Skipping an invalid line: abc"
}captureLogs returns a CapturedLogs:
| Member | Content |
|---|---|
warnings | Messages of WARN records, one per line |
errors | Messages of ERROR records, one per line |
messages | Messages of all records, one per line |
records | The LogRecords, for checks on level, logger name, or cause |
A record with an exception adds a line with the exception, so logs.warnings shouldContain "boom" also matches the exception message.
What it captures:
- Records from the thread running the block, and from threads that run no test suite, such as a server's worker threads and
Futurepools. A test that starts a server and sends it a failing request captures the server's warnings. - Not records from threads running another suite, so suites running in parallel keep their own output.
captureLogs[A] { ... }captures only the logger ofAand its children. Use it for long blocks, so that records of unrelated code stay visible.- Log levels are not changed. A record below its logger's level is not emitted, so it is not captured either.
- If the block throws, the captured records are printed, so the failure stays debuggable.
- The block must be synchronous: one that returns a
FutureorRxfails, because the async code would run after the capture ends.
captureStdout and captureStderr return what the block printed to Console.out and Console.err:
test("print a greeting") {
val out = captureStdout {
println("hello")
}
out shouldBe s"hello${System.lineSeparator()}"
}They redirect Scala's Console for the current thread and threads it starts, not the process-wide System.out/System.err, so they capture println but not output that Java code writes to System.out directly.
Test Control
Skip Tests
test("platform-specific test") {
if isScalaJS then
skip("Not supported on Scala.js")
// JVM-only code, e.g., accessing a system property
System.getProperty("java.version") shouldNotBe null
}Pending Tests
test("future feature") {
pending("Implementation not complete yet")
}
test("blocked by issue") {
pendingUntil("Issue #123 is fixed")
}Flaky Tests
Mark tests as flaky to convert failures to skipped status:
test("network test", Flaky) {
// Failures become skipped instead of failed
fetchFromNetwork() shouldBe "expected"
}
// Or wrap specific code
test("partially flaky") {
val stableResult = computeLocally()
stableResult shouldBe 42
flaky {
val unstableResult = fetchFromNetwork()
unstableResult shouldBe "expected"
}
}Cancel and Ignore
test("requires setup") {
if !setupComplete then
cancel("Setup failed, cannot run test")
runTest()
}
test("deprecated test") {
ignore("This test is no longer relevant")
}Platform Detection
test("platform-specific behavior") {
if isScalaJVM then
// JVM-specific assertions
System.getProperty("os.name") shouldBe defined
if isScalaJS then
// Scala.js-specific assertions
skip("JVM-only test")
if isScalaNative then
// Scala Native-specific assertions
skip("JVM-only test")
}Test Tags
Pass one or more tags after the test name to group tests, then run or skip a group from sbt. A single suite can then hold unit, integration, and UI tests and still run one testing layer at a time, as VSCode does with its separate unit, integration, and UI test commands.
test("renders the toolbar", UI) {
// ...
}
test("hits the real database", Integration, Slow) {
// ...
}
test("end-to-end happy path", Smoke) {
// ...
}
test("retried under load", Flaky) {
// ...
}Built-in tags are in scope in any UniTest:
| Tag | Name | Meaning |
|---|---|---|
Integration | integration | Uses real external systems |
Smoke | smoke | Quick sanity check, run as a fast pre-merge subset |
Slow | slow | Usually excluded from the inner-loop run |
Flaky | flaky | A failure is reported as skipped instead of failing the build |
UI | ui | UI-layer test |
Electron | electron | Electron-layer test, for desktop apps built on Uni's Electron support |
Define your own tags with TestTag("name"), or as an object when you reuse one across suites:
import wvlet.uni.test.TestTag
case object Gpu extends TestTag("gpu")
test("trains on the GPU", Gpu, Slow) {
// ...
}
test("legacy path", TestTag("legacy")) {
// ...
}A bare string also works, as in test("x", "legacy"), with import scala.language.implicitConversions at the call site.
Selecting Tests by Tag
Pass --tags and --exclude-tags after --. Both refer to a tag's name:
sbt "test -- --tags ui" # only tests tagged ui
sbt "test -- --tags ui --tags slow" # tests tagged both ui and slow
sbt "test -- --exclude-tags slow" # everything except slow tests
sbt "test -- --exclude-tags flaky" # skip known-flaky tests- Repeating
--tagsnarrows the selection: a test must carry every listed tag (AND), like GitHub label filters. --exclude-tagsdrops tests carrying any listed tag, and wins over--tags.- With no
--tags, every test is included. - Tags apply to top-level tests. Nested tests run with their parent.
Design Integration
A test whose body takes typed parameters gets them from the suite's Design. Add bindings with addDesign, and list what the test needs as the parameters of its body:
import wvlet.uni.test.*
class UserServiceTest extends UniTest:
addDesign(
_.bindImpl[UserRepository, InMemoryUserRepository]
.bindImpl[UserService, UserServiceImpl]
)
test("create user") { (service: UserService) =>
val user = service.createUser("Alice")
user.name shouldBe "Alice"
}
test("find user") { (service: UserService, repo: UserRepository) =>
service.createUser("Bob")
repo.findByName("Bob") shouldBe defined
}- Shared or per test: objects bound with
addDesignare built once and shared by the tests of the suite. Objects bound withaddTestDesign, and objects that are not bound at all, are built for each test and closed after it. - Sessions: the suite's Design session starts when the first such test runs, and shuts down after
afterAll. Each test gets a child session, which shuts down afterafter, once a returnedFutureorRxcompletes. Shutting a session down closes the objects it built (AutoCloseableoronShutdown) in reverse creation order. - Arguments: a test can take up to as many arguments as a Scala function literal allows, and can take
(session: Session)to get the session itself. - Plain tests: a body without parameters is a plain test and opens no session.
Lifecycle Hooks
Override these protected hooks to run code around tests:
| Hook | Runs | If it throws |
|---|---|---|
beforeAll | Once, before the first test | The suite fails and no test runs; afterAll still runs |
afterAll | Once, after the last test, also after a failed beforeAll | Reported as a suite error |
before | Before each test, including each nested test | The test fails and its body does not run; after still runs |
after | After each test, once its Future or Rx result completes, also when the test or before fails | The test fails, unless it already failed |
class CacheTest extends UniTest:
private val cache = scala.collection.mutable.Map.empty[String, Int]
override protected def after: Unit = cache.clear()
test("put") {
cache("a") = 1
cache.size shouldBe 1
}
test("starts empty") {
cache.isEmpty shouldBe true
}Tests in a suite run one at a time, in the order they are registered. Nested tests are queued after the tests already registered and run only when their parent passed, each with its own before and after.
The hooks are synchronous. To open and close resources such as servers or clients, prefer binding them in a Design (see Reusable Fixtures) over writing hooks.
Reusable Fixtures
A fixture is a trait that adds bindings for resources such as a server, a client, or a temp directory, so that many suites can mix it in. Each fixture calls addDesign (or addTestDesign) from its body; the calls compose in trait initialization order, and a later binding of the same type wins:
import wvlet.uni.test.*
import wvlet.uni.http.{Http, HttpHandler, HttpServer, HttpSyncClient, Request, Response}
import wvlet.uni.http.Http
// Starts a server on a free port for the HttpHandler that each suite binds
trait HttpServerFixture extends UniTest:
addDesign(
_.bindProvider[HttpHandler, HttpServer](handler =>
Http.server.withPort(0).withHandler(handler).start()
)
)
// Depends on the server, so the session closes the client before it stops the server
trait HttpClientFixture extends HttpServerFixture:
addDesign(
_.bindProvider[HttpServer, HttpSyncClient](server =>
Http.client.withBaseUri(s"http://localhost:${server.localPort}").newSyncClient
)
)
class GreetingApiTest extends HttpClientFixture:
addDesign(_.bindInstance[HttpHandler](request => Response.ok(s"hello ${request.path}")))
test("GET /ping") { (client: HttpSyncClient) =>
client.send(Request.get("/ping")).contentAsString shouldBe Some("hello /ping")
}
test("the client is shared by the tests of the suite") { (client: HttpSyncClient) =>
client.send(Request.get("/again")).contentAsString shouldBe Some("hello /again")
}- Shutdown order: the session closes objects in reverse creation order, so dependencies decide the order. If one
close()throws, the session still closes the rest and the error is reported. - Startup failures: an object that fails to build fails the test that needs it; the objects already built are still closed.
- When to call: call
addDesignandaddTestDesignfrom the body of the test class or a fixture trait. Calling them once the tests run throws.
These fixtures, with tests that check startup, reuse, and shutdown against a real server, are in HttpClientFixtureTest.scala.
Per-Test Resources
Bind what each test needs fresh with addTestDesign:
import java.nio.file.{Files, LinkOption, Path}
class TempDir extends AutoCloseable:
val path: Path = Files.createTempDirectory("test")
override def close(): Unit = deleteRecursively(path)
private def deleteRecursively(p: Path): Unit =
// Do not follow symlinks out of the temp directory
if Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS) then
val children = Files.list(p)
try
children.forEach(deleteRecursively)
finally
children.close()
Files.deleteIfExists(p)
trait TempDirFixture extends UniTest:
addTestDesign(_.bindSingleton[TempDir])- Async tests: a test's objects stay open until its
FutureorRxcompletes. A loan helper such aswithTempDir { dir => ... }closes them as soon as the body returns, which is too early when the body returns aFuture. - Nested tests: each nested test that takes arguments gets its own session. Take the arguments in the nested test; do not capture the parent's.
Combining Fixtures
Mix fixtures with extends A with B. Their bindings are added in trait initialization order: a trait's after those of the traits it extends, and B's after A's.
class UploadApiTest extends HttpClientFixture with TempDirFixture:
addDesign(_.bindInstance[HttpHandler](request => Response.ok(s"uploaded ${request.path}")))
test("upload a file") { (client: HttpSyncClient, dir: TempDir) =>
val file = Files.writeString(dir.path.resolve("a.txt"), "hello")
// ... send it with `client`
}The fixtures in DesignTestTest.scala run on all platforms. They and DesignLifecycleTest.scala check the behavior above with the sbt runner and the JUnit engine used by IDEs.
Hooks in Fixtures
When a fixture also overrides a lifecycle hook, write override and call super, so that the hooks of the other fixtures still run:
trait AuditFixture extends UniTest:
override protected def afterAll: Unit =
try info("suite finished")
finally super.afterAllProperty-Based Testing
UniTest ships a self-contained property-based testing toolkit — no external dependency — and it's part of the base UniTest trait. Any test class gets forAll automatically. Call it with a body that holds for every randomly generated input; a failing run is automatically shrunk to a minimal counter-example, and the seed is reported so the run can be replayed.
import wvlet.uni.test.UniTest
class ListLawsTest extends UniTest:
test("addition is commutative") {
forAll { (a: Int, b: Int) =>
(a + b) shouldBe (b + a)
}
}
test("reverse is an involution") {
forAll { (xs: List[Int]) =>
xs.reverse.reverse shouldBe xs
}
}Generators
Generators live in wvlet.uni.test.check.Gen. Every forAll body receives randomly generated values — by default drawn from the type's implicit Arbitrary instance, or from a Gen you supply explicitly.
import wvlet.uni.test.check.Gen
test("positive integers") {
forAll(Gen.posNum[Int]) { n =>
(n > 0) shouldBe true
}
}
test("bounded integers") {
forAll(Gen.chooseNum(1, 100)) { n =>
(n >= 1 && n <= 100) shouldBe true
}
}Common combinators:
| Factory | Produces |
|---|---|
Gen.const(x) | Always x |
Gen.chooseNum[T](min, max) | Uniform integer in [min, max] |
Gen.posNum[T] | Positive, non-zero value |
Gen.oneOf(a, b, c) | Uniform choice from values |
Gen.oneOfGen(g1, g2) | Uniform choice from generators |
Gen.frequency(w1 -> g1, w2 -> g2) | Weighted choice from generators |
Gen.listOf(g) / Gen.nonEmptyListOf(g) | List of g, size-aware |
Gen.listOfN(n, g) | List of exactly n elements |
Gen.setOf(g) / Gen.mapOf(gK, gV) | Set / Map, size-aware |
Gen.option(g) | Some(g) or None |
Gen.alphaChar / .numChar / .alphaNumChar / .asciiChar | Single character |
Gen.alphaStr / .numStr / .alphaNumStr / .asciiStr | Size-aware string |
Gen.identifier | [A-Za-z][A-Za-z0-9]* |
Gen.sized(n => g) / Gen.resize(n, g) | Read or override the current size |
Generators compose like Options: g.map, g.flatMap, g.filter, g.zip, plus for-comprehensions.
val evenPositive: Gen[Int] = Gen.posNum[Int].map(_ * 2)
val labeledInt: Gen[(String, Int)] =
for
label <- Gen.alphaStr
value <- Gen.chooseNum(0, 100)
yield (label, value)Arbitrary and derivation
Implicit Arbitrary[T] instances drive the argument-less forAll overload. Defaults ship for primitives, String, Array[Byte], List, Vector, Option, and small tuples. For your own types, use Scala 3's derives:
import wvlet.uni.test.check.Arbitrary
case class Point(x: Int, y: Int) derives Arbitrary
sealed trait Shape derives Arbitrary
case class Circle(r: Int) extends Shape
case class Rect(w: Int, h: Int) extends Shape
case class Triangle(a: Int, b: Int, c: Int) extends Shape
test("shapes round-trip through the renderer") {
forAll { (s: Shape) =>
Renderer.parse(Renderer.show(s)) shouldBe s
}
}Derivation is recursive: a derives Arbitrary on a sealed trait picks up each case class automatically, and fields of user types that themselves have an Arbitrary given (or a Mirror) are threaded through.
To summon a default generator explicitly, use Arbitrary.arbitrary[T]:
forAll(Arbitrary.arbitrary[String]) { s =>
s.length >= 0 shouldBe true
}Shrinking
When a property fails, the runner shrinks the counter-example to a minimal value. Default shrinkers ship for numeric primitives, Char, String, Array[Byte], List, Option, and tuples up to arity 5.
test("sum is never negative for non-negative inputs") {
forAll { (xs: List[Int]) =>
// Sum as Long: an Int sum of large inputs overflows
xs.filter(_ >= 0).map(_.toLong).sum >= 0 shouldBe true
}
}If this property were broken, the error message would point at a minimal list — typically List(...) with one or two elements — rather than the raw random failing input.
User types get an empty shrinker by default, so forAll[MyCaseClass] compiles even without a shrink instance. To customize, provide a given:
import wvlet.uni.test.check.Shrink
given Shrink[Point] = Shrink { p =>
LazyList(Point(0, 0), Point(p.x, 0), Point(0, p.y))
}Preconditions with ==> / implies
Use ==> (or its English alias implies) to discard samples that don't satisfy a precondition. The runner retries with a new sample and fails the test only if the discard budget (default: 500) runs out.
test("division by non-zero is self-inverse") {
forAll { (a: Int, b: Int) =>
(b != 0) ==> {
// Multiply as Long: a * b can overflow Int
((a.toLong * b) / b) shouldBe a.toLong
}
}
}
test("reads the same way in prose") {
forAll { (xs: List[Int]) =>
xs.nonEmpty implies {
xs.head shouldBe xs(0)
}
}
}Statistics: classify and collect
Break down what the generator is actually producing to make sure your property is exercising the cases you care about. Counts print after the run.
test("partition respects predicate") {
forAll(Gen.listOf(Gen.chooseNum(-100, 100))) { xs =>
classify(xs.isEmpty, "empty")
classify(xs.size > 50, "large")
collect(s"size=${xs.size / 10 * 10}")
val (neg, nonNeg) = xs.partition(_ < 0)
(neg ++ nonNeg).sorted shouldBe xs.sorted
}
}Output (example):
[property] 42 × size=10, 31 × size=0, 18 × large, 9 × size=20, 7 × emptyConfiguration
Override propertyConfig on the test class to change sample count, seed, size range, discard limit, or shrink budget for every forAll in that class. For a one-off tweak of the sample count, override minSuccessfulTests.
import wvlet.uni.test.check.PropertyConfig
class HeavyPropertyTest extends UniTest:
override protected def propertyConfig: PropertyConfig =
PropertyConfig.default
.withMinSuccessful(500)
.withSize(0, 256)
.withSeed(1_234L) // reproducible runs| Knob | Method | Default |
|---|---|---|
| Samples | withMinSuccessful(n) | 100 |
| Discard limit | withMaxDiscarded(n) | 500 |
| Size range | withSize(min, max) | 0..100 |
| Shrink budget | withMaxShrinks(n) | 1000 |
| Seed | withSeed(s) | System.nanoTime() |
Every failure includes the seed. To reproduce a flaky failure, copy the seed from the error message into propertyConfig.withSeed(...) and rerun — the exact same samples are drawn.
Running Tests
sbt Commands
# Run all tests
sbt test
# Run tests in a specific module
sbt coreJVM/test
# Run a specific test class
sbt "my-project/testOnly *MyTest"
# Run with debug logging
sbt "coreJVM/testOnly * -- -l debug"
# Run with trace logging for specific package
sbt "test -- -l:wvlet.uni.*=trace"
# Filter tests by name
sbt "test -- -t:ChatSession"
# Filter tests by tag (see Test Tags)
sbt "test -- --tags integration"
sbt "test -- --exclude-tags slow"Scala.js tests in a real browser
To run Scala.js tests in a real browser (headless Chromium/Firefox/WebKit) with ES module support — for DOM, web, and Electron code — see the sbt-uni-playwright Plugin.
Test Output
Test results are displayed with status symbols:
| Symbol | Status | Description |
|---|---|---|
+ | Success | Test passed |
- | Failure | Assertion failed |
x | Error | Unexpected exception |
~ | Skipped | Test was skipped |
? | Pending | Test not implemented |
! | Cancelled | Setup failed |
A suite that skips or cancels itself in its constructor or beforeAll is not counted as failed.
In CI (when the CI environment variable is set), UniTest on the JVM writes log messages from the thread running a suite (its constructor, hooks, and test bodies) into sbt's output for that suite, so they appear right above the result of the test that logged them. sbt may hold a suite's output until the suite ends, so local runs keep logs going straight to the console, where they show up live. Logs from other threads, such as a server's worker pool or a Future on the global pool, always go to the console. Override the default with a test argument:
Test / testOptions += Tests.Argument("--log-capture") // or "--no-log-capture"Best Practices
- Avoid mocks - Use real implementations or in-memory versions
- Use Design for wiring - Bind test objects with
addDesign/addTestDesignand take them as test arguments; share setup through fixture traits that extendUniTest. See Reusable Fixtures - Prefer
shouldMatch- Use pattern matching for type checks instead ofasInstanceOf - One behavior per test - Keep tests focused on a single behavior
- Descriptive names - Test names should describe the expected behavior
- Use boolean expressions - For comparisons:
(x > 5) shouldBe true - Capture expected errors - Wrap code that fails on purpose in
captureLogsand assert on the warning, instead of lowering log levels. See Testing Error Paths
Cross-Platform Support
UniTest works across all Scala 3 platforms:
| Feature | JVM | Scala.js | Scala Native |
|---|---|---|---|
| Basic assertions | ✓ | ✓ | ✓ |
| Nested tests | ✓ | ✓ | ✓ |
| Flaky tests | ✓ | ✓ | ✓ |
| Property-based testing | ✓ | ✓ | ✓ |
captureLogs from other threads | ✓ | - | ✓ |
| IDE integration | ✓ | - | - |
Package
import wvlet.uni.test.UniTest
// Property-based testing is part of UniTest — nothing else to import.
// For custom generators / shrinkers:
import wvlet.uni.test.check.{Arbitrary, Gen, Shrink, PropertyConfig}