Rust 트레이트 모킹: mockall 크레이트 사용법

Rust 트레이트 모킹: mockall 크레이트 사용법

Rust로 어느 정도 규모가 있는 코드를 짜다 보면 한 번쯤 부딪히는 문제가 있는데요. 바로 외부 의존성을 단위 테스트에서 어떻게 분리할 것인가 하는 문제입니다. 데이터베이스에 붙는 함수나 결제 게이트웨이를 호출하는 로직을, 매번 실제 서비스를 띄워놓고 검증할 수는 없잖아요.

자바에서는 Mockito, 파이썬에서는 unittest.mock이 이런 역할을 해주는데요. Rust 진영에서 이에 해당하는 라이브러리가 바로 mockall입니다. mockito가 HTTP 요청을 가로채는 도구라면, mockall은 트레이트의 구현체를 통째로 흉내내는 도구라고 보시면 됩니다. 이 글에서는 mockall로 트레이트를 어떻게 모킹하는지, 그리고 기대값을 어떻게 검증하는지를 차근차근 풀어보겠습니다.

mockall이 해결해 주는 문제

먼저 간단한 시나리오부터 출발해 볼게요. 사용자 정보를 저장소에서 읽어와서 인사말을 만들어주는 서비스가 있다고 해봅시다.

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

impl<R: UserRepository> UserService<R> {
    fn greet(&self, id: u64) -> String {
        match self.repo.find_name(id) {
            Some(name) => format!("안녕하세요, {}님!", name),
            None => "사용자를 찾을 수 없습니다.".to_string(),
        }
    }
}

여기서 UserRepository가 트레이트라면 실제 구현체는 데이터베이스에 붙겠죠. 하지만 greet이 인사말을 잘 만드는지 확인하려고 매번 DB를 띄우고 싶지는 않습니다. 원하는 건 find_name이 특정 ID에 대해 정해진 값을 돌려주는 가짜 구현체뿐이거든요. 이때 mockall이 그 가짜 구현체를 자동으로 만들어 줍니다.

설치하기

mockall은 테스트에서만 쓰이니까 Cargo.toml[dev-dependencies] 섹션에 추가합니다. 운영 바이너리에는 포함되지 않도록 분리해 두는 것이 일반적인 패턴입니다.

Cargo.toml
[dev-dependencies]
mockall = "0.13"

다만 #[automock] 매크로를 트레이트 정의 자체에 다는 경우에는 트레이트가 라이브러리 본체에 있어야 하므로 일반 [dependencies]에 넣어야 할 때도 있습니다. 이럴 때는 cfg(test)로 의존성을 가둬 두면 깔끔한데, 잠시 후에 그 패턴도 살펴볼게요.

automock으로 트레이트 모킹하기

가장 기본적인 사용법은 트레이트 위에 #[automock] 속성을 다는 것입니다. 그러면 mockall이 같은 이름 앞에 Mock이 붙은 구조체를 자동으로 생성해 줍니다.

use mockall::automock;

#[automock]
trait UserRepository {
    fn find_name(&self, id: u64) -> Option<String>;
}

이렇게만 써두면 테스트 코드에서 MockUserRepository라는 구조체를 마치 직접 정의한 것처럼 가져다 쓸 수 있습니다. 이 구조체에는 트레이트의 각 메서드마다 expect_메서드명()이라는 짝꿍 메서드가 따라붙는데요. 바로 이 친구를 통해 “이 메서드가 호출되면 이렇게 동작해라”라고 지시할 수 있습니다.

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

    #[test]
    fn greets_known_user() {
        let mut mock = MockUserRepository::new();
        mock.expect_find_name()
            .returning(|_| Some("앨리스".to_string()));

        let service = UserService { repo: mock };

        assert_eq!(service.greet(1), "안녕하세요, 앨리스님!");
    }
}

returning에 넘기는 클로저가 실제 메서드 구현 역할을 대신합니다. 인자로 받은 ID를 어떻게 처리할지 직접 정할 수 있고, 매번 다른 값을 돌려주는 동적인 동작도 표현할 수 있죠.

단순한 반환값 지정

매번 클로저를 쓰는 건 번거로울 수 있는데요. 반환값이 고정된 경우에는 return_const를 사용하면 됩니다.

let mut mock = MockUserRepository::new();
mock.expect_find_name()
    .return_const(Some("밥".to_string()));

return_const는 클로저가 매번 호출되지 않고 값을 미리 저장해 두기 때문에 약간 더 가볍습니다. 다만 반환 타입이 Clone을 구현해야 한다는 제약이 있는데, Option<String> 같은 흔한 타입에는 자연스럽게 해당됩니다.

한 번만 사용하고 끝낼 값이라면 return_once도 있습니다. Clone을 요구하지 않는 대신 두 번째 호출부터는 패닉이 발생하니, 정확히 한 번만 호출된다는 사실을 단언하고 싶을 때 잘 어울립니다.

인자 매칭

어떤 인자가 들어왔는지에 따라 다른 응답을 돌려주고 싶을 때가 있는데요. with() 메서드와 mockall이 제공하는 프레디케이트(predicate) 함수를 조합해서 조건을 걸 수 있습니다.

