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.