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 }
it "renders the list" do
response = build_controller(described_class, app: app).dispatch(:show)
expect(response).to render_text("Entries")
end
it "navigates to compose on n" do
build_controller(described_class, app: app).dispatch(:show)
expect(press(described_class, "n", app: app)).to navigate_to("/compose")
end
it "deletes through the confirm modal" do
build_controller(described_class, app: app).dispatch(:show)
press_sequence(described_class, %w[d y], app: app)
expect(Entry.count).to eq(0)
end
end
Helpers:
| Helper | Purpose |
|---|---|
build_controller(klass, app:, screen:, route:, event:) | Controller wired to an app (defaults: fresh Application, 80×24 screen). |
key_event("ctrl+p") | Build a KeyEvent from a human-readable string ("q", "down", "shift+tab"). |
press(klass, "q", app:) | Dispatch one key press; returns the Response. |
press_sequence(klass, %w[down down enter], app:) | Dispatch several presses through fresh controllers sharing the app session (mirrors the runtime). |
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("/path") | The response is a navigation to that path. |
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:
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 when testing key, timer, task, or mouse dispatch:
event = Charming::Events::KeyEvent.new(key: :up)
response = described_class.new(application: application, event: event).dispatch_key
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")
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.
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.