zudo-tauri-wisdom

Type to search...

to open search from anywhere

コアクレートテストパターン

作成2026年4月3日更新2026年4月3日Takeshi Takatsudo

Tauri 依存のない独立した Rust クレートにビジネスロジックを抽出し、クロスプラットフォームテストを可能にするパターン

コアクレートテストパターン

Tauri コマンドは Tauri のランタイム(アプリハンドル、ウィンドウ、ステート管理)に依存し、それがさらにプラットフォーム固有の GUI ライブラリ(Linux の GTK、macOS の Cocoa)に依存する。これにより、ヘッドレス CI ランナー、WSL2、ディスプレイサーバーのない環境では cargo test が実行不可能になる。

解決策は、純粋なビジネスロジックを Tauri 依存ゼロの独立した core クレートに抽出することである。

問題

典型的な Tauri コマンドハンドラは以下のようになる。

#[tauri::command]
fn settings_get(state: State<'_, AppState>) -> Option<serde_json::Value> {
    let root = state.project_root.lock().unwrap();
    let path = Path::new(&*root).join(".zudotext.settings.json");
    let content = fs::read_to_string(&path).ok()?;
    serde_json::from_str(&content).ok()
}

ビジネスロジック(ファイル読み取り、JSON パース)はシンプルだが、関数シグネチャが tauri::State に紐付いている。動作中の Tauri アプリなしにはこの関数を呼び出せない。

コアクレートの構造

zudotext-core クレートは Tauri 型なしでビジネスロジックをミラーする。

tauri-app/
  core/                      # zudotext-core クレート
    Cargo.toml
    src/
      lib.rs                 # モジュール宣言
      settings.rs            # 設定の読み書き
      drafts.rs              # 下書き管理
      messages.rs            # メッセージ CRUD
      pins.rs                # ピンディレクトリ操作
      assets.rs              # アセットファイル管理
      draft.rs               # 単一下書き操作
      workspace_registry.rs  # マルチワークスペース管理
      helpers/               # 共有ユーティリティ
  src/                       # Tauri クレート(core に依存)
    main.rs
    commands/                # Tauri コマンドハンドラ(薄いラッパー)
    state.rs

コアクレートの依存関係

コアクレートは標準的なエコシステムクレートにのみ依存する。

[package]
name = "zudotext-core"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
dirs = "5"
base64 = "0.22"
trash = "5"

[target.'cfg(target_os = "macos")'.dependencies]
font-kit = "0.14"

[dev-dependencies]
tempfile = "3"

📝 Note

このクレートには tauri 依存が一切ない。font-kit 依存はシステムフォント列挙のために macOS でのみ条件付きコンパイルされるが、GUI ライブラリは引き込まない。

パスパラメータを受け取る純粋関数

キーパターンは State<'_, AppState> をプレーンな &str プロジェクトルートパラメータに置き換えることである。

コアクレート(純粋ロジック):

// core/src/settings.rs

pub fn settings_get(project_root: &str) -> Option<serde_json::Value> {
    if project_root.is_empty() {
        return None;
    }
    let settings_path = Path::new(project_root).join(".zudotext.settings.json");
    let content = fs::read_to_string(&settings_path).ok()?;
    serde_json::from_str(&content).ok()
}

pub fn settings_save(project_root: &str, settings: &serde_json::Value) -> bool {
    if project_root.is_empty() {
        return false;
    }
    if !settings.is_object() {
        return false;
    }
    let settings_path = Path::new(project_root).join(".zudotext.settings.json");
    match serde_json::to_string_pretty(settings) {
        Ok(json) => fs::write(&settings_path, json).is_ok(),
        Err(_) => false,
    }
}

Tauri クレート(薄いラッパー):

// src/commands/settings.rs

#[tauri::command]
fn settings_get(state: State<'_, AppState>) -> Option<serde_json::Value> {
    let root = state.project_root.lock().unwrap();
    zudotext_core::settings::settings_get(&root)
}

Tauri コマンドハンドラはステートからプロジェクトルートを抽出してコア関数に委譲するだけである。

tempfile によるテスト

コア関数がパス文字列を受け取るため、テストは既知の内容で一時ディレクトリを作成する。

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

    #[test]
    fn test_settings_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_str().unwrap();
        let settings = json!({
            "theme": "dark",
            "draftCount": 5,
            "activeDraft": 2,
            "pins": [{"path": "docs", "title": "Docs"}]
        });

        assert!(settings_save(root, &settings));
        let loaded = settings_get(root).unwrap();
        assert_eq!(loaded, settings);
    }

    #[test]
    fn test_settings_get_returns_none_for_empty_root() {
        assert_eq!(settings_get(""), None);
    }

    #[test]
    fn test_settings_save_rejects_non_object() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_str().unwrap();

        assert!(!settings_save(root, &json!("string")));
        assert!(!settings_save(root, &json!(42)));
        assert!(!settings_save(root, &json!([1, 2, 3])));
    }
}

これらのテストはシンプルな cargo test で実行できる。ディスプレイサーバーも、Tauri ランタイムも、GTK も不要。

より複雑な例:下書き管理

下書きモジュールは、より複雑なビジネスロジックでこのパターンを示す。下書きファイルはディスク上に inbox/draft1.mdinbox/draft2.md として存在する。

// core/src/drafts.rs

pub fn discover_draft_count(project_root: &str) -> u32 {
    let inbox = Path::new(project_root).join("inbox");
    let mut max: u32 = 0;
    if let Ok(entries) = fs::read_dir(&inbox) {
        for entry in entries.flatten() {
            if let Some(n) = parse_draft_number(entry.file_name().to_str().unwrap_or("")) {
                if n > max { max = n; }
            }
        }
    }
    if max == 0 { 1 } else { max }
}

pub fn drafts_tidy_up(project_root: &str) -> Option<TidyUpResult> {
    // Collect non-empty drafts, compact gaps, remove trailing files
    // ...complex logic with disk I/O...
}

テストはリアルなディレクトリ構造を作成する。

#[test]
fn test_drafts_tidy_up_compacts_gaps() {
    let dir = setup_project(json!({"activeDraft": 4}));
    let root = dir.path().to_str().unwrap();

    let inbox = dir.path().join("inbox");
    fs::create_dir_all(&inbox).unwrap();
    fs::write(inbox.join("draft1.md"), "Draft 1").unwrap();
    fs::write(inbox.join("draft2.md"), "").unwrap();       // 空
    fs::write(inbox.join("draft3.md"), "  \n  ").unwrap(); // 空白のみ
    fs::write(inbox.join("draft4.md"), "Draft 4").unwrap();

    let result = drafts_tidy_up(root).unwrap();
    assert_eq!(result.new_count, 2);
    assert_eq!(result.new_active, 2); // draft4 がポジション 2 に移動
}

テストの実行

# すべてのコアクレートテストを実行(どのプラットフォームでも動作)
cd tauri-app/core && cargo test

# デバッグ用に出力付きで実行
cd tauri-app/core && cargo test -- --nocapture

これは以下の環境で動作する。

  • macOS(開発マシン)
  • WSL2(ディスプレイサーバーなし)
  • GitHub Actions ヘッドレスランナー(GTK なし)
  • 任意の Linux サーバー

このパターンを使うべき場面

⚠️ Warning

すべての Tauri コマンドロジックをコアクレートに移すべきではない。以下の場合は Tauri クレートに保持する。

  • アプリハンドルが必要(例:ウィンドウ管理、メニュー作成)
  • Tauri プラグインが必要(例:ネイティブダイアログ、システムトレイ)
  • Tauri 固有のステートを管理(例:ウォッチャーハンドル、PTY プロセス参照)

以下の場合はコアクレートに移す。

  • プロジェクトルートパスに基づいてファイルを読み書きする
  • データ構造(設定、フロントマター)をパースまたはバリデーションする
  • Tauri API を必要としないビジネスロジックを実行する
  • プラットフォーム依存なしの CI でのテストが有益

まとめ

パターンはシンプルである。Tauri ステートの代わりにパスパラメータを受け取り、そのパスに対してファイル I/O を行い、テストでは tempfile を使う。 Tauri コマンドハンドラは、ステートからパスを抽出してコア関数を呼び出す1行の委譲になる。この分離はテスト速度と CI 互換性において即座に効果を発揮する。