The blessed way to unit-test a Mob.Screen in the BEAM, no device or
emulator required. The screen-level analog of Phoenix.LiveViewTest.
A Mob.Screen is a GenServer-shaped module: mount/3 builds state,
handle_event/3 and handle_info/2 mutate it, and render/1 turns the
assigns into a view tree (plain data: %{type:, props:, children:}). That
last part is the key difference from LiveView: the screen produces a typed
data structure, not an HTML string, so assertions are tree queries against
real data instead of brittle string matching.
This module drives those callbacks directly (the same thing the on-device
runtime does) and gives you query helpers whose vocabulary matches Mob.Test
(the device-side driver): assigns/1, tree/1, find/3, flatten/1. So a
test reads the same whether it runs here in milliseconds or, later, against a
real device.
defmodule MyApp.CounterScreenTest do
use Mob.ScreenCase
test "increment bumps the count and the rendered text" do
view = mount_screen(MyApp.CounterScreen)
assert assigns(view).count == 0
view = render_event(view, "increment")
assert assigns(view).count == 1
assert text(view) =~ "Count: 1"
assert find(view, :button, tag: "increment")
# cheap native-contract check: every node the screen emits is a
# type the Compose / SwiftUI layer actually renders.
assert_renderable(view)
end
endWhat this does and does not catch
This is tier 1 of the testing pyramid: it exercises logic, state, and the
shape of the view tree, fast and deterministically. assert_renderable/2
adds a tier-2 contract check (does the tree only use renderable node
types). Neither runs the native layer, so they cannot catch a node that
renders wrong or behaves wrong on a real iOS/Android build. That needs a
device test driven through Mob.Test. Weight your suite heavily toward this
module, with a thin band of device tests for the things only hardware proves.
Summary
Functions
Assert every node in the tree is a type the native layer can render. Returns the tree on success so it composes; flunks (with the offending types) if a node uses a type that has no Compose / SwiftUI renderer.
The screen's current assigns. Mirrors Mob.Test.assigns/1 (and uses it on device).
Wrap a running device node as a View so the query/assertion helpers read
it over Mob.Test's Erlang-distribution RPC. The device-backed counterpart to
mount_screen/3. Use behind @tag :on_device; get the node from
mix mob.connect / Mob.Test. Driving (navigate, tap) stays on Mob.Test;
this is for asserting against the live screen with the same helpers.
The first node matching find_all/3, or nil.
All nodes of type whose props are a superset of props. Mirrors
Mob.Test.find/2, but matches on the typed tree rather than a substring.
Every node in the tree, depth-first. Mirrors Mob.Test.flatten_tree/1.
Mount a screen and return a View handle. Calls Mob.Socket.new/1 then the
screen's mount/3, asserting it returns {:ok, socket}.
The screen the last event navigated to, or nil if none.
Dispatch a handle_event/3 (the explicit-event style) and return the updated
View. In-BEAM only; on a device, drive with Mob.Test.tap/2.
Deliver a message to the screen's handle_info/2 and return the updated
View. This is how taps reach a screen on device (a Button's on_tap
sends a message), so it is the in-BEAM equivalent of a tap. In-BEAM only.
The set of node types the native layer can render: the core component tags
plus :native_view. The contract surface assert_renderable/2 checks against.
Concatenated text of every :text node in the tree, joined by spaces.
The current view tree, from in-BEAM render or the device. Mirrors Mob.Test.tree/1.
Functions
Assert every node in the tree is a type the native layer can render. Returns the tree on success so it composes; flunks (with the offending types) if a node uses a type that has no Compose / SwiftUI renderer.
This catches the "you emitted a node the native side can't draw" class of bug
at mix test time, no device needed. Pass extra types a plugin or your own
app registers via :extra:
assert_renderable(view, extra: [:gauge])
@spec assigns(Mob.ScreenCase.View.t()) :: map()
The screen's current assigns. Mirrors Mob.Test.assigns/1 (and uses it on device).
@spec device_view(node()) :: Mob.ScreenCase.View.t()
Wrap a running device node as a View so the query/assertion helpers read
it over Mob.Test's Erlang-distribution RPC. The device-backed counterpart to
mount_screen/3. Use behind @tag :on_device; get the node from
mix mob.connect / Mob.Test. Driving (navigate, tap) stays on Mob.Test;
this is for asserting against the live screen with the same helpers.
@tag :on_device
test "the live home screen is renderable" do
node = :"my_app_android@127.0.0.1"
Mob.Test.navigate(node, MyApp.HomeScreen)
view = device_view(node)
assert_renderable(view)
assert navigated_to(view) == MyApp.HomeScreen
end
The first node matching find_all/3, or nil.
All nodes of type whose props are a superset of props. Mirrors
Mob.Test.find/2, but matches on the typed tree rather than a substring.
find_all(view, :button, tag: "increment")
Every node in the tree, depth-first. Mirrors Mob.Test.flatten_tree/1.
Mount a screen and return a View handle. Calls Mob.Socket.new/1 then the
screen's mount/3, asserting it returns {:ok, socket}.
Dispatch a handle_event/3 (the explicit-event style) and return the updated
View. In-BEAM only; on a device, drive with Mob.Test.tap/2.
@spec render_info(Mob.ScreenCase.View.t(), term()) :: Mob.ScreenCase.View.t()
Deliver a message to the screen's handle_info/2 and return the updated
View. This is how taps reach a screen on device (a Button's on_tap
sends a message), so it is the in-BEAM equivalent of a tap. In-BEAM only.
The set of node types the native layer can render: the core component tags
plus :native_view. The contract surface assert_renderable/2 checks against.
Concatenated text of every :text node in the tree, joined by spaces.
The current view tree, from in-BEAM render or the device. Mirrors Mob.Test.tree/1.