Replace munit with zio-test #12

Merged
rleibman merged 3 commits from chore/zio-test into main 2026-09-29 14:44:47 -07:00
Owner

munit is gone from the build and from every source. munit-scalacheck went with it, because property testing comes with zio-test rather than as a second dependency.

Target Tests
signalsJVM / signalsNative 21
signalsJS 19 — no ThreadGuardSpec, JS has no threads
coreJVM / coreJS / coreNative 73
effectZioJVM 15
shimGen 10

Core's 73 is exactly the 73 that existed before, so nothing was quietly dropped in the conversion.

What the migration found

The reason to read this PR rather than skim it: three latent problems fell out, none of them guessed at.

1. The signal graph's tracking context is two process-global vars. zio-test runs a suite's tests in parallel by default; munit does not. That broke the graph on the very first run — NullPointerException on Runtime.collected, ConcurrentModificationException inside relink. This is not a bug: the graph is owned by exactly one thread, which ThreadGuard enforces in a running app, and the globals are deliberate because an Option allocation per tracked read is measurable against the 1 µs/node budget. But nothing in the test suite said so, and munit had been hiding it behind parallelExecution := false. Every graph-touching spec now carries @@ TestAspect.sequential with the reason written down.

2. TestAspect.sequential orders tests within a spec and promises nothing between specs, which zio-test runs concurrently. BridgeSpec and RemoteScreenSpec both install UiThread and ThreadGuard; they raced, and RemoteScreen's error test timed out waiting for a state the other suite's fibres were competing for. Suites sharing process-global state cannot be separate specs — they are now one, EffectZioSpec.

3. Phase 2 shipped a test that only ever passed on the JVM. Scala.js has no int/double distinction, so 7.0.toString is "7" there and "7.0" on the JVM. CatalogueSuite compared rendered strings for Prop.Value, Prop.Range and Prop.Progress — and had never been run on JS or Native, where it fails. TestRenderer now keeps numeric props in a separate nums: Map[String, Double] instead of stringifying them.

That third one is a process failure as much as a bug: I had only ever run coreJVM/testOnly. The rule is now written down in docs/12 §12.8 — running one backend is not running the tests.

Mechanics worth a look in review

  • Checks accumulates assertions made during a test. These tests drive a mutable graph and check it after each step, because the interesting property is usually how many times something happened between two writes, which the end state cannot show. Restructuring every test around a single final assertion would have discarded exactly that.
  • munit's beforeEach/afterEach became TestAspect.before/after, which has the advantage of being visible at the suite's declaration rather than buried in an override.
  • shim-gen has no signals dependency, so rather than duplicate Checks, its two specs collect every mismatch and assert once. That is better regardless: a drifting ABI usually drifts in several places, and a check that stops at the first makes you run it N times to learn how bad it is.
  • test is ambiguous under both import zio.* (the package zio.test) and import zio.test.* (the method). Scala 3 treats the wildcards as equal precedence whichever order they appear in, so RemoteScreenTests qualifies its call sites.
  • zio-test-sbt for Native is built against scala-native test-interface 0.5.10 against this build's 0.5.12. Binary compatible across 0.5.x patches, so one narrow libraryDependencySchemes entry says so rather than disabling eviction errors build-wide.

Also in this branch

  • docs/12 §12.8 documents how tests are run and the three rules above.
  • decisions.md gains two entries: never stringify a number in a cross-built test, and sbt 2's disk cache does not notice a deleted test source — renaming a suite left nativeLink failing on the old name through clean, through deleting target/out, and through touching sources. Clearing ~/.cache/sbt/v2 and target/out/<platform> is what fixes it. That cost me twenty minutes twice.
  • Both CLAUDE.md files gain the standing rule that I never merge a PR. Unrelated to the migration, and swept into the same commit — say the word if you would rather it were split out.

🤖 Generated with Claude Code

