第十九章:测试策略与调试技巧

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第十九章:测试策略与调试技巧

1. 测试金字塔

        ╱╲
       ╱E2E╲         ← 少量:关键用户流程
      ╱─────╲
     ╱组件测试╲       ← 适中:UI 交互逻辑
    ╱─────────╲
   ╱ 单元测试   ╲     ← 大量:纯逻辑、工具函数
  ╱─────────────╲

对于 Dioxus 应用,测试策略分三层:

| 层级 | 工具 | 测试内容 | 速度 | |------|------|---------|------| | 单元测试 | cargo test | 纯函数、数据转换、验证逻辑 | 毫秒级 | | 组件测试 | dioxus/testing | 组件渲染、事件响应、状态变化 | 毫秒级 | | E2E 测试 | Playwright/Cypress | 用户流程、页面跳转、API 集成 | 秒级 |

2. 单元测试

2.1 测试纯函数

// content.rs 中的工具函数
pub fn slugify_heading(text: &str) -> String {
    text.chars()
        .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-')
        .collect::<String>()
        .trim()
        .to_lowercase()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join("-")
}

pub fn extract_abstract(body: &str) -> String {
    body.lines()
        .map(str::trim)
        .find(|l| !l.starts_with('#') && !l.is_empty())
        .map(|l| l.chars().take(160).collect())
        .unwrap_or_default()
}

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

    #[test]
    fn test_slugify_heading() {
        assert_eq!(slugify_heading("Hello World"), "hello-world");
        assert_eq!(slugify_heading("  Spaces  "), "spaces");
        assert_eq!(slugify_heading("Special!@#Chars"), "specialchars");
        assert_eq!(slugify_heading(""), "");
    }

    #[test]
    fn test_extract_abstract() {
        let body = "\n\n# Title\n\nThis is the first paragraph. It should be extracted.\n\nSecond paragraph.";
        assert_eq!(
            extract_abstract(body),
            "This is the first paragraph. It should be extracted."
        );

        let empty = extract_abstract("# Only Heading");
        assert_eq!(empty, "");
    }

    #[test]
    fn test_url_encode_decode() {
        let original = "博客 v1.0 系列教程 (C#)";
        let encoded = url::encode(original);
        assert!(encoded.contains("%"));
        let decoded = url::decode(&encoded);
        assert_eq!(decoded, original);
    }
}

2.2 测试验证逻辑

// validation.rs
pub fn validate_email(email: &str) -> Result<(), String> {
    if !email.contains('@') {
        return Err("邮箱必须包含 @".to_string());
    }
    let parts: Vec<&str> = email.split('@').collect();
    if parts.len() != 2 || parts[1].is_empty() {
        return Err("邮箱格式不正确".to_string());
    }
    if !parts[1].contains('.') {
        return Err("邮箱域名必须包含点号".to_string());
    }
    Ok(())
}

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

    #[test]
    fn email_valid() {
        assert!(validate_email("user@example.com").is_ok());
        assert!(validate_email("a@b.c").is_ok());
    }

    #[test]
    fn email_missing_at() {
        assert!(validate_email("userexample.com").is_err());
    }

    #[test]
    fn email_double_at() {
        assert!(validate_email("user@@example.com").is_ok()); // 第一个@后还有内容
    }
}

3. 组件测试

3.1 使用 dioxus/testing

Dioxus 提供了测试工具,可以在无头环境中渲染组件并断言:

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

    #[test]
    fn counter_renders() {
        // 创建一个测试虚拟 DOM
        let mut dom = VirtualDom::new(App);
        dom.rebuild_in_place();

        // 渲染为 HTML 进行断言
        let html = dioxus_ssr::render(&dom);
        assert!(html.contains("Count: 0"));
    }

    #[test]
    fn counter_increments() {
        // 更详细的测试需要模拟点击事件
        // 这需要 dioxus-testing crate 的支持
    }
}

3.2 手动测试组件

在没有完整测试框架的情况下,可以通过 SSR 渲染来验证组件输出:

fn render_to_string(component: Element) -> String {
    // 使用 dioxus-ssr 渲染
    let mut dom = VirtualDom::new_with_props(
        Wrapper,
        WrapperProps { element: component },
    );
    dom.rebuild_in_place();
    dioxus_ssr::render(&dom)
}

#[test]
fn test_article_card() {
    let summary = ArticleSummary {
        title: "测试文章".to_string(),
        slug: "test-article".to_string(),
        ..Default::default()
    };

    let html = render_to_string(rsx! {
        ArticleCard { summary }
    });

    assert!(html.contains("测试文章"));
    assert!(html.contains("/article?slug=test-article"));
}

4. 使用 console 调试

4.1 日志输出

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
    #[wasm_bindgen(js_namespace = console)]
    fn warn(s: &str);
    #[wasm_bindgen(js_namespace = console)]
    fn error(s: &str);
    #[wasm_bindgen(js_namespace = console)]
    fn info(s: &str);
    #[wasm_bindgen(js_namespace = console, js_name = group)]
    fn console_group(s: &str);
    #[wasm_bindgen(js_namespace = console, js_name = groupEnd)]
    fn console_group_end();
}

macro_rules! console_log {
    ($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}

// 使用
#[component]
fn DebugExample() -> Element {
    let count = use_signal(|| 0);

    use_effect(move || {
        console_log!("Count changed to: {}", count());
    });

    rsx! {
        button { onclick: move |_| count += 1, "Count: {count}" }
    }
}

4.2 在 Signal 变化时自动追踪

// 调试 Hook —— 在 Signal 变化时打印日志
fn use_debug<T: std::fmt::Debug + PartialEq + 'static>(
    label: &str,
    signal: Signal<T>,
) {
    use_effect(move || {
        console_log!("[{}] {:?}", label, signal());
    });
}

// 使用
let articles = use_signal(|| Vec::<ArticleSummary>::new());
use_debug("articles", articles);

5. 浏览器开发者工具

5.1 React DevTools 兼容

Dioxus 支持 React DevTools 浏览组件树:

// 在 main.rs 中启用
#[cfg(debug_assertions)]
dioxus::devtools::connect();

之后在 Chrome DevTools 中可以看到 Components 和 Profiler 标签。

5.2 Dioxus DevTools

# 安装 Dioxus DevTools 扩展
# 目前正在开发中,未来会提供浏览器扩展

5.3 网络请求调试

// 封装一个带日志的 HTTP 客户端
async fn fetch_json<T>(url: &str) -> Result<T, String>
where
    T: serde::de::DeserializeOwned,
{
    console_log!("[HTTP] GET {url}");
    let start = web_sys::window()
        .unwrap()
        .performance()
        .unwrap()
        .now();

    let result = reqwest::get(url).await;

    let elapsed = web_sys::window()
        .unwrap()
        .performance()
        .unwrap()
        .now() - start;

    match result {
        Ok(resp) => {
            console_log!("[HTTP] {url} ← {} ({:.0}ms)", resp.status(), elapsed);
            resp.json().await.map_err(|e| e.to_string())
        }
        Err(e) => {
            console_log!("[HTTP] {url} → ERROR: {e}");
            Err(e.to_string())
        }
    }
}

6. 性能问题排查

6.1 检测不必要的重渲染

// 在所有组件中添加渲染追踪
macro_rules! trace_render {
    ($name:expr) => {
        #[cfg(debug_assertions)]
        let _ = {
            console_log!("[Render] {}", $name);
        };
    };
}

#[component]
fn ArticleList(articles: Vec<ArticleSummary>) -> Element {
    trace_render!("ArticleList");

    rsx! { ... }
}

6.2 使用 Profiler

// 简单的手动 Profiling
fn use_timing(label: &str) -> impl Drop {
    let start = web_sys::window()
        .unwrap()
        .performance()
        .unwrap()
        .now();

    struct Timer {
        label: String,
        start: f64,
    }

    impl Drop for Timer {
        fn drop(&mut self) {
            let elapsed = web_sys::window()
                .unwrap()
                .performance()
                .unwrap()
                .now() - self.start;
            if elapsed > 16.0 { // 超过 1 帧 (60fps)
                console_log!("[Slow] {} took {:.0}ms", self.label, elapsed);
            }
        }
    }

    Timer {
        label: label.to_string(),
        start,
    }
}

// 使用
fn expensive_computation(data: &[u8]) -> Vec<u8> {
    let _timer = use_timing("expensive_computation");
    // ... 实际计算
}

7. E2E 测试(Playwright)

// tests/e2e/article.spec.js
const { test, expect } = require('@playwright/test');

test('用户能浏览文章列表并点击进入详情', async ({ page }) => {
    await page.goto('http://localhost:5051/blog');

    // 验证列表渲染
    await expect(page.locator('.article-card')).toHaveCount(8);

    // 点击第一篇文章
    await page.locator('.article-card').first().click();

    // 验证详情页
    await expect(page.locator('h1')).toBeVisible();
    await expect(page.locator('.prose-content')).toBeVisible();

    // 验证 TOC 侧边栏
    await expect(page.locator('.toc-link').first()).toBeVisible();
});

test('主题切换', async ({ page }) => {
    await page.goto('http://localhost:5051');

    // 点击主题切换按钮
    await page.locator('.theme-toggle').click();

    // 验证 HTML data-theme 属性
    const theme = await page.evaluate(() =>
        document.documentElement.getAttribute('data-theme')
    );
    expect(theme).toBe('dark');
});

8. 常见问题与调试

| 症状 | 可能原因 | 解决方案 | |------|---------|---------| | 组件不更新 | Signal 未被读取或写入 | 检查是否在闭包中正确 .read()/.write() | | use_resource 不触发 | 依赖的 Signal 未在闭包中读取 | 确保在 async 闭包中调用 signal() | | 样式不生效 | CSS 变量未定义 | 检查 HTML 中是否定义了 --xxx 变量 | | 路由不匹配 | Route 枚举派生不正确 | 检查 #[route()] 路径格式 | | WASM 加载失败 | 资源路径不对 | 检查 index.html 中的路径配置 | | 控制台无输出 | console_log 未链接 | 确认 wasm-bindgen 绑定正确 |

9. 调试 Checklist

在发布前逐一检查:

  • [ ] cargo test 全部通过
  • [ ] 浏览器控制台无报错
  • [ ] 网络请求状态码正常
  • [ ] 移动端布局适配
  • [ ] 暗夜模式切换正常
  • [ ] 文章加载和渲染正确
  • [ ] 路由跳转无 404
  • [ ] 表单提交有加载状态
  • [ ] 错误状态有用户提示

10. 小结

  • 单元测试覆盖纯函数:slugify、验证、编解码等工具函数
  • 组件测试验证渲染输出和交互行为
  • console_log 宏是 WASM 调试的基本工具
  • use_debug Hook 自动追踪 Signal 变化
  • E2E 测试用 Playwright 覆盖用户核心流程
  • 性能 Profiling 监控渲染耗时和网络请求
  • 建立发布前 Checklist,系统化确保质量
dioxustestingdebugunit-teste2e