syntaxis/shared/rust-tui/TESTING_GUIDE.md
Jesús Pérez 9cef9b8d57 refactor: consolidate configuration directories
Merge _configs/ into config/ for single configuration directory.
Update all path references.

Changes:
- Move _configs/* to config/
- Update .gitignore for new patterns
- No code references to _configs/ found

Impact: -1 root directory (layout_conventions.md compliance)
2025-12-26 18:36:23 +00:00

19 KiB

TUI Testing Guide

Comprehensive testing guide for RataTui-based TUI applications in the Tools ecosystem.

Table of Contents

  1. Testing Philosophy
  2. Unit Testing Patterns
  3. Integration Testing
  4. Test Organization
  5. Mocking Strategies
  6. Coverage Requirements
  7. Common Testing Scenarios
  8. Performance Testing

Testing Philosophy

Core Principles

  1. Separation of Concerns: Test state separately from rendering

    • State logic (app.rs) can be tested without TUI framework
    • Rendering (screens) is harder to test but can be verified through state
  2. Minimum 15+ Tests Per Module: Comprehensive coverage

    • Unit tests in module files
    • Integration tests in tests/ directory
    • Focus on critical paths and edge cases
  3. Deterministic Tests: No flakiness allowed

    • Use fixed values, not system time
    • No random data or timing dependencies
    • Tests must pass 100% of the time
  4. Fast Tests: Keep test suite quick

    • Avoid I/O when possible
    • Use in-memory structures
    • Run in parallel (--test-threads=8)

Unit Testing Patterns

Pattern 1: Testing Application State

File: src/app.rs (inline tests)

#[cfg(test)]
mod tests {
    use super::*;

    // === Initialization Tests ===

    #[test]
    fn test_app_state_new() {
        let state = AppState::new();

        // Verify initial state
        assert_eq!(state.current_screen, ScreenType::List);
        assert!(state.items.is_empty());
        assert_eq!(state.selected_index, 0);
        assert!(state.dirty);  // Should be dirty initially
        assert!(!state.should_quit);
        assert!(state.form_name.is_empty());
    }

    #[test]
    fn test_app_state_default() {
        // Default should equal new()
        assert_eq!(AppState::default(), AppState::new());
    }

    // === Item Management Tests ===

    #[test]
    fn test_add_single_item() {
        let mut state = AppState::new();
        assert!(state.items.is_empty());

        state.add_item("Item 1".to_string());

        assert_eq!(state.items.len(), 1);
        assert!(state.dirty);
    }

    #[test]
    fn test_add_multiple_items() {
        let mut state = AppState::new();

        state.add_item("Item 1".to_string());
        state.add_item("Item 2".to_string());
        state.add_item("Item 3".to_string());

        assert_eq!(state.items.len(), 3);
        assert_eq!(state.items[0], "Item 1");
        assert_eq!(state.items[2], "Item 3");
    }

    #[test]
    fn test_selected_item_empty() {
        let state = AppState::new();
        assert!(state.selected_item().is_none());
    }

    #[test]
    fn test_selected_item_with_selection() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());

        assert_eq!(state.selected_item(), Some(&"Item 1".to_string()));
    }

    // === Navigation Tests ===

    #[test]
    fn test_select_next_single_item() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());
        state.reset_dirty();

        state.select_next();

        // Wraps around with single item
        assert_eq!(state.selected_index, 0);
        assert!(state.dirty);
    }

    #[test]
    fn test_select_next_multiple_items() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());
        state.add_item("Item 2".to_string());
        state.add_item("Item 3".to_string());
        state.reset_dirty();

        state.select_next();
        assert_eq!(state.selected_index, 1);
        assert!(state.dirty);

        state.select_next();
        assert_eq!(state.selected_index, 2);
    }

    #[test]
    fn test_select_next_wraps_around() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());
        state.add_item("Item 2".to_string());
        state.selected_index = 1;  // Last item
        state.reset_dirty();

        state.select_next();

        assert_eq!(state.selected_index, 0);  // Wraps to first
        assert!(state.dirty);
    }

    #[test]
    fn test_select_previous() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());
        state.add_item("Item 2".to_string());
        state.selected_index = 1;
        state.reset_dirty();

        state.select_previous();

        assert_eq!(state.selected_index, 0);
        assert!(state.dirty);
    }

    #[test]
    fn test_select_previous_from_first_wraps() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());
        state.add_item("Item 2".to_string());
        state.selected_index = 0;
        state.reset_dirty();

        state.select_previous();

        assert_eq!(state.selected_index, 1);  // Wraps to last
        assert!(state.dirty);
    }

    #[test]
    fn test_select_on_empty_list_no_panic() {
        let mut state = AppState::new();
        // No panic should occur on empty list
        state.select_next();
        state.select_previous();
        assert_eq!(state.selected_index, 0);
    }

    // === Screen Navigation Tests ===

    #[test]
    fn test_set_screen_changes_screen() {
        let mut state = AppState::new();
        assert_eq!(state.current_screen, ScreenType::List);
        state.reset_dirty();

        state.set_screen(ScreenType::Create);

        assert_eq!(state.current_screen, ScreenType::Create);
        assert!(state.dirty);
    }

    #[test]
    fn test_set_screen_same_screen_not_dirty() {
        let mut state = AppState::new();
        state.set_screen(ScreenType::List);
        state.reset_dirty();

        // Setting to same screen
        state.set_screen(ScreenType::List);

        // Should NOT mark dirty (optimization)
        assert!(!state.dirty);
    }

    // === Form Management Tests ===

    #[test]
    fn test_reset_form_clears_fields() {
        let mut state = AppState::new();
        state.form_name = "Test Name".to_string();
        state.form_description = "Test Description".to_string();
        state.reset_dirty();

        state.reset_form();

        assert!(state.form_name.is_empty());
        assert!(state.form_description.is_empty());
        assert!(state.dirty);
    }

    // === Dirty Flag Tests ===

    #[test]
    fn test_dirty_flag_initially_true() {
        let state = AppState::new();
        assert!(state.is_dirty());
    }

    #[test]
    fn test_dirty_flag_reset() {
        let mut state = AppState::new();
        assert!(state.is_dirty());

        state.reset_dirty();

        assert!(!state.is_dirty());
    }

    #[test]
    fn test_dirty_flag_after_operations() {
        let mut state = AppState::new();
        state.reset_dirty();
        assert!(!state.is_dirty());

        // Any mutation should mark dirty
        state.add_item("Item".to_string());
        assert!(state.is_dirty());

        state.reset_dirty();
        state.select_next();
        assert!(state.is_dirty());
    }

    // === Lifecycle Tests ===

    #[test]
    fn test_request_quit() {
        let mut state = AppState::new();
        assert!(!state.should_quit());

        state.request_quit();

        assert!(state.should_quit());
    }
}

Pattern 2: Testing Screen Rendering

File: src/screens/list.rs (inline tests)

Note: Rendering tests verify state interpretation, not visual output.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_list_screen_empty_state() {
        let state = AppState::new();

        // Verify state for empty rendering
        assert!(state.items.is_empty());
        // Empty state should be handled gracefully
    }

    #[test]
    fn test_list_screen_with_items() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());
        state.add_item("Item 2".to_string());

        assert_eq!(state.items.len(), 2);
        assert_eq!(state.selected_index, 0);
    }

    #[test]
    fn test_list_screen_selected_index_valid() {
        let mut state = AppState::new();
        state.add_item("Item 1".to_string());
        state.add_item("Item 2".to_string());

        // Verify selected item exists
        assert!(state.selected_item().is_some());
        assert_eq!(state.selected_item(), Some(&"Item 1".to_string()));

        state.select_next();
        assert_eq!(state.selected_item(), Some(&"Item 2".to_string()));
    }
}

Integration Testing

Pattern 3: Full Workflow Tests

File: tests/integration_tests.rs

//! Integration tests - test complete workflows

use app_name::{AppState, ScreenType};

#[test]
fn test_complete_list_workflow() {
    // Create state
    let mut state = AppState::new();
    assert_eq!(state.current_screen, ScreenType::List);

    // Add items
    state.add_item("Item 1".to_string());
    state.add_item("Item 2".to_string());
    state.add_item("Item 3".to_string());
    assert_eq!(state.items.len(), 3);

    // Navigate through items
    state.reset_dirty();
    state.select_next();
    assert_eq!(state.selected_index, 1);
    assert!(state.is_dirty());

    state.select_next();
    assert_eq!(state.selected_index, 2);
}

#[test]
fn test_complete_create_workflow() {
    let mut state = AppState::new();

    // Navigate to create screen
    state.set_screen(ScreenType::Create);
    assert_eq!(state.current_screen, ScreenType::Create);

    // Fill form
    state.form_name = "New Item".to_string();
    state.form_description = "Description".to_string();

    // Submit (in real app, this happens in main loop)
    state.add_item(state.form_name.clone());

    // Return to list
    state.reset_form();
    state.set_screen(ScreenType::List);

    // Verify item was added
    assert_eq!(state.items.len(), 1);
    assert_eq!(state.items[0], "New Item");
    assert!(state.form_name.is_empty());
}

#[test]
fn test_navigation_between_screens() {
    let mut state = AppState::new();

    // Start at list
    assert_eq!(state.current_screen, ScreenType::List);

    // Navigate to detail
    state.set_screen(ScreenType::Detail);
    assert_eq!(state.current_screen, ScreenType::Detail);

    // Navigate to create
    state.set_screen(ScreenType::Create);
    assert_eq!(state.current_screen, ScreenType::Create);

    // Back to list
    state.set_screen(ScreenType::List);
    assert_eq!(state.current_screen, ScreenType::List);
}

#[test]
fn test_dirty_flag_optimization() {
    let mut state = AppState::new();

    // Initial state should be dirty
    assert!(state.is_dirty());

    // Reset after render
    state.reset_dirty();
    assert!(!state.is_dirty());

    // No changes - should not be dirty
    let _ = state.selected_item();
    assert!(!state.is_dirty());

    // Add item - should be dirty
    state.add_item("Item 1".to_string());
    assert!(state.is_dirty());

    // Reset
    state.reset_dirty();
    assert!(!state.is_dirty());

    // Navigate - should be dirty
    state.select_next();
    assert!(state.is_dirty());
}

#[test]
fn test_selection_with_item_operations() {
    let mut state = AppState::new();

    // Add items
    state.add_item("Item 1".to_string());
    state.add_item("Item 2".to_string());
    state.add_item("Item 3".to_string());

    // Select specific item
    state.selected_index = 1;
    assert_eq!(state.selected_item(), Some(&"Item 2".to_string()));

    // Navigate and verify
    state.select_next();
    assert_eq!(state.selected_item(), Some(&"Item 3".to_string()));

    // Navigate with wrap
    state.select_next();
    assert_eq!(state.selected_item(), Some(&"Item 1".to_string()));
}

#[test]
fn test_form_reset_clears_all_fields() {
    let mut state = AppState::new();

    // Populate form
    state.form_name = "Name".to_string();
    state.form_description = "Description".to_string();

    // Reset
    state.reset_form();

    // All fields empty
    assert!(state.form_name.is_empty());
    assert!(state.form_description.is_empty());
}

#[test]
fn test_quit_flag_persistence() {
    let mut state = AppState::new();
    assert!(!state.should_quit());

    state.request_quit();
    assert!(state.should_quit());

    // Flag persists until explicitly cleared
    // (in real app, app loop checks and exits)
}

Test Organization

Directory Structure

crate-root/
├── src/
│   ├── lib.rs          # Public API
│   ├── app.rs          # +15 tests (inline)
│   ├── screens/
│   │   ├── mod.rs
│   │   ├── list.rs     # +2 tests (inline)
│   │   ├── create.rs   # +2 tests (inline)
│   │   └── detail.rs   # +2 tests (inline)
│   └── integration.rs  # +5 tests (inline)
│
└── tests/              # Integration tests
    ├── integration_tests.rs  # +10+ tests
    └── workflows.rs          # +5+ tests

Running Tests

# All tests
cargo test --lib

# Specific module
cargo test --lib app::

# Single test
cargo test --lib app::test_app_state_new

# With output
cargo test --lib -- --nocapture

# Parallel execution (faster)
cargo test --lib -- --test-threads=8

# Single-threaded (for debugging)
cargo test --lib -- --test-threads=1

# Integration tests only
cargo test --test '*'

# Coverage report (requires cargo-tarpaulin)
cargo tarpaulin --lib

Mocking Strategies

Pattern 4: Mocking Complex Dependencies

If your AppState depends on external services:

// src/app.rs

#[cfg(test)]
mod tests {
    use super::*;

    // Mock service
    struct MockItemService {
        items: Vec<String>,
    }

    impl MockItemService {
        fn new() -> Self {
            MockItemService {
                items: vec![
                    "Item 1".to_string(),
                    "Item 2".to_string(),
                ],
            }
        }

        fn fetch_items(&self) -> Vec<String> {
            self.items.clone()
        }
    }

    #[test]
    fn test_with_mocked_service() {
        let service = MockItemService::new();
        let items = service.fetch_items();

        assert_eq!(items.len(), 2);
    }
}

Pattern 5: Testing State Transitions

#[test]
fn test_state_transition_sequence() {
    let mut state = AppState::new();
    let mut actions = vec![];

    // Record actions
    actions.push(("initial", state.current_screen));

    state.add_item("Item 1".to_string());
    actions.push(("add_item", state.current_screen));

    state.set_screen(ScreenType::Create);
    actions.push(("set_screen", state.current_screen));

    // Verify sequence
    assert_eq!(actions[0].0, "initial");
    assert_eq!(actions[1].0, "add_item");
    assert_eq!(actions[2].0, "set_screen");
    assert_eq!(actions[2].1, ScreenType::Create);
}

Coverage Requirements

Minimum Test Coverage

Per Module Requirement: 15+ tests

Module Tests Focus
app.rs 18+ State mutations, navigation, lifecycle
list.rs 4+ Empty state, item display, selection
create.rs 3+ Form handling, field management
detail.rs 3+ Selection handling, empty state
integration.rs 5+ Dashboard integration, command execution
Total 35+ All major paths

Coverage Verification

# Install tarpaulin
cargo install cargo-tarpaulin

# Run coverage
cargo tarpaulin --lib --out Html

# View report
open tarpaulin-report.html

Coverage Targets

  • Lines: 80%+ coverage
  • Branches: 75%+ coverage
  • Functions: 90%+ coverage
  • Zero untested critical paths

Common Testing Scenarios

Scenario 1: Empty State Handling

#[test]
fn test_empty_list_no_panic() {
    let mut state = AppState::new();

    // These should not panic
    state.select_next();
    state.select_previous();
    let _ = state.selected_item();

    assert_eq!(state.selected_index, 0);
    assert!(state.items.is_empty());
}

Scenario 2: Boundary Conditions

#[test]
fn test_selection_boundaries() {
    let mut state = AppState::new();
    state.add_item("Only Item".to_string());

    // Single item wraps to itself
    state.select_next();
    assert_eq!(state.selected_index, 0);

    state.select_previous();
    assert_eq!(state.selected_index, 0);
}

#[test]
fn test_large_item_count() {
    let mut state = AppState::new();

    // Add 1000 items
    for i in 0..1000 {
        state.add_item(format!("Item {}", i));
    }

    assert_eq!(state.items.len(), 1000);

    // Navigation still works
    for _ in 0..1000 {
        state.select_next();
    }
    assert_eq!(state.selected_index, 0);  // Wraps around
}

Scenario 3: State Invariants

#[test]
fn test_selected_index_invariant() {
    let mut state = AppState::new();

    // Add and remove items - selected_index should remain valid
    state.add_item("Item 1".to_string());
    assert!(state.selected_index < state.items.len());

    state.add_item("Item 2".to_string());
    assert!(state.selected_index < state.items.len());

    state.select_next();
    assert!(state.selected_index < state.items.len());
}

Scenario 4: Dirty Flag Correctness

#[test]
fn test_dirty_flag_comprehensive() {
    let mut state = AppState::new();
    let mut changes = vec![];

    state.reset_dirty();
    changes.push(("after_reset", state.is_dirty()));

    state.add_item("Item".to_string());
    changes.push(("after_add", state.is_dirty()));

    state.reset_dirty();
    changes.push(("after_reset_2", state.is_dirty()));

    state.set_screen(ScreenType::Create);
    changes.push(("after_set_screen", state.is_dirty()));

    // Verify pattern
    assert!(!changes[0].1);  // Should be clean
    assert!(changes[1].1);   // Should be dirty
    assert!(!changes[2].1);  // Should be clean
    assert!(changes[3].1);   // Should be dirty
}

Performance Testing

Benchmark Template

If performance is critical, add benchmarks:

# Cargo.toml
[[bench]]
name = "app_state_operations"
harness = false

Create benches/app_state_operations.rs:

use criterion::{black_box, criterion_group, criterion_main, Criterion};
use your_tui::{AppState};

fn bench_add_items(c: &mut Criterion) {
    c.bench_function("add_1000_items", |b| {
        b.iter(|| {
            let mut state = AppState::new();
            for i in 0..1000 {
                state.add_item(black_box(format!("Item {}", i)));
            }
        });
    });
}

fn bench_select_next(c: &mut Criterion) {
    let mut state = AppState::new();
    for i in 0..1000 {
        state.add_item(format!("Item {}", i));
    }

    c.bench_function("select_next_1000_items", |b| {
        b.iter(|| {
            state.select_next();
        });
    });
}

criterion_group!(benches, bench_add_items, bench_select_next);
criterion_main!(benches);

Run benchmarks:

cargo bench

Summary

Key Testing Rules:

  1. Minimum 15+ tests per module
  2. Test state separately from rendering
  3. Use integration tests for full workflows
  4. Verify dirty flag behavior (critical for performance)
  5. Test edge cases and boundaries
  6. Keep tests deterministic and fast
  7. Achieve 80%+ code coverage
  8. All tests must pass before committing

Testing Checklist:

  • 15+ tests in app.rs
  • 2-3 tests per screen
  • 5+ tests in integration module
  • 10+ integration tests in tests/
  • All edge cases covered
  • All tests pass: cargo test --lib
  • Coverage > 80%: cargo tarpaulin --lib
  • No panics or unwrap() in tested code