munit is gone from the build and from every source. `munit-scalacheck` went with it, because property testing comes with zio-test rather than as a second dependency. | Target | Tests | |---|---| | `signalsJVM` / `signalsNative` | 21 | | `signalsJS` | 19 — no `ThreadGuardSpec`, JS has no threads | | `coreJVM` / `coreJS` / `coreNative` | 73 | | `effectZioJVM` | 15 | | `shimGen` | 10 | Core's 73 is exactly the 73 that existed before, so nothing was quietly dropped in the conversion. ## What the migration found The reason to read this PR rather than skim it: three latent problems fell out, none of them guessed at. **1. The signal graph's tracking context is two process-global `var`s.** zio-test runs a suite's tests in parallel by default; munit does not. That broke the graph on the very first run — `NullPointerException` on `Runtime.collected`, `ConcurrentModificationException` inside `relink`. This is not a bug: the graph is owned by exactly one thread, which `ThreadGuard` enforces in a running app, and the globals are deliberate because an `Option` allocation per tracked read is measurable against the 1 µs/node budget. But **nothing in the test suite said so**, and munit had been hiding it behind `parallelExecution := false`. Every graph-touching spec now carries `@@ TestAspect.sequential` with the reason written down. **2. `TestAspect.sequential` orders tests *within* a spec and promises nothing *between* specs**, which zio-test runs concurrently. `BridgeSpec` and `RemoteScreenSpec` both install `UiThread` and `ThreadGuard`; they raced, and `RemoteScreen`'s error test timed out waiting for a state the other suite's fibres were competing for. Suites sharing process-global state **cannot be separate specs** — they are now one, `EffectZioSpec`. **3. Phase 2 shipped a test that only ever passed on the JVM.** Scala.js has no int/double distinction, so `7.0.toString` is `"7"` there and `"7.0"` on the JVM. `CatalogueSuite` compared *rendered strings* for `Prop.Value`, `Prop.Range` and `Prop.Progress` — and had never been run on JS or Native, where it fails. `TestRenderer` now keeps numeric props in a separate `nums: Map[String, Double]` instead of stringifying them. That third one is a process failure as much as a bug: I had only ever run `coreJVM/testOnly`. The rule is now written down in `docs/12` §12.8 — **running one backend is not running the tests.** ## Mechanics worth a look in review - **`Checks`** accumulates assertions made *during* a test. These tests drive a mutable graph and check it after each step, because the interesting property is usually *how many times* something happened between two writes, which the end state cannot show. Restructuring every test around a single final assertion would have discarded exactly that. - munit's `beforeEach`/`afterEach` became `TestAspect.before`/`after`, which has the advantage of being visible at the suite's declaration rather than buried in an override. - **shim-gen has no `signals` dependency**, so rather than duplicate `Checks`, its two specs collect every mismatch and assert once. That is better regardless: a drifting ABI usually drifts in several places, and a check that stops at the first makes you run it N times to learn how bad it is. - `test` is ambiguous under both `import zio.*` (the *package* `zio.test`) and `import zio.test.*` (the *method*). Scala 3 treats the wildcards as equal precedence whichever order they appear in, so `RemoteScreenTests` qualifies its call sites. - zio-test-sbt for Native is built against scala-native `test-interface` 0.5.10 against this build's 0.5.12. Binary compatible across 0.5.x patches, so one narrow `libraryDependencySchemes` entry says so rather than disabling eviction errors build-wide. ## Also in this branch - `docs/12` §12.8 documents how tests are run and the three rules above. - `decisions.md` gains two entries: never stringify a number in a cross-built test, and **sbt 2's disk cache does not notice a deleted test source** — renaming a suite left `nativeLink` failing on the old name through `clean`, through deleting `target/out`, and through touching sources. Clearing `~/.cache/sbt/v2` *and* `target/out/<platform>` is what fixes it. That cost me twenty minutes twice. - Both `CLAUDE.md` files gain the standing rule that I never merge a PR. Unrelated to the migration, and swept into the same commit — say the word if you would rather it were split out. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
First module off munit. zio-test publishes for JVM, JS and Native at 2.1.26,
the version already pinned here, so the portability objection I expected to
make does not exist: 21 tests on JVM and Native, 19 on JS (no ThreadGuardSpec
there - JS has no threads).

The migration paid for itself on the first run. zio-test runs a suite's tests
in parallel by default; munit does not. That broke the signal graph
immediately - NullPointerException on Runtime.collected, and a
ConcurrentModificationException inside relink - because the dependency
tracking context is two process-global vars, deliberately, since an Option
allocation per tracked read is measurable against the 1 us/node budget.

That is not a bug: the graph is owned by exactly one thread, which is what
ThreadGuard enforces in a running app. But it was an assumption nothing in
the test suite stated, and munit hid it behind parallelExecution := false and
sequential-within-suite execution. Every spec that touches the graph now
carries @@ TestAspect.sequential with the reason written down.

