Back to list
imehr

rust-testing-guidelines

by imehr

0🍴 0📅 Jan 15, 2026

SKILL.md


name: rust-testing-guidelines description: Rust testing patterns with cargo test and mockall version: 1.0.0 triggers:

  • rust test
  • cargo test
  • mockall
  • rust mock
  • rust unit test

Rust Testing Guidelines

Overview

This skill provides patterns for testing Rust applications with cargo test, including unit tests, integration tests, mocking with mockall, and async testing.

Quick Reference

PatternWhen to UseExample
Unit TestTest single function#[test]
Integration TestTest modules togethertests/ directory
MockReplace dependencies#[automock]
Async TestTest async code#[tokio::test]

Project Structure

my_project/
├── src/
│   ├── lib.rs
│   └── services/
│       └── user_service.rs
├── tests/                    # Integration tests
│   ├── common/
│   │   └── mod.rs           # Shared test utilities
│   └── user_tests.rs
└── Cargo.toml

Core Patterns

Pattern 1: Unit Tests

// ✅ CORRECT: Unit tests in the same file
// src/services/user_service.rs

pub fn validate_email(email: &str) -> bool {
    email.contains('@') && email.contains('.')
}

pub fn hash_password(password: &str) -> String {
    // Implementation
    format!("hashed_{}", password)
}

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

    #[test]
    fn test_validate_email_with_valid_email() {
        assert!(validate_email("user@example.com"));
    }

    #[test]
    fn test_validate_email_with_invalid_email() {
        assert!(!validate_email("invalid"));
        assert!(!validate_email("no-at-sign.com"));
        assert!(!validate_email("no-dot@com"));
    }

    #[test]
    fn test_hash_password_returns_hashed_value() {
        let result = hash_password("mypassword");
        assert!(result.starts_with("hashed_"));
        assert!(result.contains("mypassword"));
    }

    #[test]
    #[should_panic(expected = "empty password")]
    fn test_hash_password_panics_on_empty() {
        hash_password("");
    }
}

Pattern 2: Async Tests

// ✅ CORRECT: Async test with tokio
use tokio;

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

    #[tokio::test]
    async fn test_fetch_user_returns_user() {
        let pool = setup_test_db().await;
        let repo = UserRepository::new(pool);

        let user = repo.create("test@example.com", "Test").await.unwrap();
        let fetched = repo.find_by_id(user.id).await.unwrap();

        assert!(fetched.is_some());
        assert_eq!(fetched.unwrap().email, "test@example.com");
    }

    #[tokio::test]
    async fn test_fetch_user_returns_none_for_missing() {
        let pool = setup_test_db().await;
        let repo = UserRepository::new(pool);

        let result = repo.find_by_id(999).await.unwrap();
        assert!(result.is_none());
    }
}

Pattern 3: Mocking with mockall

// ✅ CORRECT: Trait-based mocking
use mockall::{automock, predicate::*};
use async_trait::async_trait;

#[async_trait]
#[automock]
pub trait UserRepository {
    async fn find_by_id(&self, id: i64) -> Result<Option<User>, DbError>;
    async fn create(&self, email: &str, name: &str) -> Result<User, DbError>;
    async fn exists_by_email(&self, email: &str) -> Result<bool, DbError>;
}

pub struct UserService<R: UserRepository> {
    repo: R,
}

impl<R: UserRepository> UserService<R> {
    pub fn new(repo: R) -> Self {
        Self { repo }
    }

    pub async fn create_user(&self, email: &str, name: &str) -> Result<User, AppError> {
        if self.repo.exists_by_email(email).await? {
            return Err(AppError::Conflict("Email exists"));
        }
        let user = self.repo.create(email, name).await?;
        Ok(user)
    }
}

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

    #[tokio::test]
    async fn test_create_user_success() {
        let mut mock_repo = MockUserRepository::new();

        mock_repo
            .expect_exists_by_email()
            .with(eq("new@example.com"))
            .times(1)
            .returning(|_| Ok(false));

        mock_repo
            .expect_create()
            .with(eq("new@example.com"), eq("New User"))
            .times(1)
            .returning(|email, name| {
                Ok(User {
                    id: 1,
                    email: email.to_string(),
                    name: name.to_string(),
                })
            });

        let service = UserService::new(mock_repo);
        let result = service.create_user("new@example.com", "New User").await;

        assert!(result.is_ok());
        let user = result.unwrap();
        assert_eq!(user.email, "new@example.com");
    }

    #[tokio::test]
    async fn test_create_user_duplicate_email() {
        let mut mock_repo = MockUserRepository::new();

        mock_repo
            .expect_exists_by_email()
            .returning(|_| Ok(true));

        let service = UserService::new(mock_repo);
        let result = service.create_user("existing@example.com", "Test").await;

        assert!(matches!(result, Err(AppError::Conflict(_))));
    }
}

