Home/Testing Strategies/Integration Testing

Integration Testing

Testing across module boundaries

intermediate
testingintegrationtests-dir
šŸŽ® Interactive Playground

What is Integration Testing?

Integration testing verifies that different parts of your system work together correctly. In Rust, integration tests live in the tests/ directory and test your crate as an external user would.

The Problem

Integration testing addresses:

  • Cross-module behavior: Testing interactions between components
  • Public API verification: Ensuring external interface works
  • System boundaries: Testing I/O, databases, external services
  • Realistic scenarios: End-to-end workflows

Example Code

// src/lib.rs - The library we're testing

pub mod auth {
    use std::collections::HashMap;

    #[derive(Debug, Clone)]
    pub struct User {
        pub id: u64,
        pub username: String,
        pub email: String,
        password_hash: String,
    }

    pub struct AuthService {
        users: HashMap<u64, User>,
        sessions: HashMap<String, u64>,
        next_id: u64,
    }

    #[derive(Debug, PartialEq)]
    pub enum AuthError {
        UserNotFound,
        InvalidPassword,
        UsernameTaken,
        InvalidEmail,
        SessionExpired,
    }

    impl AuthService {
        pub fn new() -> Self {
            AuthService {
                users: HashMap::new(),
                sessions: HashMap::new(),
                next_id: 1,
            }
        }

        pub fn register(&mut self, username: &str, email: &str, password: &str)
            -> Result<u64, AuthError>
        {
            // Check if username is taken
            if self.users.values().any(|u| u.username == username) {
                return Err(AuthError::UsernameTaken);
            }

            // Validate email
            if !email.contains('@') {
                return Err(AuthError::InvalidEmail);
            }

            let id = self.next_id;
            self.next_id += 1;

            self.users.insert(id, User {
                id,
                username: username.to_string(),
                email: email.to_string(),
                password_hash: Self::hash_password(password),
            });

            Ok(id)
        }

        pub fn login(&mut self, username: &str, password: &str)
            -> Result<String, AuthError>
        {
            let user = self.users.values()
                .find(|u| u.username == username)
                .ok_or(AuthError::UserNotFound)?;

            if Self::hash_password(password) != user.password_hash {
                return Err(AuthError::InvalidPassword);
            }

            // Generate session token
            let token = format!("session_{}_{}", user.id, rand_string());
            self.sessions.insert(token.clone(), user.id);

            Ok(token)
        }

        pub fn get_user_by_session(&self, token: &str) -> Result<&User, AuthError> {
            let user_id = self.sessions.get(token)
                .ok_or(AuthError::SessionExpired)?;

            self.users.get(user_id)
                .ok_or(AuthError::UserNotFound)
        }

        pub fn logout(&mut self, token: &str) -> bool {
            self.sessions.remove(token).is_some()
        }

        fn hash_password(password: &str) -> String {
            // Simple hash for demonstration (use bcrypt/argon2 in production!)
            format!("hash_{}", password)
        }
    }

    impl Default for AuthService {
        fn default() -> Self {
            Self::new()
        }
    }

    fn rand_string() -> String {
        use std::time::{SystemTime, UNIX_EPOCH};
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .subsec_nanos();
        format!("{:x}", nanos)
    }
}

pub mod storage {
    use std::collections::HashMap;
    use std::io::{self, Read, Write};
    use std::path::Path;

    pub trait Storage {
        fn get(&self, key: &str) -> Option<Vec<u8>>;
        fn set(&mut self, key: &str, value: &[u8]) -> io::Result<()>;
        fn delete(&mut self, key: &str) -> bool;
        fn keys(&self) -> Vec<String>;
    }

    /// In-memory storage for testing
    #[derive(Default)]
    pub struct MemoryStorage {
        data: HashMap<String, Vec<u8>>,
    }

    impl MemoryStorage {
        pub fn new() -> Self {
            Self::default()
        }
    }

    impl Storage for MemoryStorage {
        fn get(&self, key: &str) -> Option<Vec<u8>> {
            self.data.get(key).cloned()
        }

        fn set(&mut self, key: &str, value: &[u8]) -> io::Result<()> {
            self.data.insert(key.to_string(), value.to_vec());
            Ok(())
        }

        fn delete(&mut self, key: &str) -> bool {
            self.data.remove(key).is_some()
        }

        fn keys(&self) -> Vec<String> {
            self.data.keys().cloned().collect()
        }
    }
}

pub mod app {
    use crate::auth::{AuthService, AuthError};
    use crate::storage::{Storage, MemoryStorage};

    pub struct Application<S: Storage> {
        pub auth: AuthService,
        pub storage: S,
    }

    impl<S: Storage> Application<S> {
        pub fn new(storage: S) -> Self {
            Application {
                auth: AuthService::new(),
                storage,
            }
        }

        pub fn store_user_data(&mut self, token: &str, key: &str, data: &[u8])
            -> Result<(), String>
        {
            let user = self.auth.get_user_by_session(token)
                .map_err(|e| format!("Auth error: {:?}", e))?;

            let prefixed_key = format!("user_{}_{}", user.id, key);
            self.storage.set(&prefixed_key, data)
                .map_err(|e| format!("Storage error: {}", e))
        }

        pub fn get_user_data(&self, token: &str, key: &str)
            -> Result<Option<Vec<u8>>, String>
        {
            let user = self.auth.get_user_by_session(token)
                .map_err(|e| format!("Auth error: {:?}", e))?;

            let prefixed_key = format!("user_{}_{}", user.id, key);
            Ok(self.storage.get(&prefixed_key))
        }
    }

    impl Default for Application<MemoryStorage> {
        fn default() -> Self {
            Self::new(MemoryStorage::new())
        }
    }
}
// tests/integration_test.rs - Integration tests

// Import the crate being tested
use my_crate::auth::{AuthService, AuthError};
use my_crate::storage::{Storage, MemoryStorage};
use my_crate::app::Application;

// Common test utilities module
mod common;

// Test the full auth workflow
#[test]
fn test_full_auth_workflow() {
    let mut auth = AuthService::new();

    // Register a new user
    let user_id = auth.register("alice", "alice@example.com", "password123")
        .expect("Registration should succeed");
    assert!(user_id > 0);

    // Login
    let token = auth.login("alice", "password123")
        .expect("Login should succeed");
    assert!(!token.is_empty());

    // Access user info via session
    let user = auth.get_user_by_session(&token)
        .expect("Should get user by session");
    assert_eq!(user.username, "alice");

    // Logout
    assert!(auth.logout(&token));

    // Session should be invalid after logout
    let result = auth.get_user_by_session(&token);
    assert_eq!(result.unwrap_err(), AuthError::SessionExpired);
}

#[test]
fn test_duplicate_registration() {
    let mut auth = AuthService::new();

    auth.register("bob", "bob@example.com", "pass1")
        .expect("First registration should succeed");

    let result = auth.register("bob", "other@example.com", "pass2");
    assert_eq!(result.unwrap_err(), AuthError::UsernameTaken);
}

#[test]
fn test_invalid_login() {
    let mut auth = AuthService::new();

    auth.register("charlie", "charlie@example.com", "correct")
        .expect("Registration should succeed");

    // Wrong password
    let result = auth.login("charlie", "wrong");
    assert_eq!(result.unwrap_err(), AuthError::InvalidPassword);

    // Wrong username
    let result = auth.login("nobody", "password");
    assert_eq!(result.unwrap_err(), AuthError::UserNotFound);
}

// Test storage integration
#[test]
fn test_storage_operations() {
    let mut storage = MemoryStorage::new();

    // Store data
    storage.set("key1", b"value1").unwrap();
    storage.set("key2", b"value2").unwrap();

    // Retrieve data
    assert_eq!(storage.get("key1"), Some(b"value1".to_vec()));
    assert_eq!(storage.get("key2"), Some(b"value2".to_vec()));
    assert_eq!(storage.get("nonexistent"), None);

    // List keys
    let keys = storage.keys();
    assert!(keys.contains(&"key1".to_string()));
    assert!(keys.contains(&"key2".to_string()));

    // Delete
    assert!(storage.delete("key1"));
    assert!(!storage.delete("key1")); // Already deleted
    assert_eq!(storage.get("key1"), None);
}

// Test application integration (auth + storage)
#[test]
fn test_application_user_data() {
    let mut app = Application::default();

    // Register and login
    app.auth.register("david", "david@example.com", "pass")
        .expect("Registration should succeed");

    let token = app.auth.login("david", "pass")
        .expect("Login should succeed");

    // Store user data
    app.store_user_data(&token, "preferences", b"dark_mode")
        .expect("Should store data");

    // Retrieve user data
    let data = app.get_user_data(&token, "preferences")
        .expect("Should retrieve data");
    assert_eq!(data, Some(b"dark_mode".to_vec()));

    // Different user shouldn't access this data
    app.auth.register("eve", "eve@example.com", "pass2").unwrap();
    let eve_token = app.auth.login("eve", "pass2").unwrap();

    let eve_data = app.get_user_data(&eve_token, "preferences")
        .expect("Query should succeed");
    assert_eq!(eve_data, None); // Eve has no preferences stored
}

#[test]
fn test_unauthenticated_access() {
    let mut app = Application::default();

    // Try to store data without valid session
    let result = app.store_user_data("invalid_token", "key", b"value");
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("Auth error"));
}

// Async integration test (if using async)
#[cfg(feature = "async")]
#[tokio::test]
async fn test_async_operation() {
    // Async test code here
}

// Test with setup/teardown
struct TestContext {
    app: Application<MemoryStorage>,
    user_token: String,
}

impl TestContext {
    fn new() -> Self {
        let mut app = Application::default();

        // Set up test user
        app.auth.register("test_user", "test@example.com", "test_pass")
            .expect("Setup: registration should succeed");

        let token = app.auth.login("test_user", "test_pass")
            .expect("Setup: login should succeed");

        TestContext {
            app,
            user_token: token,
        }
    }
}

#[test]
fn test_with_context() {
    let mut ctx = TestContext::new();

    // Test using pre-configured context
    ctx.app.store_user_data(&ctx.user_token, "test_key", b"test_value")
        .expect("Should store data");

    let data = ctx.app.get_user_data(&ctx.user_token, "test_key")
        .expect("Should retrieve data");

    assert_eq!(data, Some(b"test_value".to_vec()));
}
// tests/common/mod.rs - Shared test utilities

use my_crate::auth::AuthService;
use my_crate::app::Application;
use my_crate::storage::MemoryStorage;

/// Create an authenticated test application
pub fn create_authenticated_app() -> (Application<MemoryStorage>, String) {
    let mut app = Application::default();

    app.auth.register("test", "test@test.com", "password")
        .expect("Registration failed");

    let token = app.auth.login("test", "password")
        .expect("Login failed");

    (app, token)
}

/// Helper to assert that a result is an error containing a specific message
pub fn assert_error_contains<T, E: std::fmt::Debug>(
    result: Result<T, E>,
    expected_substring: &str,
) {
    match result {
        Ok(_) => panic!("Expected error, got Ok"),
        Err(e) => {
            let error_msg = format!("{:?}", e);
            assert!(
                error_msg.contains(expected_substring),
                "Error '{}' should contain '{}'",
                error_msg,
                expected_substring
            );
        }
    }
}

Why This Works

  1. tests/ directory: Cargo automatically compiles as integration tests
  2. External perspective: Tests use only public API
  3. Shared utilities: tests/common/mod.rs for reusable helpers
  4. Feature flags: Conditional async tests with #[cfg(feature = "async")]

Test Organization

tests/
ā”œā”€ā”€ integration_test.rs    # Main integration tests
ā”œā”€ā”€ auth_tests.rs          # Auth-specific tests
ā”œā”€ā”€ storage_tests.rs       # Storage-specific tests
└── common/
    └── mod.rs             # Shared utilities

Integration vs Unit Tests

| Aspect | Unit Tests | Integration Tests |

|--------|------------|-------------------|

| Location | src/.rs | tests/.rs |

| Scope | Single function/module | Multiple modules |

| Access | Public + private | Public only |

| Speed | Fast | Slower |

| Dependencies | Mocked | Real or mocked |

When to Use

  • API contracts: Verify public interface behavior
  • Cross-module flows: User registration → login → action
  • External dependencies: Database, file system, network
  • Regression testing: End-to-end scenarios

āš ļø Anti-patterns

// DON'T: Test implementation details
#[test]
fn test_internal_hash_format() {
    // This tests private implementation, not behavior
    let hash = AuthService::hash_password("test");
    assert!(hash.starts_with("hash_"));
}

// DO: Test observable behavior
#[test]
fn test_password_validation() {
    let mut auth = AuthService::new();
    auth.register("user", "user@example.com", "correct").unwrap();

    assert!(auth.login("user", "correct").is_ok());
    assert!(auth.login("user", "wrong").is_err());
}

Exercises

  1. Add tests for concurrent access to the AuthService
  2. Create integration tests with file-based storage
  3. Write tests that verify cleanup after errors
  4. Add benchmark tests for performance-critical paths

šŸŽ® Try it Yourself

šŸŽ®

Integration Testing - Playground

Run this code in the official Rust Playground