Test Design Document: asset-light

版本: 1.0.0
日期: 2025-12-20
测试工程师: TEA Agent


1. 测试概述

1.1 测试目标

为 asset-light MVP 建立全面的测试策略,确保:

  • 核心业务逻辑正确性
  • 数据持久化可靠性
  • UI 交互符合预期
  • 性能指标达标

1.2 测试范围

模块测试类型优先级
数据模型单元测试P0
业务服务单元测试 + 集成测试P0
数据库层集成测试P0
组件渲染组件测试P1
端到端流程E2E 测试P1
性能基准性能测试P2

1.3 测试工具

用途工具
单元测试Rust 内置 #[test]
断言增强pretty_assertions
Mockmockall
异步测试tokio::test
测试覆盖率cargo-tarpaulin
基准测试criterion

2. 测试策略

2.1 测试金字塔

        ┌───────────┐
        │   E2E     │  少量关键流程
        │  Tests    │  
        ├───────────┤
        │Integration│  服务 + 数据库
        │  Tests    │
        ├───────────┤
        │   Unit    │  大量覆盖
        │  Tests    │  模型 + 服务 + 工具
        └───────────┘

2.2 覆盖率目标

层级目标覆盖率
模型层 (models)≥ 90%
服务层 (services)≥ 85%
数据库层 (db)≥ 80%
工具函数 (utils)≥ 90%
整体≥ 80%

3. 单元测试

3.1 模型层测试

3.1.1 Asset 模型

测试文件: src/models/asset.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_asset_new() {
        let asset = Asset::new(
            "余额宝".to_string(),
            Category::Cash,
            dec!(10000.00),
        );
        
        assert!(!asset.id.is_nil());
        assert_eq!(asset.name, "余额宝");
        assert_eq!(asset.category, Category::Cash);
        assert_eq!(asset.current_value, dec!(10000.00));
        assert!(asset.sub_category.is_none());
        assert!(asset.notes.is_none());
    }

    #[test]
    fn test_category_display_name() {
        assert_eq!(Category::Cash.display_name(), "现金类");
        assert_eq!(Category::Stable.display_name(), "稳健类");
        assert_eq!(Category::Advanced.display_name(), "进阶类");
    }

    #[test]
    fn test_asset_with_sub_category() {
        let mut asset = Asset::new(
            "沪深300ETF".to_string(),
            Category::Advanced,
            dec!(50000.00),
        );
        asset.sub_category = Some("宽基指数".to_string());
        
        assert_eq!(asset.sub_category, Some("宽基指数".to_string()));
    }
}
}

3.1.2 Snapshot 模型

测试文件: src/models/snapshot.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_snapshot_new_calculates_total() {
        let items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产A".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(10000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产B".to_string(),
                category: Category::Stable,
                sub_category: None,
                value: dec!(20000.00),
            },
        ];
        
        let snapshot = Snapshot::new(items);
        
        assert_eq!(snapshot.total_value, dec!(30000.00));
        assert_eq!(snapshot.items.len(), 2);
    }

    #[test]
    fn test_snapshot_category_total() {
        let items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金A".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(10000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金B".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(5000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "稳健".to_string(),
                category: Category::Stable,
                sub_category: None,
                value: dec!(20000.00),
            },
        ];
        
        let snapshot = Snapshot::new(items);
        
        assert_eq!(snapshot.category_total(&Category::Cash), dec!(15000.00));
        assert_eq!(snapshot.category_total(&Category::Stable), dec!(20000.00));
        assert_eq!(snapshot.category_total(&Category::Advanced), dec!(0.00));
    }

    #[test]
    fn test_snapshot_empty_items() {
        let snapshot = Snapshot::new(vec![]);
        
        assert_eq!(snapshot.total_value, dec!(0.00));
        assert!(snapshot.items.is_empty());
    }
}
}

3.1.3 AllocationPlan 模型