Pattern 4: Test Fixtures

// tests/common/mod.rs - Shared test utilities
use sqlx::PgPool;
use once_cell::sync::Lazy;
use tokio::sync::OnceCell;

static TEST_DB: OnceCell<PgPool> = OnceCell::const_new();

pub async fn get_test_pool() -> &'static PgPool {
    TEST_DB.get_or_init(|| async {
        let url = std::env::var("TEST_DATABASE_URL")
            .unwrap_or_else(|_| "postgres://test:test@localhost/test".to_string());
        PgPool::connect(&url).await.expect("Failed to connect")
    }).await
}

pub fn sample_user() -> User {
    User {
        id: 1,
        email: "test@example.com".to_string(),
        name: "Test User".to_string(),
        created_at: Utc::now(),
    }
}

pub struct TestContext {
    pub pool: PgPool,
}

impl TestContext {
    pub async fn new() -> Self {
        let pool = get_test_pool().await.clone();
        // Clean up before test
        sqlx::query!("DELETE FROM users").execute(&pool).await.unwrap();
        Self { pool }
    }
}

Pattern 5: Integration Tests

// tests/user_tests.rs
mod common;

use common::TestContext;

#[tokio::test]
async fn test_user_crud_flow() {
    let ctx = TestContext::new().await;
    let repo = UserRepository::new(ctx.pool.clone());

    // Create
    let user = repo.create("test@example.com", "Test User").await.unwrap();
    assert_eq!(user.email, "test@example.com");

    // Read
    let fetched = repo.find_by_id(user.id).await.unwrap().unwrap();
    assert_eq!(fetched.name, "Test User");

    // Update
    let updated = repo.update(user.id, "Updated Name").await.unwrap().unwrap();
    assert_eq!(updated.name, "Updated Name");

    // Delete
    let deleted = repo.delete(user.id).await.unwrap();
    assert!(deleted);

    // Verify deleted
    let not_found = repo.find_by_id(user.id).await.unwrap();
    assert!(not_found.is_none());
}

#[tokio::test]
async fn test_api_create_user() {
    let ctx = TestContext::new().await;
    let app = create_test_app(ctx.pool.clone()).await;

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/users")
                .header("Content-Type", "application/json")
                .body(Body::from(r#"{"email":"new@test.com","name":"New"}"#))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::CREATED);
}

Pattern 6: Property-Based Testing

// Using proptest for property-based testing
use proptest::prelude::*;

proptest! {
    #[test]
    fn test_email_validation_never_panics(email in "\\PC*") {
        // Should never panic, regardless of input
        let _ = validate_email(&email);
    }

    #[test]
    fn test_valid_emails_accepted(
        local in "[a-z]{1,10}",
        domain in "[a-z]{1,10}",
        tld in "[a-z]{2,4}"
    ) {
        let email = format!("{}@{}.{}", local, domain, tld);
        assert!(validate_email(&email));
    }
}

Test Attributes

#[test]                           // Basic test
#[tokio::test]                    // Async test
#[ignore]                         // Skip by default
#[should_panic]                   // Expect panic
#[should_panic(expected = "msg")] // Expect specific panic

Running Tests

# Run all tests
cargo test

# Run specific test
cargo test test_name

# Run tests in specific module
cargo test module_name::

# Run with output
cargo test -- --nocapture

# Run ignored tests
cargo test -- --ignored

# Run specific integration test
cargo test --test user_tests

Anti-Patterns

Don't: Test Implementation Details

// ❌ BAD: Testing internal state
#[test]
fn test_internal_counter() {
    let service = MyService::new();
    service.do_something();
    assert_eq!(service.internal_counter, 1);  // Implementation detail!
}

// ✅ GOOD: Test observable behavior
#[test]
fn test_operation_result() {
    let service = MyService::new();
    let result = service.do_something();
    assert_eq!(result, expected_value);
}

Don't: Hardcoded Sleep

// ❌ BAD: Arbitrary sleep
#[tokio::test]
async fn test_async_operation() {
    start_background_task();
    tokio::time::sleep(Duration::from_secs(5)).await;  // Slow!
    assert!(is_complete());
}

// ✅ GOOD: Wait for condition
#[tokio::test]
async fn test_async_operation() {
    start_background_task();
    tokio::time::timeout(Duration::from_secs(5), wait_for_completion())
        .await
        .expect("Timed out");
}

Resources

TopicLink
Unit Testing[mdc:resources/unit-tests.md]
Integration Testing[mdc:resources/integration-tests.md]
Mocking[mdc:resources/mocking.md]
Fixtures[mdc:resources/fixtures.md]

Score

Total Score

50/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon