# TUI Testing Guide Comprehensive testing guide for RataTui-based TUI applications in the Tools ecosystem. ## Table of Contents 1. [Testing Philosophy](#testing-philosophy) 2. [Unit Testing Patterns](#unit-testing-patterns) 3. [Integration Testing](#integration-testing) 4. [Test Organization](#test-organization) 5. [Mocking Strategies](#mocking-strategies) 6. [Coverage Requirements](#coverage-requirements) 7. [Common Testing Scenarios](#common-testing-scenarios) 8. [Performance Testing](#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) ```rust #[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. ```rust #[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` ```rust //! 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 ```bash # 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: ```rust // src/app.rs #[cfg(test)] mod tests { use super::*; // Mock service struct MockItemService { items: Vec, } impl MockItemService { fn new() -> Self { MockItemService { items: vec![ "Item 1".to_string(), "Item 2".to_string(), ], } } fn fetch_items(&self) -> Vec { 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 ```rust #[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 ```bash # 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 ```rust #[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 ```rust #[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 ```rust #[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 ```rust #[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: ```toml # Cargo.toml [[bench]] name = "app_state_operations" harness = false ``` Create `benches/app_state_operations.rs`: ```rust 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: ```bash 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