Mechanics:

- Checks accumulates assertions made *during* a test. These tests drive a
  mutable graph and check it after each step, because the interesting
  property is usually how many times something happened between two writes,
  which the end state cannot show. Restructuring them around a single final
  assertion would have thrown that away.
- GraphPropertySuite becomes GraphPropertySpec on zio-test's Gen, dropping
  munit-scalacheck: property testing comes with zio-test rather than as a
  second dependency.
- zio-test-sbt for Native is built against scala-native test-interface
  0.5.10 against this build's 0.5.12. Binary compatible across 0.5.x patches,
  so a narrow libraryDependencySchemes entry says so rather than turning
  eviction errors off build-wide.
- signals no longer depends on munit at all. core gains a test->test
  dependency on it so the Checks helper is shared rather than copied.

Also recorded in decisions.md: sbt 2's disk cache does not notice a deleted
test source. Renaming a suite left nativeLink failing on the old name through
clean, through deleting target/out, and through touching sources. Clearing
~/.cache/sbt/v2 *and* target/out/<platform> is what fixes it.

Still on munit: core (9 suites), effect-zio (2), shim-gen (2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
munit is gone from the build and from every source; munit-scalacheck went
with it, because property testing comes with zio-test rather than as a second
dependency.

  signalsJVM/signalsNative  21    coreJVM/coreJS/coreNative  73
  signalsJS                 19    effectZioJVM               15
                                  shimGen                    10

Core's 73 is exactly the 73 that existed before, so nothing was quietly
dropped in the conversion.

Two more things the migration found, on top of the global-tracking-context
problem the signals commit described:

1. TestAspect.sequential orders tests *within* a spec and promises nothing
   *between* specs, which zio-test runs concurrently. BridgeSpec and
   RemoteScreenSpec both install UiThread and ThreadGuard, raced, and
   RemoteScreen's error test timed out waiting for a state the other suite's
   fibres were competing for. Suites sharing process-global state cannot be
   separate specs; they are now one, EffectZioSpec, and sequential finally
   covers them. munit never showed this because it runs test classes one at a
   time under parallelExecution := false.

2. Phase 2 shipped a test that only ever passed on the JVM. Scala.js has no
   int/double distinction, so 7.0.toString is "7" there and "7.0" on the JVM,
   and CatalogueSuite compared rendered strings for Value, Range and
   Progress. It had never been run on JS or Native. TestRenderer now keeps
   numeric props in a separate nums: Map[String, Double] rather than
   stringifying them. The process lesson is the larger one and is written
   down in docs/12 section 12.8: running coreJVM/testOnly is not running the
   tests.

Mechanics worth knowing:

- Checks accumulates assertions made during a test. These tests drive a
  mutable graph and check it after each step, because the interesting
  property is usually how many times something happened between two writes,
  which the end state cannot show. Restructuring around a single final
  assertion would have discarded exactly that.
- munit's beforeEach/afterEach became TestAspect.before/after, which has the
  advantage of being visible at the suite declaration rather than buried in
  an override.
- shim-gen has no signals dependency, so rather than duplicate Checks its two
  specs collect all mismatches and assert once. That is better anyway: a
  drifting ABI usually drifts in several places, and a check that stops at
  the first makes you run it N times to learn how bad it is.
- `test` is ambiguous under both `import zio.*` and `import zio.test.*` -
  Scala 3 treats the wildcards as equal precedence whichever order they
  appear in - so RemoteScreenTests qualifies its call sites.

Also in decisions.md: never stringify a number in a cross-built test, and
sbt 2's disk cache does not notice a deleted test source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict was documentation only — both sides appended to the decision log.
Kept both, Apple's entries first since they landed on main first.

Checked the thing that could have gone wrong silently: kind codes. Phase 2
reserved 9-14 in AppleRenderer for the widgets the shim has not built yet,
and #11's commit message says the horizontal Scroll "travels as kind code 9".
The code does not — it uses 15, and the header documents 9-14 as reserved.
Swift, C header and Scala agree. The commit message is wrong, the code is
right; noting it here so nobody trusts the message later.

rendererApple/compile is green after the merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rleibman referenced this pull request from a commit 2026-09-29 14:44:48 -07:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rleibman/scala-ui!12
No description provided.