测试文件: src/models/plan.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_default_balanced_plan() {
        let plan = AllocationPlan::default_balanced();
        
        assert_eq!(plan.name, "平衡型");
        assert!(plan.is_active);
        assert_eq!(plan.allocations.len(), 3);
        
        let total: Decimal = plan.allocations
            .iter()
            .map(|a| a.target_percentage)
            .sum();
        assert_eq!(total, dec!(100));
    }

    #[test]
    fn test_template_conservative() {
        let plan = AllocationPlan::template_conservative();
        
        let cash = plan.allocations.iter()
            .find(|a| a.category == Category::Cash)
            .unwrap();
        assert_eq!(cash.target_percentage, dec!(30));
    }

    #[test]
    fn test_template_aggressive() {
        let plan = AllocationPlan::template_aggressive();
        
        let advanced = plan.allocations.iter()
            .find(|a| a.category == Category::Advanced)
            .unwrap();
        assert_eq!(advanced.target_percentage, dec!(70));
    }
}
}

3.2 服务层测试

3.2.1 AnalysisService 测试

测试文件: src/services/analysis_service.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    fn create_test_assets() -> Vec<Asset> {
        vec![
            Asset {
                id: Uuid::new_v4(),
                name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                current_value: dec!(25000.00), // 25%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
            Asset {
                id: Uuid::new_v4(),
                name: "稳健".to_string(),
                category: Category::Stable,
                sub_category: None,
                current_value: dec!(40000.00), // 40%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
            Asset {
                id: Uuid::new_v4(),
                name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                current_value: dec!(35000.00), // 35%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
        ]
    }

    fn create_test_plan() -> AllocationPlan {
        AllocationPlan {
            id: Uuid::new_v4(),
            name: "测试方案".to_string(),
            description: None,
            is_active: true,
            allocations: vec![
                Allocation {
                    category: Category::Cash,
                    target_percentage: dec!(20),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Stable,
                    target_percentage: dec!(40),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Advanced,
                    target_percentage: dec!(40),
                    min_percentage: None,
                    max_percentage: None,
                },
            ],
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }

    #[test]
    fn test_calculate_deviation_normal() {
        let assets = create_test_assets();
        let plan = create_test_plan();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        
        assert_eq!(results.len(), 3);
        
        // 现金类: 25% - 20% = +5% (超配,轻度偏离)
        let cash = results.iter().find(|r| r.category == Category::Cash).unwrap();
        assert_eq!(cash.deviation, dec!(5));
        assert_eq!(cash.status, DeviationStatus::Mild);
        assert_eq!(cash.direction, DeviationDirection::Overweight);
        
        // 稳健类: 40% - 40% = 0% (正常)
        let stable = results.iter().find(|r| r.category == Category::Stable).unwrap();
        assert_eq!(stable.deviation, dec!(0));
        assert_eq!(stable.status, DeviationStatus::Normal);
        
        // 进阶类: 35% - 40% = -5% (低配,正常边界)
        let advanced = results.iter().find(|r| r.category == Category::Advanced).unwrap();
        assert_eq!(advanced.deviation, dec!(-5));
        assert_eq!(advanced.status, DeviationStatus::Normal);
        assert_eq!(advanced.direction, DeviationDirection::Underweight);
    }

    #[test]
    fn test_calculate_deviation_severe() {
        let assets = vec![
            Asset {
                id: Uuid::new_v4(),
                name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                current_value: dec!(50000.00), // 50%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
            Asset {
                id: Uuid::new_v4(),
                name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                current_value: dec!(50000.00), // 50%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
        ];
        let plan = create_test_plan();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        
        // 现金类: 50% - 20% = +30% (严重偏离)
        let cash = results.iter().find(|r| r.category == Category::Cash).unwrap();
        assert_eq!(cash.status, DeviationStatus::Severe);
    }

    #[test]
    fn test_calculate_deviation_empty_assets() {
        let assets: Vec<Asset> = vec![];
        let plan = create_test_plan();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        
        assert!(results.is_empty());
    }

    #[test]
    fn test_calculate_return() {
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(100000.00),
            },
        ];
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(110000.00),
            },
        ];
        
        let start = Snapshot::new(start_items);
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        assert_eq!(result.absolute_return, dec!(10000.00));
        assert_eq!(result.return_rate, dec!(10)); // 10%
    }

    #[test]
    fn test_calculate_return_negative() {
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(100000.00),
            },
        ];
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(90000.00),
            },
        ];
        
        let start = Snapshot::new(start_items);
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        assert_eq!(result.absolute_return, dec!(-10000.00));
        assert_eq!(result.return_rate, dec!(-10)); // -10%
    }

    #[test]
    fn test_calculate_return_zero_start() {
        let start = Snapshot::new(vec![]);
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(10000.00),
            },
        ];
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        assert_eq!(result.return_rate, dec!(0)); // 避免除零
    }

    #[test]
    fn test_category_return_contribution() {
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(50000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(50000.00),
            },
        ];
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(51000.00), // +1000
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(59000.00), // +9000
            },
        ];
        
        let start = Snapshot::new(start_items);
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        // 总收益 10000,现金贡献 10%,进阶贡献 90%
        let cash_contrib = result.category_breakdown
            .iter()
            .find(|c| c.category == Category::Cash)
            .unwrap();
        assert_eq!(cash_contrib.contribution_rate, dec!(10));
        
        let advanced_contrib = result.category_breakdown
            .iter()
            .find(|c| c.category == Category::Advanced)
            .unwrap();
        assert_eq!(advanced_contrib.contribution_rate, dec!(90));
    }
}
}