use mockall::predicate::*;

let mut mock = MockUserRepository::new();

mock.expect_find_name()
    .with(eq(1))
    .return_const(Some("앨리스".to_string()));

mock.expect_find_name()
    .with(eq(2))
    .return_const(Some("밥".to_string()));

mock.expect_find_name()
    .return_const(None);

eq, gt, lt, always, function 같은 프레디케이트가 있고, 필요하면 withf로 직접 클로저를 넘길 수도 있습니다. mockall은 기대값을 등록한 순서대로 매칭을 시도하고, 가장 먼저 매칭되는 기대값의 응답을 사용합니다. 그래서 위 예제처럼 구체적인 조건을 먼저, 포괄적인 fallback을 마지막에 두는 패턴이 자주 쓰이죠.

호출 횟수 검증

함수가 호출되었는지 여부뿐 아니라 몇 번 호출되었는지도 검증할 수 있습니다. times, once, never 같은 메서드를 체이닝하면 됩니다.

let mut mock = MockUserRepository::new();

mock.expect_find_name()
    .with(eq(1))
    .times(1)
    .return_const(Some("앨리스".to_string()));

mock.expect_find_name()
    .with(eq(999))
    .never();

기본적으로 mockall은 기대값이 등록된 메서드가 한 번이라도 호출되어야 한다고 가정하는데요. MockUserRepository 인스턴스가 drop될 때 호출 횟수를 검증하고, 어긋나면 패닉을 일으킵니다. 그래서 별도의 assert 없이도 “호출되지 않으면 실패”라는 단언을 무료로 얻는 셈입니다.

만약 호출되지 않아도 괜찮다면 times(0..)처럼 범위로 지정하거나 times(0..=1)처럼 상한만 두는 식으로 유연하게 풀어줄 수 있습니다.

시퀀스로 호출 순서 단언하기

조금 더 까다로운 경우인데요. 어떤 메서드는 반드시 다른 메서드 다음에 호출되어야 한다는 단언이 필요할 때가 있습니다. 예를 들어 트랜잭션을 시작한 뒤에야 쿼리가 실행되어야 한다거나 하는 식이죠. 이때는 Sequence를 사용합니다.

use mockall::Sequence;

let mut mock = MockUserRepository::new();
let mut seq = Sequence::new();

mock.expect_begin_transaction()
    .times(1)
    .in_sequence(&mut seq)
    .return_const(());

mock.expect_find_name()
    .times(1)
    .in_sequence(&mut seq)
    .return_const(Some("앨리스".to_string()));

in_sequence로 묶인 기대값들은 등록된 순서대로만 매칭됩니다. 순서가 뒤바뀌면 매칭이 실패하면서 어느 시점에서 어긋났는지 친절하게 알려주니, 단계가 많은 워크플로를 검증할 때 유용합니다.

외부 트레이트 모킹하기: mock! 매크로

#[automock]은 직접 정의한 트레이트에만 붙일 수 있는데요. 다른 크레이트가 제공하는 트레이트를 모킹해야 한다면 mock! 매크로를 사용합니다.

use mockall::mock;

mock! {
    pub Cache {}

    impl crate::cache::CacheBackend for Cache {
        fn get(&self, key: &str) -> Option<String>;
        fn set(&mut self, key: String, value: String);
    }
}

mock! 블록 안에서 구조체 이름과 빈 본문을 선언하고, 구현할 트레이트를 impl 블록으로 나열합니다. 이렇게 만들면 MockCache라는 구조체가 생기는데, 사용법은 #[automock]이 생성한 것과 동일합니다. 같은 구조체에 여러 트레이트를 동시에 구현해야 할 때도 impl 블록을 여러 개 적으면 되니 확장성이 좋죠.

정적 메서드 모킹

self를 받지 않는 정적 메서드도 모킹할 수 있는데요. 다만 인스턴스 없이 호출되기 때문에 expect_*를 일반 인스턴스 메서드처럼 부를 수 없습니다. 대신 mockall이 만들어 주는 컨텍스트 객체를 사용합니다.

#[automock]
trait Clock {
    fn now() -> u64;
}

#[test]
fn uses_fake_clock() {
    let ctx = MockClock::now_context();
    ctx.expect().returning(|| 1_700_000_000);

    assert_eq!(MockClock::now(), 1_700_000_000);
}

now_context()로 가져온 컨텍스트 객체가 살아 있는 동안에만 기대값이 유효합니다. 주의할 점은 정적 메서드 모킹이 프로세스 전역 상태를 다룬다는 건데요. 같은 테스트 바이너리 안에서 여러 테스트가 병렬로 실행될 때 충돌이 일어날 수 있으므로, 충돌이 우려되는 정적 모킹은 단일 스레드로 강제하거나 mockall::lazy_static을 활용하는 편이 안전합니다.

비동기 트레이트 다루기

