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)
19 KiB
19 KiB
TUI Testing Guide
Comprehensive testing guide for RataTui-based TUI applications in the Tools ecosystem.
Table of Contents
- Testing Philosophy
- Unit Testing Patterns
- Integration Testing
- Test Organization
- Mocking Strategies
- Coverage Requirements
- Common Testing Scenarios
- Performance Testing
Testing Philosophy
Core Principles
-
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
-
Minimum 15+ Tests Per Module: Comprehensive coverage
- Unit tests in module files
- Integration tests in tests/ directory
- Focus on critical paths and edge cases
-
Deterministic Tests: No flakiness allowed
- Use fixed values, not system time
- No random data or timing dependencies
- Tests must pass 100% of the time
-
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:
- Minimum 15+ tests per module
- Test state separately from rendering
- Use integration tests for full workflows
- Verify dirty flag behavior (critical for performance)
- Test edge cases and boundaries
- Keep tests deterministic and fast
- Achieve 80%+ code coverage
- 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