3.3 工具函数测试

3.3.1 金额格式化

测试文件: src/utils/format.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_format_currency() {
        assert_eq!(format_currency(dec!(1234567.89)), "¥ 1,234,567.89");
        assert_eq!(format_currency(dec!(0)), "¥ 0.00");
        assert_eq!(format_currency(dec!(-1234.56)), "-¥ 1,234.56");
    }

    #[test]
    fn test_format_percentage() {
        assert_eq!(format_percentage(dec!(25.5)), "25.50%");
        assert_eq!(format_percentage(dec!(0)), "0.00%");
        assert_eq!(format_percentage(dec!(-5.25)), "-5.25%");
    }

    #[test]
    fn test_format_change() {
        assert_eq!(format_change(dec!(1234.56)), "+¥ 1,234.56");
        assert_eq!(format_change(dec!(-1234.56)), "-¥ 1,234.56");
        assert_eq!(format_change(dec!(0)), "¥ 0.00");
    }

    #[test]
    fn test_format_change_percentage() {
        assert_eq!(format_change_percentage(dec!(5.25)), "+5.25%");
        assert_eq!(format_change_percentage(dec!(-3.5)), "-3.50%");
    }
}
}

3.3.2 日期处理

测试文件: src/utils/date.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use chrono::NaiveDate;

    #[test]
    fn test_get_quarter() {
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()), 1);
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()), 2);
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 7, 31).unwrap()), 3);
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()), 4);
    }

    #[test]
    fn test_get_quarter_date_range() {
        let (start, end) = get_quarter_date_range(2025, 1);
        assert_eq!(start, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
        assert_eq!(end, NaiveDate::from_ymd_opt(2025, 3, 31).unwrap());
        
        let (start, end) = get_quarter_date_range(2025, 4);
        assert_eq!(start, NaiveDate::from_ymd_opt(2025, 10, 1).unwrap());
        assert_eq!(end, NaiveDate::from_ymd_opt(2025, 12, 31).unwrap());
    }

    #[test]
    fn test_format_date_display() {
        let date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap();
        assert_eq!(format_date_display(date), "2025-12-20");
    }

    #[test]
    fn test_format_date_friendly() {
        let date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap();
        assert_eq!(format_date_friendly(date), "12月20日");
    }
}
}

4. 集成测试

4.1 数据库层集成测试

测试文件: tests/db_integration.rs

#![allow(unused)]
fn main() {
use asset_light::db::{Database, AssetRepository, SnapshotRepository, PlanRepository};
use asset_light::models::*;
use rust_decimal_macros::dec;
use tempfile::TempDir;

fn setup_test_db() -> (Database, TempDir) {
    let temp_dir = TempDir::new().unwrap();
    let db_path = temp_dir.path().join("test.db");
    std::env::set_var("ASSET_LIGHT_DB_PATH", db_path.to_str().unwrap());
    
    let db = Database::new().unwrap();
    db.run_migrations().unwrap();
    
    (db, temp_dir)
}

#[test]
fn test_asset_crud() {
    let (db, _temp) = setup_test_db();
    let repo = AssetRepository::new(db.conn());
    
    // Create
    let asset = Asset::new("测试资产".to_string(), Category::Cash, dec!(10000));
    repo.insert(&asset).unwrap();
    
    // Read
    let found = repo.find_by_id(asset.id).unwrap().unwrap();
    assert_eq!(found.name, "测试资产");
    
    // Update
    let mut updated = found.clone();
    updated.current_value = dec!(20000);
    repo.update(&updated).unwrap();
    
    let found = repo.find_by_id(asset.id).unwrap().unwrap();
    assert_eq!(found.current_value, dec!(20000));
    
    // Delete
    repo.delete(asset.id).unwrap();
    let found = repo.find_by_id(asset.id).unwrap();
    assert!(found.is_none());
}

#[test]
fn test_asset_find_by_category() {
    let (db, _temp) = setup_test_db();
    let repo = AssetRepository::new(db.conn());
    
    repo.insert(&Asset::new("现金1".to_string(), Category::Cash, dec!(1000))).unwrap();
    repo.insert(&Asset::new("现金2".to_string(), Category::Cash, dec!(2000))).unwrap();
    repo.insert(&Asset::new("稳健".to_string(), Category::Stable, dec!(3000))).unwrap();
    
    let cash_assets = repo.find_by_category(Category::Cash).unwrap();
    assert_eq!(cash_assets.len(), 2);
    
    let stable_assets = repo.find_by_category(Category::Stable).unwrap();
    assert_eq!(stable_assets.len(), 1);
}

#[test]
fn test_snapshot_with_items() {
    let (db, _temp) = setup_test_db();
    let asset_repo = AssetRepository::new(db.conn());
    let snapshot_repo = SnapshotRepository::new(db.conn());
    
    // 创建资产
    let asset = Asset::new("资产".to_string(), Category::Cash, dec!(10000));
    asset_repo.insert(&asset).unwrap();
    
    // 创建快照
    let items = vec![SnapshotItem {
        asset_id: asset.id,
        asset_name: asset.name.clone(),
        category: asset.category.clone(),
        sub_category: None,
        value: asset.current_value,
    }];
    let snapshot = Snapshot::new(items);
    snapshot_repo.insert(&snapshot).unwrap();
    
    // 查询快照
    let found = snapshot_repo.find_by_id(snapshot.id).unwrap().unwrap();
    assert_eq!(found.items.len(), 1);
    assert_eq!(found.total_value, dec!(10000));
}

#[test]
fn test_plan_activation() {
    let (db, _temp) = setup_test_db();
    let repo = PlanRepository::new(db.conn());
    
    // 创建两个方案
    let plan1 = AllocationPlan::default_balanced();
    let mut plan2 = AllocationPlan::template_conservative();
    plan2.is_active = false;
    
    repo.insert(&plan1).unwrap();
    repo.insert(&plan2).unwrap();
    
    // 激活 plan2
    repo.set_active(plan2.id).unwrap();
    
    // 验证
    let found1 = repo.find_by_id(plan1.id).unwrap().unwrap();
    let found2 = repo.find_by_id(plan2.id).unwrap().unwrap();
    
    assert!(!found1.is_active);
    assert!(found2.is_active);
    
    // 查询激活方案
    let active = repo.find_active().unwrap().unwrap();
    assert_eq!(active.id, plan2.id);
}

#[test]
fn test_snapshot_daily_limit() {
    let (db, _temp) = setup_test_db();
    let repo = SnapshotRepository::new(db.conn());
    
    // 创建 5 个快照
    for i in 0..5 {
        let snapshot = Snapshot::new(vec![]);
        repo.insert(&snapshot).unwrap();
    }
    
    // 第 6 个应该检查失败
    let count = repo.count_today().unwrap();
    assert_eq!(count, 5);
}
}

5. 业务流程测试

5.1 盘点流程测试