tokio 기반의 비동기 코드를 다룰 때도 mockall은 자연스럽게 어우러집니다. 별다른 설정 없이 트레이트의 메서드가 async이기만 하면 동일한 방식으로 모킹할 수 있습니다.

use mockall::automock;

#[automock]
trait UserApi {
    async fn fetch_name(&self, id: u64) -> Option<String>;
}

#[tokio::test]
async fn async_mock_works() {
    let mut mock = MockUserApi::new();
    mock.expect_fetch_name()
        .with(eq(1))
        .returning(|_| Some("앨리스".to_string()));

    assert_eq!(mock.fetch_name(1).await, Some("앨리스".to_string()));
}

만약 #[async_trait] 매크로를 함께 쓰는 코드라면 속성 순서에 주의해야 합니다. #[automock]을 먼저, #[async_trait]을 그 아래에 적어야 mockall이 트레이트 원본을 보고 모의 구현체를 생성할 수 있습니다.

운영 코드에 mockall 의존성을 새지 않게 하기

#[automock]을 트레이트 정의에 직접 달면 트레이트가 정의된 크레이트에 mockall 의존성이 들어가 버립니다. 운영 빌드까지 따라가는 게 부담스러울 수 있는데요. 이때는 cfg_attr로 테스트 빌드에서만 매크로를 적용하면 됩니다.

#[cfg_attr(test, mockall::automock)]
pub trait UserRepository {
    fn find_name(&self, id: u64) -> Option<String>;
}

그리고 Cargo.toml에서 mockall을 [dev-dependencies]에 두면 운영 빌드에는 한 줄도 포함되지 않습니다. 이 패턴 덕분에 트레이트로 추상화한 의존성 위에 자연스럽게 모킹을 얹을 수 있죠.

실전 예제: 결제 서비스 단위 테스트

지금까지 살펴본 기능을 묶어서 좀 더 현실적인 예제를 만들어 볼게요. 포인트 적립과 알림 발송을 함께 처리하는 결제 서비스를 가정합니다.

#[cfg_attr(test, mockall::automock)]
pub trait PointRepository {
    fn add_points(&mut self, user_id: u64, amount: u32) -> Result<u32, String>;
}

#[cfg_attr(test, mockall::automock)]
pub trait Notifier {
    fn send(&self, user_id: u64, message: &str) -> Result<(), String>;
}

pub struct CheckoutService<P: PointRepository, N: Notifier> {
    points: P,
    notifier: N,
}

impl<P: PointRepository, N: Notifier> CheckoutService<P, N> {
    pub fn complete(&mut self, user_id: u64, paid: u32) -> Result<(), String> {
        let earned = paid / 100;
        let total = self.points.add_points(user_id, earned)?;
        self.notifier
            .send(user_id, &format!("{}P 적립 완료, 누적 {}P", earned, total))
    }
}

complete가 호출되었을 때 포인트가 정확히 적립되고 알림이 한 번만 발송되는지를 검증해 보겠습니다.

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

    #[test]
    fn awards_points_and_notifies() {
        let mut points = MockPointRepository::new();
        let mut notifier = MockNotifier::new();

        points
            .expect_add_points()
            .with(eq(42), eq(30))
            .times(1)
            .returning(|_, _| Ok(130));

        notifier
            .expect_send()
            .with(eq(42), eq("30P 적립 완료, 누적 130P"))
            .times(1)
            .returning(|_, _| Ok(()));

        let mut service = CheckoutService { points, notifier };

        assert!(service.complete(42, 3_000).is_ok());
    }
}

이 테스트는 데이터베이스도, 알림 서버도 띄우지 않고 순수하게 비즈니스 로직만 검증합니다. 인자 매칭으로 “정확히 30포인트가 적립되어야 한다”는 사양이 코드로 박혀 있고, times(1)로 알림이 두 번 발송되는 회귀를 막아주죠. 이런 식으로 트레이트 경계를 잡아두면 mockall의 진가가 잘 드러납니다.

마치며

지금까지 mockall로 Rust 트레이트를 모킹하는 방법을 살펴봤습니다. #[automock]으로 가짜 구현체를 자동 생성하고, expect_* 메서드로 반환값과 호출 횟수를 단언했으며, mock! 매크로로 외부 트레이트까지 모킹하는 흐름을 짚어봤어요. 정적 메서드와 비동기 트레이트, 그리고 운영 빌드에 mockall을 새지 않게 하는 패턴도 함께 다뤘습니다.

mockall이 잘 동작하려면 결국 의존성을 트레이트로 깔끔하게 분리해 두는 설계가 선행되어야 합니다. 처음에는 다소 번거롭게 느껴질 수 있지만 한번 익숙해지면 테스트 가능한 코드와 그렇지 않은 코드의 차이가 분명해지죠. HTTP 호출처럼 트레이트보다는 네트워크 경계에서 모킹하는 게 자연스러운 경우라면 mockito 같은 도구를 함께 쓰면 좋고요. 같은 테스트를 입력만 바꿔 여러 번 돌려야 한다면 rstest로 파라미터화 테스트를 만들면 깔끔합니다.

더 자세한 내용은 mockall 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord