Skip to content

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:

scala
libraryDependencies += "org.wvlet.uni" %% "uni-test" % "2026.1.39" % Test
testFrameworks += new TestFramework("wvlet.uni.test.Framework")

Write your first test:

scala
import wvlet.uni.test.UniTest

class MyTest extends UniTest:
  test("addition works") {
    (1 + 1) shouldBe 2
  }

Run tests:

bash
sbt test

Assertions in UniTest ​

SyntaxMeaning
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 yCheck x == y (supports deep equality for Array, Seq)
x shouldNotBe yCheck x != y
x shouldBe nullCheck x is null
x shouldNotBe nullCheck x is not null
x shouldBe definedCheck x.isDefined == true (Option, Seq, String)
x shouldBe emptyCheck x.isEmpty == true (Option, Seq, String)
x shouldBeTheSameInstanceAs yCheck x eq y (reference equality)
x shouldNotBeTheSameInstanceAs yCheck x ne y (reference inequality)
x shouldMatch { case .. => }Check x matches the pattern
x shouldContain yCheck x contains y
x shouldNotContain yCheck 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 ​

scala
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:

scala
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 ​

SyntaxDescription
a shouldBe bAssert a equals b
a shouldNotBe bAssert a does not equal b
scala
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 ​

scala
test("null checks") {
  val value: String = null
  value shouldBe null

  val nonNull = "hello"
  nonNull shouldNotBe null
}

Defined and Empty ​

SyntaxDescription
a shouldBe definedAssert Option is Some, collection/string is non-empty
a shouldBe emptyAssert 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.

scala
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 ​

SyntaxDescription
collection shouldContain elementAssert collection contains element
collection shouldNotContain elementAssert collection does not contain element
scala
test("collection assertions") {
  val list = List(1, 2, 3)
  list shouldContain 2
  list shouldNotContain 5
}

String Assertions ​

scala
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:

scala
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 ​

scala
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 ​

scala
test("floating point with delta") {
  assertEquals(0.1 + 0.2, 0.3, 0.0001)
  assertEquals(3.14159f, 3.14f, 0.01f)
}

Exception Testing ​

scala
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:

scala
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:

scala
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:

MemberContent
warningsMessages of WARN records, one per line
errorsMessages of ERROR records, one per line
messagesMessages of all records, one per line
recordsThe 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 Future pools. 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 of A and 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 Future or Rx fails, because the async code would run after the capture ends.

captureStdout and captureStderr return what the block printed to Console.out and Console.err:

scala
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 ​

scala
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 ​

scala
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:

scala
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 ​

scala
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 ​

scala
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.

scala
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:

TagNameMeaning
IntegrationintegrationUses real external systems
SmokesmokeQuick sanity check, run as a fast pre-merge subset
SlowslowUsually excluded from the inner-loop run
FlakyflakyA failure is reported as skipped instead of failing the build
UIuiUI-layer test
ElectronelectronElectron-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:

scala
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:

bash
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 --tags narrows the selection: a test must carry every listed tag (AND), like GitHub label filters.
  • --exclude-tags drops 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:

scala
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 addDesign are built once and shared by the tests of the suite. Objects bound with addTestDesign, 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 after after, once a returned Future or Rx completes. Shutting a session down closes the objects it built (AutoCloseable or onShutdown) 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:

HookRunsIf it throws
beforeAllOnce, before the first testThe suite fails and no test runs; afterAll still runs
afterAllOnce, after the last test, also after a failed beforeAllReported as a suite error
beforeBefore each test, including each nested testThe test fails and its body does not run; after still runs
afterAfter each test, once its Future or Rx result completes, also when the test or before failsThe test fails, unless it already failed
scala
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:

scala
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 addDesign and addTestDesign from 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:

scala
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 Future or Rx completes. A loan helper such as withTempDir { dir => ... } closes them as soon as the body returns, which is too early when the body returns a Future.
  • 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.

scala
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:

scala
trait AuditFixture extends UniTest:
  override protected def afterAll: Unit =
    try info("suite finished")
    finally super.afterAll

Property-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.

scala
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.

scala
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:

FactoryProduces
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 / .asciiCharSingle character
Gen.alphaStr / .numStr / .alphaNumStr / .asciiStrSize-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.

scala
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:

scala
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]:

scala
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.

scala
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:

scala
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.

scala
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.

scala
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 × empty

Configuration ​

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.

scala
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
KnobMethodDefault
SampleswithMinSuccessful(n)100
Discard limitwithMaxDiscarded(n)500
Size rangewithSize(min, max)0..100
Shrink budgetwithMaxShrinks(n)1000
SeedwithSeed(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 ​

bash
# 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:

SymbolStatusDescription
+SuccessTest passed
-FailureAssertion failed
xErrorUnexpected exception
~SkippedTest was skipped
?PendingTest not implemented
!CancelledSetup 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:

scala
Test / testOptions += Tests.Argument("--log-capture")    // or "--no-log-capture"

Best Practices ​

  1. Avoid mocks - Use real implementations or in-memory versions
  2. Use Design for wiring - Bind test objects with addDesign / addTestDesign and take them as test arguments; share setup through fixture traits that extend UniTest. See Reusable Fixtures
  3. Prefer shouldMatch - Use pattern matching for type checks instead of asInstanceOf
  4. One behavior per test - Keep tests focused on a single behavior
  5. Descriptive names - Test names should describe the expected behavior
  6. Use boolean expressions - For comparisons: (x > 5) shouldBe true
  7. Capture expected errors - Wrap code that fails on purpose in captureLogs and assert on the warning, instead of lowering log levels. See Testing Error Paths

Cross-Platform Support ​

UniTest works across all Scala 3 platforms:

FeatureJVMScala.jsScala Native
Basic assertions✓✓✓
Nested tests✓✓✓
Flaky tests✓✓✓
Property-based testing✓✓✓
captureLogs from other threads✓-✓
IDE integration✓--

Package ​

scala
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}

Released under the Apache 2.0 License.