Mocking dependencies com mockall

1. Introdução ao Mocking em Rust

Em testes de software, isolar a unidade sob teste de suas dependências externas é crucial para garantir previsibilidade e reprodutibilidade. Mocking permite substituir componentes reais (como bancos de dados, APIs HTTP ou sistemas de arquivos) por versões controladas que simulam comportamentos específicos.

O Rust apresenta desafios únicos para mocking devido ao seu sistema de ownership, lifetimes e traits. Diferentemente de linguagens com garbage collection, onde objetos mock podem ser facilmente criados e descartados, no Rust precisamos gerenciar cuidadosamente a propriedade e os tempos de vida dos objetos mock.

A biblioteca mockall surge como uma solução elegante para esses desafios, oferecendo:

  • Geração automática de mocks via macros
  • Suporte completo a traits com genéricos e lifetimes
  • Integração com código assíncrono
  • Verificação rigorosa de chamadas e argumentos

2. Configuração Inicial e Primeiro Mock

Para começar, adicione mockall como dependência de desenvolvimento no seu Cargo.toml:

[dev-dependencies]
mockall = "0.12"

Vamos criar um trait simples para mockar — uma interface de banco de dados:

pub trait Database {
    fn get_user(&self, id: u32) -> Option<String>;
    fn save_user(&mut self, id: u32, name: &str) -> Result<(), String>;
}

Com mockall, podemos gerar automaticamente um mock usando #[automock]:

use mockall::automock;

#[automock]
pub trait Database {
    fn get_user(&self, id: u32) -> Option<String>;
    fn save_user(&mut self, id: u32, name: &str) -> Result<(), String>;
}

Alternativamente, para maior controle, podemos usar a macro mock!:

mockall::mock! {
    pub Database {
        fn get_user(&self, id: u32) -> Option<String>;
        fn save_user(&mut self, id: u32, name: &str) -> Result<(), String>;
    }
}

3. Definindo Expectativas e Comportamentos

O poder do mockall está em suas expectativas. Cada método do mock gera um método expect_* correspondente:

#[test]
fn test_user_service() {
    let mut mock_db = MockDatabase::new();

    // Espera uma chamada com argumento específico
    mock_db.expect_get_user()
        .with(predicate::eq(42))
        .returning(|_| Some("Alice".to_string()));

    // Espera exatamente uma chamada
    mock_db.expect_save_user()
        .with(predicate::eq(1), predicate::eq("Bob"))
        .times(1)
        .returning(|_, _| Ok(()));

    let service = UserService::new(mock_db);
    let user = service.get_user(42);
    assert_eq!(user, Some("Alice".to_string()));
}

Matchers permitem especificar argumentos de forma flexível:

use mockall::predicate;

// Qualquer valor u32
.with(predicate::always())

// Função personalizada
.withf(|id: &u32| *id > 0 && *id < 100)

// Combinação de predicados
.with(predicate::eq(42), predicate::in_iter(vec!["Alice", "Bob"]))

4. Mockando Traits com Genéricos e Lifetimes

Traits genéricos requerem configuração adicional. Use #[automock(type ...)] para especificar os tipos concretos:

#[automock]
pub trait Repository<T: Clone + 'static> {
    fn find(&self, id: u32) -> Option<T>;
    fn save(&mut self, item: T) -> Result<(), String>;
}

// No teste, especifique o tipo concreto:
type MockUserRepo = MockRepository<User>;

#[test]
fn test_repo() {
    let mut mock_repo = MockRepository::<User>::new();
    mock_repo.expect_find()
        .with(predicate::eq(1))
        .returning(|_| Some(User { id: 1, name: "Alice".into() }));
}

Para traits com lifetimes explícitos:

#[automock]
pub trait DataProcessor<'a> {
    fn process(&self, data: &'a [u8]) -> Vec<u8>;
}

// O mock lida automaticamente com o lifetime
let mut mock = MockDataProcessor::new();
mock.expect_process()
    .with(predicate::always())
    .returning(|data: &[u8]| data.to_vec());