#![allow(unused)]
fn main() {
#[cfg(test)]
mod inventory_flow_tests {
    use super::*;

    #[test]
    fn test_complete_inventory_flow() {
        let (db, _temp) = setup_test_db();
        let asset_service = AssetService::new(db.clone());
        let snapshot_service = SnapshotService::new(db.clone());
        
        // 1. 创建资产
        let asset1 = Asset::new("资产1".to_string(), Category::Cash, dec!(10000));
        let asset2 = Asset::new("资产2".to_string(), Category::Stable, dec!(20000));
        asset_service.create(&asset1).unwrap();
        asset_service.create(&asset2).unwrap();
        
        // 2. 执行盘点
        let assets = asset_service.list_all().unwrap();
        let snapshot = snapshot_service.create_snapshot(&assets).unwrap();
        
        assert_eq!(snapshot.total_value, dec!(30000));
        assert_eq!(snapshot.items.len(), 2);
        
        // 3. 更新资产
        let mut updated = asset1.clone();
        updated.current_value = dec!(15000);
        asset_service.update(&updated).unwrap();
        
        // 4. 再次盘点
        let assets = asset_service.list_all().unwrap();
        let snapshot2 = snapshot_service.create_snapshot(&assets).unwrap();
        
        assert_eq!(snapshot2.total_value, dec!(35000));
        
        // 5. 验证历史
        let history = snapshot_service.list_all().unwrap();
        assert_eq!(history.len(), 2);
    }

    #[test]
    fn test_inventory_daily_limit() {
        let (db, _temp) = setup_test_db();
        let asset_service = AssetService::new(db.clone());
        let snapshot_service = SnapshotService::new(db.clone());
        
        // 创建资产
        let asset = Asset::new("资产".to_string(), Category::Cash, dec!(10000));
        asset_service.create(&asset).unwrap();
        
        // 盘点 5 次
        for _ in 0..5 {
            let assets = asset_service.list_all().unwrap();
            snapshot_service.create_snapshot(&assets).unwrap();
        }
        
        // 第 6 次应该失败
        let assets = asset_service.list_all().unwrap();
        let result = snapshot_service.create_snapshot(&assets);
        
        assert!(result.is_err());
        match result {
            Err(AppError::LimitExceeded(msg)) => {
                assert!(msg.contains("5次"));
            }
            _ => panic!("Expected LimitExceeded error"),
        }
    }
}
}

5.2 收益分析流程测试

#![allow(unused)]
fn main() {
#[cfg(test)]
mod analysis_flow_tests {
    use super::*;

    #[test]
    fn test_quarterly_return_analysis() {
        let (db, _temp) = setup_test_db();
        let snapshot_repo = SnapshotRepository::new(db.conn());
        
        // 创建 Q4 的两个快照
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(100000),
            },
        ];
        let mut start = Snapshot::new(start_items);
        start.snapshot_date = NaiveDate::from_ymd_opt(2025, 10, 1).unwrap();
        snapshot_repo.insert(&start).unwrap();
        
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(105000),
            },
        ];
        let mut end = Snapshot::new(end_items);
        end.snapshot_date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap();
        snapshot_repo.insert(&end).unwrap();
        
        // 查询 Q4 收益
        let (q_start, q_end) = get_quarter_date_range(2025, 4);
        let snapshots = snapshot_repo.find_in_range(q_start, q_end).unwrap();
        
        assert_eq!(snapshots.len(), 2);
        
        let result = AnalysisService::calculate_return(
            snapshots.last().unwrap(),  // 最早
            snapshots.first().unwrap(), // 最新
        );
        
        assert_eq!(result.absolute_return, dec!(5000));
        assert_eq!(result.return_rate, dec!(5));
    }
}
}

6. 边界条件测试

6.1 数据边界

#![allow(unused)]
fn main() {
#[cfg(test)]
mod boundary_tests {
    use super::*;

    #[test]
    fn test_asset_name_max_length() {
        let name = "a".repeat(100);
        let asset = Asset::new(name.clone(), Category::Cash, dec!(1000));
        assert_eq!(asset.name.len(), 100);
        
        // 超过 100 字符应该验证失败
        let long_name = "a".repeat(101);
        let result = validate_asset_name(&long_name);
        assert!(result.is_err());
    }

    #[test]
    fn test_asset_value_zero() {
        let asset = Asset::new("零值资产".to_string(), Category::Cash, dec!(0));
        assert_eq!(asset.current_value, dec!(0));
    }

    #[test]
    fn test_asset_value_negative_rejected() {
        let result = validate_asset_value(dec!(-100));
        assert!(result.is_err());
    }

    #[test]
    fn test_asset_value_precision() {
        // 保留 2 位小数
        let asset = Asset::new("资产".to_string(), Category::Cash, dec!(1234.567));
        // 应该被截断或四舍五入到 1234.57
        assert_eq!(asset.current_value.scale(), 2);
    }

    #[test]
    fn test_allocation_total_not_100() {
        let plan = AllocationPlan {
            id: Uuid::new_v4(),
            name: "错误方案".to_string(),
            description: None,
            is_active: false,
            allocations: vec![
                Allocation {
                    category: Category::Cash,
                    target_percentage: dec!(30),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Stable,
                    target_percentage: dec!(30),
                    min_percentage: None,
                    max_percentage: None,
                },
                // 缺少 Advanced,总和只有 60%
            ],
            created_at: Utc::now(),
            updated_at: Utc::now(),
        };
        
        let result = validate_allocation_plan(&plan);
        assert!(result.is_err());
    }

    #[test]
    fn test_empty_assets_deviation() {
        let assets: Vec<Asset> = vec![];
        let plan = AllocationPlan::default_balanced();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        assert!(results.is_empty());
    }

    #[test]
    fn test_single_snapshot_return() {
        // 只有一个快照时无法计算收益
        let snapshot = Snapshot::new(vec![]);
        
        // 应该返回特殊值或错误
        // 具体取决于 API 设计
    }
}
}

