Testing

Charming is designed to be tested without a real terminal. Use controller, template, view, and component specs for small units, and use MemoryBackend for runtime-level behavior.

For app structure and rendering concepts, see Core Concepts, Controllers & Views, and Layouts.

Generated Specs

Generated apps include specs for the default state object, controller, template, and component. Run them from the generated app with:

bundle exec rspec

Framework development uses:

bin/rspec
bin/lint
bin/check

Apps generated with --database sqlite3 get a spec_helper that pins CHARMING_ENV=test before the app loads, prepares the test database from db/schema.rb, and rolls back each example in a transaction — your specs run against db/test.sqlite3 in full isolation.

Charming::TestHelper

charming/test_helper is the fastest way to write controller and journey specs. It ships with the framework and registers RSpec matchers when RSpec is loaded:

require "charming/test_helper"

RSpec.describe MyApp::EntriesController do
  include Charming::TestHelper

  let(:app) { MyApp::Application.new }
  let(:ctrl) { build_controller(described_class, app: app) }

  it "renders the list" do
    expect(ctrl.dispatch(:show)).to render_text("Entries")
  end

  it "navigates to compose on n" do
    ctrl.dispatch(:show)
    expect(press(ctrl, "n")).to navigate_to(:compose)
  end

  it "deletes through the confirm modal" do
    ctrl.dispatch(:show)
    press_sequence(ctrl, %w[d y])
    expect(Entry.count).to eq(0)
  end
end

Helpers:

Helper Purpose
build_controller(klass, app:, screen:, route:, params:) One controller instance wired to an app, with screen_entered run (defaults: fresh Application, 80×24 screen).
key_event("ctrl+p") Build a KeyEvent from a human-readable string ("q", "down", "shift+tab").
press(ctrl, "q") Dispatch one key press at the instance; returns the Response.
press_sequence(ctrl, %w[down down enter]) Dispatch several presses at the same instance (mirrors the runtime’s persistent controller).
memory_backend("up", "q", width: 80, height: 24) A MemoryBackend pre-seeded with parsed key events, ready for Charming::Runtime.

Matchers:

Matcher Asserts
render_text("Hello") The response body includes the text — compared ANSI-stripped, so styled output that interleaves escape codes mid-phrase still matches.
render_match(/Count: \d+/) Regex variant, also ANSI-stripped.
navigate_to(:screen_name) The response is a navigation to that screen; pass params to assert them too: navigate_to(:entry, id: 5).
be_quit / be_navigate Predicate matchers on Response.

Journey Specs

Drive the whole app through a real Runtime — actual keystrokes, no TTY:

it "creates an entry end-to-end" do
  keys = ["n", *"Demo day".chars, "enter", "down", "enter", *"It worked.".chars, "ctrl+s", "q"]
  backend = memory_backend(*keys, width: 100, height: 30)

  Charming::Runtime.new(MyApp::Application.new, backend: backend,
    task_executor: Charming::Tasks::InlineExecutor).run

  expect(Entry.find_by(title: "Demo day").body).to eq("It worked.")
  expect(Charming::UI::Width.strip_ansi(backend.frames.last)).to include("Demo day")
end

When a MemoryBackend runs out of events the runtime stops the loop (MemoryBackend#exhausted?), so a forgotten trailing quit event ends the test instead of hanging it.

Controller Specs

Instantiate controllers with an application and dispatch actions directly. Controllers are persistent per screen, so one instance serves every dispatch and instance variables survive across events:

RSpec.describe MyApp::HomeController do
  let(:application) { MyApp::Application.new }

  it "renders the home screen" do
    response = described_class.new(application: application).dispatch(:show)

    expect(response.body).to include("Home")
  end
end

Pass events to the dispatch methods when testing key, timer, task, or mouse dispatch:

controller = described_class.new(application: application)
event = Charming::Events::KeyEvent.new(key: :up)
response = controller.dispatch_key(event)

Route params can be passed directly for controller-level tests:

controller = described_class.new(application: application, params: {id: "123"})
expect(controller.dispatch(:show).body).to include("123")

Template Specs

Resolve and render templates directly when testing template output:

template = Charming::Templates.resolve("home/show", root: app_root)
view = Charming::TemplateView.new(
  template: template,
  home: double(title: "Home"),
  theme: Charming::UI::Theme.default
)

expect(view.render).to include("Home")

Templates can use normal view helpers. Strip ANSI codes when assertions do not need styling:

body = Charming::UI::Width.strip_ansi(view.render)
expect(body).to include("Home")

Controller tests can cover template rendering through render :show:

response = MyApp::HomeController.new(application: application).dispatch(:show)

expect(response.body).to include("Home")

Class-Based View Specs

Class-based views are plain objects. Pass assigns to new and call render:

view = MyApp::HomeView.new(
  home: double(title: "Home"),
  theme: Charming::UI::Theme.default
)

expect(view.render).to include("Home")

Rendering is pure: a view’s screen_layout does not mutate the controller. It returns registration data instead — the focusable pane names and mouse targets the frame implies. Use render_view from Charming::TestHelper to assert on the frame and the registrations together, with no controller:

include Charming::TestHelper

result = render_view(MyApp::HomeView, screen: Charming::Screen.new(width: 40, height: 10))

expect(result[:frame]).to include("Home")
expect(result[:focus_slots]).to eq([:entries])
expect(result[:mouse_targets].map { |target| target[:name] }).to eq([:entries])

The runtime commits these registrations when the response paints. A dispatch that raises mid-render commits nothing — the previous frame’s focus and mouse registrations stay live.

Component Specs

Render components directly:

component = Charming::Components::List.new(items: %w[One Two])

expect(component.render).to include("One")

For interactive components, assert return values and state changes:

input = Charming::Components::TextInput.new

expect(input.handle_key(Charming::Events::KeyEvent.new(key: :a, char: "a"))).to eq(:handled)
expect(input.value).to eq("a")

Runtime Specs

Use MemoryBackend to script terminal events and capture rendered frames:

backend = Charming::Internal::Terminal::MemoryBackend.new(
  events: [
    Charming::Events::KeyEvent.new(key: :up),
    Charming::Events::KeyEvent.new(key: :q)
  ],
  width: 80,
  height: 24
)

Charming::Runtime.new(MyApp::Application.new, backend: backend).run

expect(backend.frames).to eq(["Count: 0", "Count: 1"])

MemoryBackend accepts events:, width:, and height: keyword args. After running, inspect backend.frames to assert against rendered terminal frames.

Timer Specs

Inject a deterministic clock into the runtime:

times = [0.0, 0.0, 0.0, 0.1, 0.2]
clock = -> { times.shift || 0.2 }

runtime = Charming::Runtime.new(app, backend: backend, clock: clock)
runtime.run

This avoids sleeps and makes timer behavior deterministic.

For animation timers (Animation), an incrementing clock (t = 0.0; clock = -> { t += 1.0 / 120 }) is easier than a scripted array. To prove an animation stops itself, follow the motion with nil input events while the clock keeps advancing — backend.frames must gain no new frames after the settle frame.

Task Specs

Use the inline task executor for deterministic async task tests — it runs blocks synchronously and queues results (and progress events) immediately:

runtime = Charming::Runtime.new(
  app,
  backend: backend,
  task_executor: Charming::Tasks::InlineExecutor
)
runtime.run

Controller-level task tests can stub the app task executor. The contract is submit(name, timeout: nil, &block) — the keyword is only passed when the caller sets a timeout, so the simple signature works until you test timeouts:

executor = Class.new do
  attr_reader :name

  def submit(name, timeout: nil, &)
    @name = name
  end
end.new

application.task_executor = executor
controller.dispatch(:refresh)

expect(executor.name).to eq(:refresh_home)

Progress assertions: drain the queue and inspect TaskProgressEvents (current, total, message, fraction) ahead of the final TaskEvent.

Renderer Specs

For renderer-level tests, pass a custom renderer: to Charming::Runtime.new or test renderer classes directly with MemoryBackend.

Backend and renderer classes under Charming::Internal are mostly test-facing implementation details. Prefer MemoryBackend for app and framework specs unless you specifically need TTY integration behavior.

Snapshot-Style Assertions

For rendered terminal output, prefer small, stable assertions first. Use full-frame comparisons when the output is intentionally fixed.

Good:

body = Charming::UI::Width.strip_ansi(response.body)
expect(body).to include("Status: Loaded")

Use exact frame assertions for runtime flow:

expect(backend.frames).to eq(["Home", "Settings"])

Avoid tests that only assert a response exists unless the behavior under test is dispatch plumbing.


This site uses Just the Docs, a documentation theme for Jekyll.