Skip to content

Mob.ScreenCase: blessed in-BEAM screen testing (+ contract check, device backend)#44

Merged
GenericJam merged 4 commits into
masterfrom
mob-screen-test
Jun 19, 2026
Merged

Mob.ScreenCase: blessed in-BEAM screen testing (+ contract check, device backend)#44
GenericJam merged 4 commits into
masterfrom
mob-screen-test

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

What

Mob.ScreenCase — the blessed way to unit-test a Mob.Screen in the BEAM, no device or emulator required. The screen-level analog of Phoenix.LiveViewTest, but better positioned because render/1 returns a typed view tree (%{type:, props:, children:}), so assertions are tree queries against real data instead of brittle HTML-string matching.

defmodule MyApp.CounterScreenTest do
  use Mob.ScreenCase, async: true

  test "increment bumps state, text, and stays renderable" do
    view = mount_screen(MyApp.CounterScreen) |> render_event("increment")
    assert assigns(view).count == 1
    assert text(view) =~ "Count: 1"
    assert find(view, :button, tag: "increment")
    assert_renderable(view)
  end
end

The three tiers

  • Tier 1 (drive in-BEAM): mount_screen/3, render_event/3, render_info/2, assigns/1, plus tree queries (tree, find, find_all, text, flatten) whose vocabulary mirrors Mob.Test (the device driver).
  • Tier 2 (contract check): assert_renderable/2 verifies every node type is renderable, derived at compile time from priv/tags/{ios,android}.txt (the same authoritative source the ~MOB sigil validates against) plus :native_view, with an :extra opt for plugin/custom types. Catches "emitted a node the native layer can't draw" at mix test time.
  • Bridge to Tier 3 (device): the View carries a :source (:beam | :device). device_view/1 wraps a running node; tree/1, assigns/1, navigated_to/1 dispatch to Mob.Test over Erlang-distribution RPC. The query + assertion layer is tree-based, so the same assertions run in-BEAM or against hardware; only driving differs. Device examples are @tag :on_device (already auto-excluded).

Plus navigated_to/1: in-BEAM it reads the nav action Mob.Socket.push_screen/3 records; on device it returns the live screen.

It also starts Mob.State per test against a throwaway data dir (like ConnCase starts the Ecto sandbox), since screens commonly read it in mount/3.

Tests

18 pass, 1 excluded (the device example). credo --strict clean. Companion mob_new PR scaffolds one of these from mix mob.new.

Caveat

The device backend reuses the existing, working Mob.Test RPC surface and compiles, but the on-hardware run hasn't been exercised in CI (no connected node). The in-BEAM tiers are fully verified.

🤖 Generated with Claude Code

GenericJam and others added 3 commits June 18, 2026 19:20
Tier-1 screen unit testing, the Phoenix.LiveViewTest analog. `use Mob.ScreenCase`
gives mount_screen/3, render_event/3, render_info/2, assigns/1, plus tree queries
(tree/find/find_all/text/flatten) whose vocabulary mirrors Mob.Test (the device
driver), so the same assertions read identically in-BEAM or, later, on device.

Key difference from LiveView: render returns a typed view tree
(%{type, props, children}), so assertions query real data, not HTML strings.

assert_renderable/2 adds a tier-2 contract check: every node type must be
renderable, derived at compile time from priv/tags/{ios,android}.txt (the same
authoritative source the ~MOB sigil validates against) plus :native_view, with
an :extra opt for plugin/custom types. Catches 'emitted a node the native layer
cannot draw' at mix test time, no device.

15 tests; credo --strict clean. Prototype for review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scaffolding a real test from mob.new surfaced this: the generated home screen
reads the theme from Mob.State in mount/3, and Mob.State is DETS-backed, so
every screen that touches it crashed in mix test with a :dets argument error.
A blessed screen Case should own that runtime, like ConnCase owns the Ecto
sandbox. The setup starts Mob.State per test against a throwaway MOB_DATA_DIR so
screen tests just work and never pollute the app's real dev state. Idempotent
(reuses an already-running Mob.State). Existing 15 tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
navigated_to/1: assert what an event navigated to. In-BEAM it reads the
nav action Mob.Socket.push_screen/3 records on the socket (e.g.
{:push, Dest, params}); on device it returns the live screen via Mob.Test.

Device backend: the View now carries a :source (:beam | :device). device_view/1
wraps a running node, and tree/1, assigns/1, navigated_to/1 dispatch to Mob.Test
over Erlang-distribution RPC. The query + assertion layer (find/text/flatten/
assert_renderable) is tree-based, so the SAME assertions run in-BEAM or against
hardware; only driving differs (render_event/render_info in-BEAM, Mob.Test.tap/
navigate on device). Device example is @tag :on_device (mob's auto-excluded tag).

18 tests pass + 1 excluded device example; the mob.new scaffold still passes
against the updated helper. credo clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rmalize navigated_to

- tree/1 :device clause called Mob.Test.view_tree/1 (native accessibility
  tree, %{type,label,value,frame}) when the query helpers expect the logical
  render tree (%{type,props,children}). Route to Mob.Test.tree/1 instead.
- Add a node-less unit test pinning the :device dispatch: against a down node
  Mob.Test.tree/1 raises BadMapError (it does rpc(...).tree) while view_tree/1
  returns the tuple, so asserting the raise proves the routing without a device.
- navigated_to/1 returned the raw nav tuple ({:push, Dest, params}) on :beam
  but a bare module on :device, breaking the same-assertion promise. Normalize
  destination-bearing actions ({:push,_,_}/{:reset,_,_}/{:pop_to,_}) to the
  destination module; leave destinationless actions unchanged. Update docs/tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GenericJam GenericJam merged commit eb66568 into master Jun 19, 2026
3 checks passed
GenericJam added a commit that referenced this pull request Jun 19, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant