Module: RatatuiRuby::TestHelper::Snapshot

Included in:
RatatuiRuby::TestHelper
Defined in:
lib/ratatui_ruby/test_helper/snapshot.rb

Overview

Snapshot testing assertions for terminal UIs.

Verifying every character of a TUI screen by hand is tedious. Snapshots let you capture the screen once and compare against it in future runs.

This mixin provides assert_plain_snapshot for plain text, assert_rich_snapshot for styled ANSI output, and assert_snapshots (plural) for both. All auto-create snapshot files on first run.

Use it to verify complex layouts, styles, and interactions without manual assertions.

Snapshot Files

Snapshots live in a snapshots/ subdirectory next to your test file:

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

test/examples/my_app/test_app.rb
test/examples/my_app/snapshots/initial_render.txt
test/examples/my_app/snapshots/initial_render.ansi

– SPDX-SnippetEnd ++

Creating and Updating Snapshots

Run tests with UPDATE_SNAPSHOTS=1 to create or refresh snapshots:

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

UPDATE_SNAPSHOTS=1 bundle exec rake test

– SPDX-SnippetEnd ++

Seeding Random Data

Random data (scatter plots, generated content) breaks snapshot stability. Use a seeded Random instance instead of Kernel.rand:

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

class MyApp
  def initialize(seed: nil)
    @rng = seed ? Random.new(seed) : Random.new
  end

  def generate_data
    (0..20).map { @rng.rand(0.0..10.0) }
  end
end

# In your test
def setup
  @app = MyApp.new(seed: 42)
end

– SPDX-SnippetEnd ++ For libraries like Faker, see their docs on deterministic random: github.com/faker-ruby/faker#deterministic-random

Normalization Blocks

Mask dynamic content (timestamps, IDs) with a normalization block:

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

assert_snapshots("dashboard") do |lines|
  lines.map { |l| l.gsub(/\d{4}-\d{2}-\d{2}/, "YYYY-MM-DD") }
end

– SPDX-SnippetEnd ++

Class-Wide Normalization

When every test in a class faces the same dynamic content, override normalize_snapshots instead of repeating the block. Return a callable (or Array of callables) that transforms lines. The hook runs before any per-call block, so the two compose naturally. See normalize_snapshots for details.

Instance Method Summary collapse

Instance Method Details

#assert_plain_snapshot(name, msg = nil, snapshot_dir: nil) ⇒ Object

Asserts that the current screen content matches a stored plain text snapshot.

Plain text snapshots capture layout but miss styling bugs: wrong colors, missing bold, invisible text on a matching background. *Prefer assert_snapshots* (plural) to catch styling regressions.

Plain text snapshots are human-readable when viewed in any editor or diff tool. They pair well with rich snapshots for documentation. Use assert_snapshots to generate both.

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

assert_plain_snapshot("login_screen")
# Compares against: test/snapshots/login_screen.txt

# With normalization block
assert_plain_snapshot("clock") do |actual|
  actual.map { |l| l.gsub(/\d{2}:\d{2}/, "XX:XX") }
end

– SPDX-SnippetEnd ++

name

String name of the snapshot (without extension).

msg

String optional failure message.



186
187
188
189
190
191
192
# File 'lib/ratatui_ruby/test_helper/snapshot.rb', line 186

def assert_plain_snapshot(name, msg = nil, snapshot_dir: nil, &)
  # Get the path of the test file calling this method
  snapshot_dir ||= File.join(File.dirname(caller_locations(1, 1).first.path), "snapshots")
  snapshot_path = File.join(snapshot_dir, "#{name}.txt")

  assert_screen_matches(snapshot_path, msg, &)
end

#assert_rich_snapshot(name, msg = nil, snapshot_dir: nil) ⇒ Object

Asserts that the current screen content (including colors and styles) matches a stored ANSI snapshot.

TUIs communicate meaning through colors and styles. Rich snapshots capture everything: wrong colors, missing bold, invisible text on a matching background. *Prefer assert_snapshots* (plural) to also generate human-readable plain text files for documentation.

The .ansi snapshot files contain ANSI escape codes. You can cat them in a terminal to see exactly what the screen looked like.

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

assert_rich_snapshot("login_screen")
# Compares against: test/snapshots/login_screen.ansi

# With normalization
assert_rich_snapshot("log_view") do |lines|
  lines.map { |l| l.gsub(/\d{2}:\d{2}:\d{2}/, "HH:MM:SS") }
end

– SPDX-SnippetEnd ++

name

String snapshot name.

msg

String optional failure message.



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/ratatui_ruby/test_helper/snapshot.rb', line 318

def assert_rich_snapshot(name, msg = nil, snapshot_dir: nil)
  snapshot_dir ||= File.join(File.dirname(caller_locations(1, 1).first.path), "snapshots")
  snapshot_path = File.join(snapshot_dir, "#{name}.ansi")

  actual_content = _render_buffer_with_ansi

  lines = normalize_snapshots(actual_content.split("\n"))
  if block_given?
    lines = yield(lines)
  end
  actual_content = "#{lines.join("\n")}\n"

  update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"

  if !File.exist?(snapshot_path) || update_snapshots
    FileUtils.mkdir_p(File.dirname(snapshot_path))

    begin
      # Delete old file first to avoid git index stale-read issues
      FileUtils.rm_f(snapshot_path)

      # Write with explicit mode to ensure clean write
      File.write(snapshot_path, actual_content, mode: "w")
    rescue => e
      warn "Failed to write rich snapshot #{snapshot_path}: #{e.message}"
      raise
    end

    puts (update_snapshots ? "Updated" : "Created") + " rich snapshot: #{snapshot_path}"

  end

  expected_content = File.read(snapshot_path)

  # Compare byte-for-byte first
  if expected_content != actual_content
    # Fallback to line-by-line diff for better error messages
    expected_lines = expected_content.split("\n")
    actual_lines = actual_content.split("\n")

    assert_equal expected_lines.size, actual_lines.size, "#{msg}: Line count mismatch"

    expected_lines.each_with_index do |exp, i|
      act = actual_lines[i]
      assert_equal exp, act, "#{msg}: Rich content mismatch at line #{i + 1}"
    end
  end
end

#assert_screen_matches(expected, msg = nil) ⇒ Object

Asserts that the current screen content matches the expected content.

Users need to verify that the entire TUI screen looks exactly as expected. Manually checking every cell or line is tedious and error-prone.

This helper compares the current buffer content against an expected string (file path) or array of strings. It supports automatic snapshot creation and updating via the UPDATE_SNAPSHOTS environment variable.

Use it to verify complex UI states, layouts, and renderings.

Usage

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

# Direct comparison
assert_screen_matches(["Line 1", "Line 2"])

# File comparison
assert_screen_matches("test/snapshots/login.txt")

# With normalization (e.g., masking dynamic data)
assert_screen_matches("test/snapshots/dashboard.txt") do |lines|
  lines.map { |l| l.gsub(/User ID: \d+/, "User ID: XXX") }
end

– SPDX-SnippetEnd ++

expected

String (file path) or Array<String> (content).

msg

String optional failure message.

Non-Determinism

To prevent flaky tests, this assertion performs a “Flakiness Check” when creating or updating snapshots. It captures the screen content, immediately re-renders the buffer, and compares the two results.

Ensure your render logic is deterministic by seeding random number generators and stubbing time where necessary.



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/ratatui_ruby/test_helper/snapshot.rb', line 238

def assert_screen_matches(expected, msg = nil)
  actual_lines = normalize_snapshots(buffer_content)

  if block_given?
    actual_lines = yield(actual_lines)
  end

  if expected.is_a?(String)
    # Snapshot file mode
    snapshot_path = expected
    update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"

    if !File.exist?(snapshot_path) || update_snapshots
      FileUtils.mkdir_p(File.dirname(snapshot_path))

      content_to_write = "#{actual_lines.join("\n")}\n"

      begin
        # Delete old file first to avoid git index stale-read issues
        FileUtils.rm_f(snapshot_path)

        # Write with explicit mode to ensure clean write
        File.write(snapshot_path, content_to_write, mode: "w")
      rescue => e
        warn "Failed to write snapshot #{snapshot_path}: #{e.message}"
        raise
      end

      if update_snapshots
        puts "Updated snapshot: #{snapshot_path}"
      else
        puts "Created snapshot: #{snapshot_path}"
      end

    end
    expected_lines = File.readlines(snapshot_path, chomp: true)
  else
    # Direct comparison mode
    expected_lines = expected
  end

  msg ||= "Screen content mismatch"

  assert_equal expected_lines.size, actual_lines.size, "#{msg}: Line count mismatch"

  expected_lines.each_with_index do |expected_line, i|
    actual_line = actual_lines[i]
    assert_equal expected_line, actual_line,
      "#{msg}: Line #{i + 1} mismatch.\nExpected: #{expected_line.inspect}\nActual:   #{actual_line.inspect}"
  end
end

#assert_snapshots(name, msg = nil) ⇒ Object

Asserts both plain text and rich (ANSI-styled) snapshots match.

This is the recommended snapshot assertion. It calls both assert_plain_snapshot and assert_rich_snapshot with the same name, generating .txt and .ansi files.

Rich snapshots catch styling bugs that plain text misses. Plain text snapshots are human-readable in any editor or diff tool, making them valuable for documentation and code review. Together, they provide comprehensive coverage and discoverability.

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

assert_snapshots("login_screen")
# Creates/compares: snapshots/login_screen.txt AND snapshots/login_screen.ansi

# With normalization (masks dynamic content like timestamps)
assert_snapshots("dashboard") do |lines|
  lines.map { |l| l.gsub(/\d{2}:\d{2}:\d{2}/, "HH:MM:SS") }
end

– SPDX-SnippetEnd ++

name

String snapshot name (without extension).

msg

String optional failure message.



395
396
397
398
399
# File 'lib/ratatui_ruby/test_helper/snapshot.rb', line 395

def assert_snapshots(name, msg = nil, &)
  snapshot_dir = File.join(File.dirname(caller_locations(1, 1).first.path), "snapshots")
  assert_plain_snapshot(name, msg, snapshot_dir:, &)
  assert_rich_snapshot(name, msg, snapshot_dir:, &)
end

#render_rich_bufferObject

Returns the current buffer content as an ANSI-encoded string.

The rich snapshot assertion captures styled output. Sometimes you need the raw ANSI string for debugging, custom assertions, or programmatic inspection.

This method renders the buffer with escape codes for colors and modifiers. You can ‘cat` the output to see exactly what the terminal would display.

Example

– SPDX-SnippetBegin SPDX-FileCopyrightText: 2026 Kerrick Long SPDX-License-Identifier: MIT-0 ++

with_test_terminal(80, 25) do
  RatatuiRuby.run do |tui|
    tui.draw tui.paragraph(text: "Hello", block: tui.block(title: "Test"))
    break
  end
  ansi_output = render_rich_buffer
  puts ansi_output  # Shows styled output with escape codes
end

– SPDX-SnippetEnd ++



429
430
431
# File 'lib/ratatui_ruby/test_helper/snapshot.rb', line 429

def render_rich_buffer
  _render_buffer_with_ansi
end