Mob.ScreenCase (mob v0.7.15)

Copy Markdown View Source

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
end

What 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_renderable(view_or_tree, opts \\ [])

@spec assert_renderable(
  Mob.ScreenCase.View.t() | map(),
  keyword()
) :: map()

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])

assigns(view)

@spec assigns(Mob.ScreenCase.View.t()) :: map()

The screen's current assigns. Mirrors Mob.Test.assigns/1 (and uses it on device).

device_view(node)

@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

find(view_or_tree, type, props \\ [])

@spec find(Mob.ScreenCase.View.t() | map(), atom(), keyword()) :: map() | nil

The first node matching find_all/3, or nil.

find_all(view_or_tree, type, props \\ [])

@spec find_all(Mob.ScreenCase.View.t() | map(), atom(), keyword()) :: [map()]

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")

flatten(view_or_tree)

@spec flatten(Mob.ScreenCase.View.t() | map()) :: [map()]

Every node in the tree, depth-first. Mirrors Mob.Test.flatten_tree/1.

mount_screen(module, params \\ %{}, session \\ %{})

@spec mount_screen(module(), map(), map()) :: Mob.ScreenCase.View.t()

Mount a screen and return a View handle. Calls Mob.Socket.new/1 then the screen's mount/3, asserting it returns {:ok, socket}.

render_event(view, event, params \\ %{})

@spec render_event(Mob.ScreenCase.View.t(), String.t(), map()) ::
  Mob.ScreenCase.View.t()

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.

render_info(view, message)

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

renderable_types()

@spec renderable_types() :: MapSet.t(atom())

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.

text(view_or_tree)

@spec text(Mob.ScreenCase.View.t() | map()) :: String.t()

Concatenated text of every :text node in the tree, joined by spaces.

tree(node)

@spec tree(Mob.ScreenCase.View.t() | map()) :: map()

The current view tree, from in-BEAM render or the device. Mirrors Mob.Test.tree/1.