第十九章:测试策略与调试技巧
博客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_debugHook 自动追踪 Signal 变化- E2E 测试用 Playwright 覆盖用户核心流程
- 性能 Profiling 监控渲染耗时和网络请求
- 建立发布前 Checklist,系统化确保质量
dioxustestingdebugunit-teste2e