syntaxis/shared/rust-tui/TESTING_GUIDE.md

812 lines
19 KiB
Markdown
Raw Normal View History

# 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<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
```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