5. Testando Async Code com mockall

Mockall suporta traits assíncronos usando #[async_trait]:

use mockall::automock;
use async_trait::async_trait;

#[async_trait]
#[automock]
pub trait AsyncService {
    async fn fetch_data(&self, id: u32) -> Result<String, String>;
}

#[tokio::test]
async fn test_async_service() {
    let mut mock = MockAsyncService::new();

    mock.expect_fetch_data()
        .with(predicate::eq(42))
        .returning(|_| Box::pin(async { Ok("data".to_string()) }));

    let service = MyService::new(mock);
    let result = service.get_data(42).await;
    assert!(result.is_ok());
}

Note que funções async retornam Pin<Box<dyn Future>>, então precisamos encapsular o retorno em Box::pin.

6. Técnicas Avançadas de Mocking

Mock de métodos estáticos (structs sem traits)

mockall::mock! {
    pub MyStruct {
        fn static_method(x: i32) -> i32;
        fn method(&self, x: i32) -> i32;
    }
}

#[test]
fn test_static() {
    let ctx = MockMyStruct::static_method_context();
    ctx.expect()
        .with(predicate::eq(10))
        .returning(|x| x * 2);

    assert_eq!(MockMyStruct::static_method(10), 20);
}

Verificação de ordem de chamadas

use mockall::Sequence;

#[test]
fn test_ordered_calls() {
    let mut mock = MockDatabase::new();
    let mut seq = Sequence::new();

    mock.expect_get_user()
        .times(1)
        .in_sequence(&mut seq)
        .returning(|_| Some("Alice".to_string()));

    mock.expect_save_user()
        .times(1)
        .in_sequence(&mut seq)
        .returning(|_, _| Ok(()));
}

Mock de múltiplas dependências

#[test]
fn test_multiple_deps() {
    let mut mock_db = MockDatabase::new();
    let mut mock_cache = MockCache::new();

    mock_db.expect_get_user().returning(|_| Some("Alice".into()));
    mock_cache.expect_get().returning(|_| None);
    mock_cache.expect_set().returning(|_, _| ());

    let service = UserService::new(mock_db, mock_cache);
    assert_eq!(service.get_user_cached(1), Some("Alice".to_string()));
}

7. Boas Práticas e Padrões

Mantendo mocks simples

Evite expectativas excessivas — mocke apenas o necessário para o teste:

// Ruim: expectativas desnecessárias
mock.expect_get_user().returning(|_| None);
mock.expect_save_user().returning(|_, _| Ok(())); // nunca será chamada

// Bom: apenas o que o teste precisa
mock.expect_get_user().returning(|_| None);

Testando casos de erro

#[test]
fn test_error_case() {
    let mut mock = MockDatabase::new();
    mock.expect_save_user()
        .returning(|_, _| Err("connection failed".to_string()));

    let service = UserService::new(mock);
    let result = service.create_user("Alice");
    assert!(result.is_err());
}

Reutilizando mocks com fixtures

fn setup_mock_db() -> MockDatabase {
    let mut mock = MockDatabase::new();
    mock.expect_get_user()
        .returning(|id| if id == 1 { Some("Alice".into()) } else { None });
    mock
}

#[test]
fn test_with_fixture() {
    let mock = setup_mock_db();
    let service = UserService::new(mock);
    assert_eq!(service.get_user(1), Some("Alice".into()));
}

8. Limitações e Alternativas

Limitações do mockall

  • Não funciona com FFI (Foreign Function Interface)
  • Pode ter problemas com macros complexas
  • Traits com tipos associados complexos podem exigir configuração manual

Alternativas

  • double: Biblioteca leve focada em simplicidade
  • faux: Usa geração procedural para mocks
  • injectable: Abordagem baseada em injeção de dependência

Quando usar injeção real vs mocking

Para testes de integração, considere usar implementações reais leves (como SQLite em memória) em vez de mocks. Mocking é ideal para testes unitários onde o comportamento da dependência precisa ser controlado precisamente.

Referências