Mob.Test (mob v0.5.6)

Copy Markdown View Source

Remote inspection and interaction helpers for connected Mob apps.

All functions accept a node atom and operate on the running screen via Erlang distribution. Connect first with mix mob.connect, then use these from IEx or from an agent via :rpc.call/4.

Quick reference

node = :"my_app_ios@127.0.0.1"

# Inspection
Mob.Test.screen(node)               #=> MyApp.HomeScreen
Mob.Test.assigns(node)              #=> %{count: 3, ...}
Mob.Test.tree(node)                 #=> %{type: :column, ...}
Mob.Test.find(node, "Save")         #=> [{[0, 2], %{...}}]
Mob.Test.inspect(node)              #=> %{screen: ..., assigns: ..., tree: ...}

# Interaction
Mob.Test.tap(node, :increment)      # tap a button by tag
Mob.Test.back(node)                 # system back gesture
Mob.Test.pop(node)                  # pop to previous screen (synchronous)
Mob.Test.navigate(node, MyApp.DetailScreen, %{id: 42})
Mob.Test.pop_to(node, MyApp.HomeScreen)
Mob.Test.pop_to_root(node)

# Lists
Mob.Test.select(node, :my_list, 0)  # select first row

# Device API simulation
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/photo.jpg", width: 1920, height: 1080}})
Mob.Test.send_message(node, {:location, %{lat: 43.65, lon: -79.38, accuracy: 10.0, altitude: 80.0}})
Mob.Test.send_message(node, {:notification, %{id: "n1", title: "Hi", body: "Hey", data: %{}, source: :push}})

Tap vs send_message

tap/2 is for UI interactions that go through handle_event/3 via the native tap registry. send_message/2 delivers any term directly to handle_info/2. Use send_message/2 to simulate async results from device APIs (camera, location, notifications, etc.) without having to trigger the actual hardware.

Synchronous vs fire-and-forget

Navigation functions (pop, navigate, pop_to, pop_to_root) are synchronous — they block until the navigation and re-render complete. This makes them safe to follow immediately with screen/1 or assigns/1 to verify the result.

back/1 and send_message/2 are fire-and-forget (they send a message to the screen process and return immediately). Use :sys.get_state/1 as a sync point if you need to wait before reading state:

Mob.Test.send_message(node, {:permission, :camera, :granted})
:rpc.call(node, :sys, :get_state, [:mob_screen])  # flush mailbox
Mob.Test.assigns(node)

Summary

Functions

Return the current screen's assigns map.

Simulate the system back gesture (Android hardware back / iOS edge-pan).

Clear all text in the focused input (select-all + delete).

Delete one character behind the cursor (backspace).

Find all nodes in the current tree whose text contains substring. Returns a list of {path, node} tuples where path is a list of indices from the root.

Find elements in the native accessibility tree whose label or value contains text.

Return a map with :screen, :assigns, :nav_history, and :tree (the raw render tree from calling render/1 on the current screen).

Press a special key on the focused text input.

Locate an element by visible label text or accessibility ID (tag atom name). Returns the element's screen frame.

Long-press at screen coordinates for duration_ms milliseconds (default 800ms).

Push a new screen onto the navigation stack. Synchronous.

Pop the current screen and return to the previous one. Synchronous.

Pop the stack until dest is at the top. Synchronous.

Pop all screens back to the root of the current stack. Synchronous.

Replace the entire navigation stack with a new root screen. Synchronous.

Return the current screen module.

Select a row in a :list component by index.

Send an arbitrary message to the screen's handle_info/2. Fire-and-forget.

Swipe from (x1, y1) to (x2, y2). Drives UIScrollView contentOffset on simulator; synthesises a drag gesture on real device.

Send a tap event to the current screen by tag atom.

Locate an element and tap it via the simulator's native UI mechanism.

Tap at screen coordinates on the native app. On simulator uses accessibility activation; on real device synthesises a UITouch via IOHIDEvent.

Return the current rendered tree (calls render/1 on the live assigns).

Type text into the currently focused text field.

Return the live accessibility tree from the running native app.

Wait until predicate returns true when called with the current ui_tree, polling every interval_ms until timeout_ms elapses.

Wait until an element whose label or value contains text appears in the accessibility tree.

Functions

assigns(node)

@spec assigns(node()) :: map()

Return the current screen's assigns map.

back(node)

@spec back(node()) :: :ok

Simulate the system back gesture (Android hardware back / iOS edge-pan).

Fire-and-forget. The framework pops the navigation stack; if already at the root, it exits the app. Prefer pop/1 when you need to know that navigation has finished before reading state.

clear_text(node)

@spec clear_text(node()) :: :ok | {:error, atom()}

Clear all text in the focused input (select-all + delete).

delete_backward(node)

@spec delete_backward(node()) :: :ok | {:error, atom()}

Delete one character behind the cursor (backspace).

find(node, substring)

@spec find(node(), String.t()) :: [{list(), map()}]

Find all nodes in the current tree whose text contains substring. Returns a list of {path, node} tuples where path is a list of indices from the root.

Mob.Test.find(node, "Device APIs")
#=> [{[0, 1, 8], %{"type" => "button", "props" => %{"text" => "Device APIs →", ...}}}]

find_native(node, text)

@spec find_native(node(), String.t()) :: list()

Find elements in the native accessibility tree whose label or value contains text.

Mob.Test.find_native(node, "Increment")
#=> [{:button, "Increment", "", {164.0, 400.0, 54.0, 54.0}}]

inspect(node)

@spec inspect(node()) :: map()

Return a map with :screen, :assigns, :nav_history, and :tree (the raw render tree from calling render/1 on the current screen).

key_press(node, key)

@spec key_press(node(), atom()) :: :ok | {:error, atom()}

Press a special key on the focused text input.

Keys: :return | :tab | :escape | :space

Mob.Test.key_press(node, :return)
Mob.Test.key_press(node, :escape)

locate(tag_or_label)

@spec locate(atom() | String.t()) :: {:ok, map()} | {:error, :not_found}

Locate an element by visible label text or accessibility ID (tag atom name). Returns the element's screen frame.

Requires idb (iOS) to be installed.

Mob.Test.locate(:save)
#=> {:ok, %{x: 0.0, y: 412.0, width: 402.0, height: 44.0}}

Mob.Test.locate("Save")
#=> {:ok, %{x: 0.0, y: 412.0, width: 402.0, height: 44.0}}

long_press_xy(node, x, y, duration_ms \\ 800)

@spec long_press_xy(node(), number(), number(), non_neg_integer()) ::
  :ok | {:error, atom()}

Long-press at screen coordinates for duration_ms milliseconds (default 800ms).

Mob.Test.long_press_xy(node, 195.0, 400.0)
Mob.Test.long_press_xy(node, 195.0, 400.0, 1200)

pop(node)

@spec pop(node()) :: :ok

Pop the current screen and return to the previous one. Synchronous.

Returns :ok once the navigation and re-render are complete, so it is safe to call screen/1 or assigns/1 immediately after.

No-op (returns :ok) if already at the root of the stack.

pop_to(node, dest)

@spec pop_to(node(), module() | atom()) :: :ok

Pop the stack until dest is at the top. Synchronous.

dest is a screen module or registered name atom. No-op if not in history.

pop_to_root(node)

@spec pop_to_root(node()) :: :ok

Pop all screens back to the root of the current stack. Synchronous.

reset_to(node, dest, params \\ %{})

@spec reset_to(node(), module() | atom(), map()) :: :ok

Replace the entire navigation stack with a new root screen. Synchronous.

Use this to simulate auth transitions (e.g. login → home with no back button).

screen(node)

@spec screen(node()) :: module()

Return the current screen module.

select(node, list_id, index)

@spec select(node(), atom(), non_neg_integer()) :: :ok

Select a row in a :list component by index.

list_id must match the :id prop on the type: :list node. index is zero-based. Delivers {:select, list_id, index} to handle_info/2.

Fire-and-forget.

Mob.Test.select(node, :my_list, 0)   # first row

send_message(node, message)

@spec send_message(node(), term()) :: :ok

Send an arbitrary message to the screen's handle_info/2. Fire-and-forget.

Use this to simulate results from device APIs without triggering real hardware:

# Permissions
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.send_message(node, {:permission, :notifications, :denied})

# Camera
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/photo.jpg", width: 1920, height: 1080}})
Mob.Test.send_message(node, {:camera, :cancelled})

# Location
Mob.Test.send_message(node, {:location, %{lat: 43.6532, lon: -79.3832, accuracy: 10.0, altitude: 80.0}})
Mob.Test.send_message(node, {:location, :error, :denied})

# Photos / Files
Mob.Test.send_message(node, {:photos, :picked, [%{path: "/tmp/photo.jpg", width: 800, height: 600}]})
Mob.Test.send_message(node, {:files, :picked, [%{path: "/tmp/doc.pdf", name: "doc.pdf", size: 4096}]})

# Audio / Motion / Scanner
Mob.Test.send_message(node, {:audio, :recorded, %{path: "/tmp/audio.aac", duration: 12}})
Mob.Test.send_message(node, {:motion, %{ax: 0.1, ay: 9.8, az: 0.0, gx: 0.0, gy: 0.0, gz: 0.0}})
Mob.Test.send_message(node, {:scan, :result, %{type: :qr, value: "https://example.com"}})

# Notifications
Mob.Test.send_message(node, {:notification, %{id: "n1", title: "Hi", body: "Hello", data: %{}, source: :push}})
Mob.Test.send_message(node, {:push_token, :ios, "abc123def456"})

# Biometric
Mob.Test.send_message(node, {:biometric, :success})
Mob.Test.send_message(node, {:biometric, :failure, :user_cancel})

# Custom
Mob.Test.send_message(node, {:my_event, %{key: "value"}})

swipe(node, x1, y1, x2, y2)

@spec swipe(node(), number(), number(), number(), number()) :: :ok | {:error, atom()}

Swipe from (x1, y1) to (x2, y2). Drives UIScrollView contentOffset on simulator; synthesises a drag gesture on real device.

Mob.Test.swipe(node, 195.0, 500.0, 195.0, 100.0)   # scroll down

tap(node, tag)

@spec tap(node(), atom()) :: :ok

Send a tap event to the current screen by tag atom.

The tag comes from on_tap: {self(), :tag_atom} in the screen's render/1. Check the screen's render function to find available tags.

Fire-and-forget — does not wait for the screen to finish processing.

Mob.Test.tap(node, :save)
Mob.Test.tap(node, :open_detail)

tap_native(tag_or_label)

@spec tap_native(atom() | String.t()) :: :ok | {:error, term()}

Locate an element and tap it via the simulator's native UI mechanism.

Requires idb (iOS) to be installed. Exercises the full native gesture path rather than sending a BEAM message — useful for testing gesture recognizers or verifying that the native layer wired up the tap handler correctly.

Prefer tap/2 for testing Elixir logic; use tap_native/1 when you need the native path.

Mob.Test.tap_native("Save")      # by visible text
Mob.Test.tap_native(:save)       # by accessibility_id (= tag atom name)

tap_xy(node, x, y)

@spec tap_xy(node(), number(), number()) :: :ok | {:error, atom()}

Tap at screen coordinates on the native app. On simulator uses accessibility activation; on real device synthesises a UITouch via IOHIDEvent.

Mob.Test.tap_xy(node, 289.7, 518.8)

tree(node)

@spec tree(node()) :: map()

Return the current rendered tree (calls render/1 on the live assigns).

type_text(node, text)

@spec type_text(node(), String.t()) :: :ok | {:error, atom()}

Type text into the currently focused text field.

Tap the field first to give it focus, then call this function.

Mob.Test.tap_xy(node, 195.0, 300.0)
Process.sleep(100)
Mob.Test.type_text(node, "hello@example.com")

ui_tree(node)

@spec ui_tree(node()) :: list()

Return the live accessibility tree from the running native app.

Each element is a tuple: {type, label, value, {x, y, w, h}}

Mob.Test.ui_tree(node)
#=> [{:button, "Increment", "", {164.0, 400.0, 54.0, 54.0}}, ...]

wait_for(node, predicate, opts \\ [])

@spec wait_for(node(), (list() -> boolean()), keyword()) :: :ok | {:error, :timeout}

Wait until predicate returns true when called with the current ui_tree, polling every interval_ms until timeout_ms elapses.

Mob.Test.wait_for(node, fn tree ->
  Enum.any?(tree, fn {_, label, _, _} -> label == "Success" end)
end)

wait_for_text(node, text, opts \\ [])

@spec wait_for_text(node(), String.t(), keyword()) :: :ok | {:error, :timeout}

Wait until an element whose label or value contains text appears in the accessibility tree.

Mob.Test.wait_for_text(node, "Welcome")
Mob.Test.wait_for_text(node, "Error", timeout_ms: 2000)