7. 性能测试

7.1 基准测试

文件: benches/performance.rs

#![allow(unused)]
fn main() {
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use asset_light::services::AnalysisService;
use asset_light::models::*;
use rust_decimal_macros::dec;
use uuid::Uuid;

fn create_large_asset_list(count: usize) -> Vec<Asset> {
    (0..count)
        .map(|i| Asset {
            id: Uuid::new_v4(),
            name: format!("资产{}", i),
            category: match i % 3 {
                0 => Category::Cash,
                1 => Category::Stable,
                _ => Category::Advanced,
            },
            sub_category: None,
            current_value: dec!(10000),
            notes: None,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        })
        .collect()
}

fn bench_deviation_calculation(c: &mut Criterion) {
    let assets = create_large_asset_list(100);
    let plan = AllocationPlan::default_balanced();
    
    c.bench_function("calculate_deviation_100_assets", |b| {
        b.iter(|| {
            AnalysisService::calculate_deviation(
                black_box(&assets),
                black_box(&plan),
            )
        })
    });
}

fn bench_return_calculation(c: &mut Criterion) {
    let items: Vec<SnapshotItem> = (0..100)
        .map(|i| SnapshotItem {
            asset_id: Uuid::new_v4(),
            asset_name: format!("资产{}", i),
            category: Category::Cash,
            sub_category: None,
            value: dec!(10000),
        })
        .collect();
    
    let start = Snapshot::new(items.clone());
    let end = Snapshot::new(items);
    
    c.bench_function("calculate_return_100_items", |b| {
        b.iter(|| {
            AnalysisService::calculate_return(
                black_box(&start),
                black_box(&end),
            )
        })
    });
}

criterion_group!(benches, bench_deviation_calculation, bench_return_calculation);
criterion_main!(benches);
}

7.2 性能指标

场景目标验收标准
应用启动< 2s冷启动到可交互
页面切换< 200ms点击到渲染完成
盘点保存< 500ms100 个资产
偏离计算< 50ms100 个资产
收益计算< 100ms1000 条快照

8. 测试执行

8.1 测试命令

# 运行所有测试
cargo test

# 运行单元测试
cargo test --lib

# 运行集成测试
cargo test --test '*'

# 运行特定模块测试
cargo test models::
cargo test services::

# 显示详细输出
cargo test -- --nocapture

# 运行性能基准
cargo bench

# 生成覆盖率报告
cargo tarpaulin --out Html

8.2 CI 集成

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Run tests
        run: cargo test --all
      - name: Run clippy
        run: cargo clippy -- -D warnings

9. 测试清单

9.1 模型层测试清单

测试状态
Asset::new() 创建
Category 枚举显示名
Snapshot 总值计算
Snapshot 类别汇总
AllocationPlan 模板

9.2 服务层测试清单

测试状态
偏离度正常计算
偏离度严重偏离
偏离度空资产
收益正向计算
收益负向计算
收益贡献度
每日盘点限制

9.3 集成测试清单

测试状态
资产 CRUD
快照存储
方案激活切换
完整盘点流程
季度收益分析

9.4 边界测试清单

测试状态
名称最大长度
零值资产
负值拒绝
占比非 100%
空资产偏离

修订历史

版本日期作者变更说明
1.0.02025-12-20TEA Agent初始版本