Packages
mob
0.7.19
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/screen_case.ex
defmodule Mob.ScreenCase do
@moduledoc """
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.
"""
use ExUnit.CaseTemplate
using do
quote do
import Mob.ScreenCase
end
end
# Many screens read or write `Mob.State` in `mount/3` (the home screen reads
# the theme, for one), and it is DETS-backed, so without it open every such
# screen crashes in `mix test` with a `:dets` argument error. Start it per
# test against a throwaway data dir, the same way ConnCase starts the Ecto
# sandbox, so screen tests just work and never touch the app's real dev state.
setup do
if Process.whereis(Mob.State) == nil do
tmp = Path.join(System.tmp_dir!(), "mob_screen_case_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp)
System.put_env("MOB_DATA_DIR", tmp)
ExUnit.Callbacks.start_supervised!(Mob.State)
ExUnit.Callbacks.on_exit(fn -> File.rm_rf(tmp) end)
end
:ok
end
defmodule View do
@moduledoc """
A screen under test. Two backends, same query/assertion surface:
* `source: :beam` — `module` + `socket`, driven in-process (the default,
built by `mount_screen/3`).
* `source: :device` — a `node` running the app, read over `Mob.Test`'s
Erlang-distribution RPC (built by `device_view/1`, gated behind
`@tag :on_device`).
`tree/1`, `assigns/1`, `find/3`, `text/1`, `assert_renderable/2` and
`navigated_to/1` work against either, so an assertion reads the same whether
it ran here in milliseconds or against real hardware.
"""
@enforce_keys [:source]
defstruct [:source, :module, :socket, :node]
end
# Renderable node types, derived at compile time from the same authoritative
# source the ~MOB sigil validates against (priv/tags/{ios,android}.txt, one
# PascalCase tag per line, converted to the snake_case `:type` atom the same
# way the sigil does). Plus `:native_view`, the runtime-only escape hatch that
# plugin / custom components serialize to and which has no template tag.
@renderable_types (
read = fn name ->
path = Application.app_dir(:mob, "priv/tags/#{name}")
case File.read(path) do
{:ok, body} ->
body
|> String.split("\n", trim: true)
|> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#")))
|> Enum.map(&(&1 |> Macro.underscore() |> String.to_atom()))
_ ->
[]
end
end
(read.("ios.txt") ++ read.("android.txt") ++ [:native_view])
|> MapSet.new()
)
@doc """
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.
"""
@spec renderable_types() :: MapSet.t(atom())
def renderable_types, do: @renderable_types
# ── Driving a screen ───────────────────────────────────────────────────────
@doc """
Mount a screen and return a `View` handle. Calls `Mob.Socket.new/1` then the
screen's `mount/3`, asserting it returns `{:ok, socket}`.
"""
@spec mount_screen(module(), map(), map()) :: View.t()
def mount_screen(module, params \\ %{}, session \\ %{}) when is_atom(module) do
socket = Mob.Socket.new(module)
case module.mount(params, session, socket) do
{:ok, %Mob.Socket{} = socket} ->
%View{source: :beam, module: module, socket: socket}
other ->
raise ArgumentError,
"#{inspect(module)}.mount/3 must return {:ok, socket}, got: #{inspect(other)}"
end
end
@doc """
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
"""
@spec device_view(node()) :: View.t()
def device_view(node) when is_atom(node), do: %View{source: :device, node: node}
@doc """
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_event(View.t(), String.t(), map()) :: View.t()
def render_event(
%View{source: :beam, module: module, socket: socket} = view,
event,
params \\ %{}
)
when is_binary(event) do
{:noreply, socket} = module.handle_event(event, params, socket)
%{view | socket: socket}
end
@doc """
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.
"""
@spec render_info(View.t(), term()) :: View.t()
def render_info(%View{source: :beam, module: module, socket: socket} = view, message) do
{:noreply, socket} = module.handle_info(message, socket)
%{view | socket: socket}
end
@doc "The screen's current assigns. Mirrors `Mob.Test.assigns/1` (and uses it on device)."
@spec assigns(View.t()) :: map()
def assigns(%View{source: :beam, socket: socket}), do: socket.assigns
def assigns(%View{source: :device, node: node}), do: Mob.Test.assigns(node)
@doc """
The screen the last event navigated to, or `nil` if none.
Returns the destination **module** on both backends, so the same assertion
reads identically whether the test ran in-BEAM or against a device:
assert navigated_to(view) == MyApp.CounterScreen
* in-BEAM: the destination of the nav action recorded on the socket by
`Mob.Socket.push_screen/3` and friends. Destination-bearing actions
(`{:push, Dest, _}`, `{:reset, Dest, _}`, `{:pop_to, Dest}`) return
`Dest`; destinationless ones (`{:pop}`, `{:pop_to_root}`,
`{:switch_tab, tab}`) return the raw action unchanged.
* on device: the screen currently showing (`Mob.Test.screen/1`).
"""
@spec navigated_to(View.t()) :: term() | nil
def navigated_to(%View{source: :beam, socket: socket}) do
case Map.get(socket.__mob__, :nav_action) do
{:push, dest, _params} -> dest
{:reset, dest, _params} -> dest
{:pop_to, dest} -> dest
other -> other
end
end
def navigated_to(%View{source: :device, node: node}), do: Mob.Test.screen(node)
# ── Querying the rendered tree ───────────────────────────────────────────────
@doc "The current view tree, from in-BEAM render or the device. Mirrors `Mob.Test.tree/1`."
@spec tree(View.t() | map()) :: map()
def tree(%View{source: :beam, module: module, socket: socket}),
do: module.render(socket.assigns)
def tree(%View{source: :device, node: node}), do: Mob.Test.tree(node)
def tree(%{type: _} = node), do: node
@doc "Every node in the tree, depth-first. Mirrors `Mob.Test.flatten_tree/1`."
@spec flatten(View.t() | map()) :: [map()]
def flatten(view_or_tree), do: do_flatten(tree(view_or_tree))
defp do_flatten(%{type: _} = node) do
children = Map.get(node, :children, []) || []
[node | Enum.flat_map(List.wrap(children), &do_flatten/1)]
end
defp do_flatten(_), do: []
@doc """
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")
"""
@spec find_all(View.t() | map(), atom(), keyword()) :: [map()]
def find_all(view_or_tree, type, props \\ []) when is_atom(type) do
want = Map.new(props)
view_or_tree
|> flatten()
|> Enum.filter(fn node ->
node.type == type and props_match?(Map.get(node, :props, %{}), want)
end)
end
@doc "The first node matching `find_all/3`, or `nil`."
@spec find(View.t() | map(), atom(), keyword()) :: map() | nil
def find(view_or_tree, type, props \\ []) do
view_or_tree |> find_all(type, props) |> List.first()
end
@doc "Concatenated text of every `:text` node in the tree, joined by spaces."
@spec text(View.t() | map()) :: String.t()
def text(view_or_tree) do
view_or_tree
|> find_all(:text)
|> Enum.map(&(&1.props[:text] || ""))
|> Enum.join(" ")
end
defp props_match?(have, want) do
Enum.all?(want, fn {k, v} -> Map.get(have, k) == v end)
end
# ── The native contract check (tier 2) ──────────────────────────────────────
@doc """
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 assert_renderable(View.t() | map(), keyword()) :: map()
def assert_renderable(view_or_tree, opts \\ []) do
tree = tree(view_or_tree)
allowed = MapSet.union(@renderable_types, MapSet.new(Keyword.get(opts, :extra, [])))
offenders =
tree
|> do_flatten()
|> Enum.map(& &1.type)
|> Enum.uniq()
|> Enum.reject(&MapSet.member?(allowed, &1))
if offenders == [] do
tree
else
ExUnit.Assertions.flunk("""
view tree uses node type(s) the native layer cannot render: #{inspect(offenders)}
Renderable types come from mob's priv/tags/{ios,android}.txt (plus :native_view).
If one of these is a plugin or custom component, pass it via
`assert_renderable(view, extra: #{inspect(offenders)})`. Otherwise it is
likely a typo or a component with no registered native renderer.
""")
end
end
end