Rust HTTP 모킹: mockito 크레이트 사용법

Rust HTTP 모킹: mockito 크레이트 사용법

웹 API를 호출하는 코드를 작성하다 보면 항상 마주치는 고민이 있는데요. “이 코드는 어떻게 테스트하지?” 하는 문제입니다. 실제 API를 호출하는 테스트는 네트워크 상태에 따라 결과가 달라지고, 외부 서비스의 응답을 마음대로 흉내내기도 어렵죠.

이럴 때 유용한 것이 바로 HTTP 모킹 라이브러리인데요. Rust 생태계에서는 mockito 크레이트가 가장 널리 사용되고 있습니다. 이 글에서는 mockito를 사용해서 HTTP 요청을 보내는 코드를 어떻게 테스트하는지 단계별로 살펴보겠습니다.

mockito란?

mockito는 Rust로 작성된 HTTP 모킹 라이브러리로, 테스트 중에 실제 HTTP 서버처럼 동작하는 가상의 서버를 띄워줍니다. 이름은 자바 진영의 유명한 모킹 프레임워크인 Mockito에서 따왔지만 동작 방식은 완전히 다릅니다. 자바 Mockito가 객체의 메서드를 모킹하는 데 비해, Rust의 mockito는 진짜 HTTP 서버를 로컬에서 실행해서 응답을 흉내냅니다. 객체나 트레이트 단위로 모킹하고 싶다면 mockall 크레이트가 더 어울립니다.

덕분에 reqwest 크레이트나 hyper 같은 HTTP 클라이언트를 코드 변경 없이 그대로 사용할 수 있는 게 큰 장점입니다. URL만 mockito가 띄운 로컬 서버 주소로 바꿔주면 되거든요.

설치하기

mockito는 테스트 용도로만 쓰이는 라이브러리이기 때문에 Cargo.toml[dev-dependencies] 섹션에 추가합니다. 이렇게 하면 실제 빌드 결과물에는 포함되지 않고 테스트할 때만 사용됩니다.

Cargo.toml
[dev-dependencies]
mockito = "1"
tokio = { version = "1", features = ["macros", "rt"] }
reqwest = "0.12"

비동기 테스트를 작성할 거라면 tokio와 함께 설치하는 것이 일반적입니다.

기본 사용법

먼저 가장 간단한 형태부터 살펴보겠습니다. Server::new()로 가짜 HTTP 서버를 띄우고, mock() 메서드로 어떤 요청에 어떻게 응답할지 정의합니다.

#[test]
fn test_hello() {
    let mut server = mockito::Server::new();

    let mock = server
        .mock("GET", "/hello")
        .with_status(200)
        .with_header("content-type", "text/plain")
        .with_body("world")
        .create();

    let url = format!("{}/hello", server.url());
    let body = reqwest::blocking::get(&url).unwrap().text().unwrap();

    assert_eq!(body, "world");
    mock.assert();
}

Server::new()는 사용 가능한 임의의 포트에서 서버를 시작합니다. server.url()로 서버의 기본 URL을 가져올 수 있는데, 보통 http://127.0.0.1:포트번호 형태입니다. mock()의 첫 번째 인자는 HTTP 메서드, 두 번째는 경로이고, 그 뒤로 응답을 어떻게 구성할지 체이닝합니다. 마지막에 .create()를 호출해야 모의 응답이 실제로 등록되니 빠뜨리지 않도록 주의하세요.

mock.assert()는 정의한 모의 응답이 실제로 호출되었는지 검증합니다. 호출되지 않았다면 어떤 요청이 들어왔는지 상세한 정보와 함께 panic이 발생하죠.

비동기 테스트

tokio 같은 비동기 런타임을 사용하는 코드를 테스트할 때는 _async 접미사가 붙은 메서드를 사용합니다.

#[tokio::test]
async fn test_async_endpoint() {
    let mut server = mockito::Server::new_async().await;

    let mock = server
        .mock("POST", "/events")
        .with_status(201)
        .with_header("content-type", "application/json")
        .with_body(r#"{"status":"created"}"#)
        .create_async()
        .await;

    let client = reqwest::Client::new();
    let response = client
        .post(format!("{}/events", server.url()))
        .body(r#"{"type":"click"}"#)
        .send()
        .await
        .unwrap();

    assert_eq!(response.status(), 201);
    mock.assert_async().await;
}

new_async(), create_async(), assert_async()처럼 비동기 버전을 골고루 사용해야 한다는 점만 기억하면 됩니다. 동기와 비동기를 혼용해서 쓰면 런타임 안에서 블로킹 호출이 발생할 수 있으니까요.

요청 헤더 매칭

같은 경로라도 헤더에 따라 다른 응답을 반환하고 싶을 때가 있는데요. match_header() 메서드를 사용하면 요청 헤더에 조건을 걸 수 있습니다.

use mockito::Matcher;

let mut server = mockito::Server::new();

// 정확한 헤더 값 매칭
server.mock("GET", "/secure")
    .match_header("authorization", "Bearer secret-token")
    .with_status(200)
    .with_body("protected data")
    .create();

// 헤더가 존재하기만 하면 통과
server.mock("GET", "/any-auth")
    .match_header("authorization", Matcher::Any)
    .with_status(200)
    .create();

// 헤더가 없어야 통과
server.mock("GET", "/public")
    .match_header("authorization", Matcher::Missing)
    .with_status(200)
    .with_body("public data")
    .create();

헤더 이름은 대소문자를 구분하지 않습니다. Matcher::Any는 헤더의 존재 여부만 확인하고 값은 따지지 않으며, Matcher::Missing은 반대로 헤더가 없을 때만 매칭됩니다. 인증된 사용자와 익명 사용자의 응답을 분리해서 테스트하기 좋죠.

쿼리 파라미터 매칭

쿼리 스트링은 match_query() 메서드로 매칭합니다. 검색이나 필터링 API를 테스트할 때 자주 쓰이는 기능인데요.

use mockito::Matcher;

let mut server = mockito::Server::new();

// 단일 파라미터 매칭
server.mock("GET", "/search")
    .match_query(Matcher::UrlEncoded("q".into(), "hello world".into()))
    .with_body("results for hello world")
    .create();

// 여러 파라미터를 모두 만족해야 매칭
server.mock("GET", "/filter")
    .match_query(Matcher::AllOf(vec![
        Matcher::UrlEncoded("status".into(), "active".into()),
        Matcher::UrlEncoded("page".into(), "1".into()),
    ]))
    .with_body(r#"{"page":1}"#)
    .create();

Matcher::UrlEncoded에 넘기는 값은 디코딩된 평문 형태로 작성합니다. 공백을 %20으로 인코딩하지 않고 그대로 "hello world"라고 써도 mockito가 알아서 비교해 줍니다. AllOf는 모든 조건을 만족해야 매칭되고, 반대로 AnyOf는 하나라도 만족하면 매칭되니 상황에 맞게 골라 쓰면 됩니다.

요청 본문 매칭

POST나 PUT 같은 요청은 본문 내용이 중요한 경우가 많은데요. match_body()로 다양한 방식의 본문 매칭이 가능합니다.

use mockito::Matcher;

let mut server = mockito::Server::new();

// 문자열 정확 매칭
server.mock("POST", "/echo")
    .match_body("ping")
    .with_body("pong")
    .create();

// JSON 정확 매칭 (키 순서는 무시)
server.mock("POST", "/users")
    .match_body(Matcher::JsonString(
        r#"{"name":"Alice","role":"admin"}"#.to_string(),
    ))
    .with_status(201)
    .create();

// JSON 부분 매칭 - 지정한 키만 일치하면 통과
server.mock("POST", "/events")
    .match_body(Matcher::PartialJsonString(
        r#"{"type":"click"}"#.to_string(),
    ))
    .with_status(200)
    .create();

// 정규식 매칭
server.mock("POST", "/log")
    .match_body(Matcher::Regex(r"level=(info|warn|error)".to_string()))
    .with_status(204)
    .create();

특히 PartialJsonString이 유용한데요. 요청 본문에 동적으로 생성되는 타임스탬프나 ID가 포함된 경우, 검증하고 싶은 필드만 골라서 비교할 수 있거든요. JsonString은 키의 순서까지는 무시하지만 모든 필드가 일치해야 매칭됩니다.

호출 횟수 검증

테스트하는 코드가 외부 API를 정확히 몇 번 호출하는지 검증하고 싶을 때는 expect() 계열의 메서드를 사용합니다.

let mut server = mockito::Server::new();

// 정확히 3번 호출되어야 통과
let exact_mock = server
    .mock("GET", "/exact")
    .expect(3)
    .create();

// 2번 이상 5번 이하로 호출되어야 통과
let range_mock = server
    .mock("GET", "/range")
    .expect_at_least(2)
    .expect_at_most(5)
    .create();

expect(n)은 정확한 횟수를, expect_at_least(n)expect_at_most(n)은 범위를 지정합니다. 나중에 assert()를 호출했을 때 기대치를 벗어나면 panic이 발생하죠. 재시도 로직이 의도대로 동작하는지 확인하거나 캐시가 잘 적용되어 불필요한 요청이 나가지 않는지 검증할 때 자주 사용됩니다.

파일에서 응답 본문 읽기

응답 본문이 길거나 복잡한 JSON인 경우 코드에 직접 박아넣기보다는 픽스처 파일로 분리하는 게 좋은데요. with_body_from_file() 메서드가 그 역할을 해줍니다.

let mut server = mockito::Server::new();

let _mock = server
    .mock("GET", "/users")
    .with_status(200)
    .with_header("content-type", "application/json")
    .with_body_from_file("tests/fixtures/users.json")
    .create();

tests/fixtures/users.json 같은 파일에 응답 본문을 저장해두고 불러올 수 있습니다. Content-Length 헤더도 자동으로 설정되니 편리합니다. 파일을 읽지 못하면 panic이 발생하니, 테스트 작성 시 경로를 정확히 지정해야 합니다.

실전 예제: API 클라이언트 테스트

지금까지 살펴본 기능을 조합해서 실제 API 클라이언트를 테스트하는 예제를 만들어 보겠습니다. GitHub 사용자 정보를 가져오는 간단한 함수를 가정해 볼게요.

use serde::Deserialize;

#[derive(Deserialize, Debug)]
pub struct User {
    pub login: String,
    pub id: u64,
}

pub async fn fetch_user(base_url: &str, username: &str) -> reqwest::Result<User> {
    let url = format!("{}/users/{}", base_url, username);
    reqwest::Client::new()
        .get(&url)
        .header("user-agent", "my-app")
        .send()
        .await?
        .json::<User>()
        .await
}

이 함수의 핵심은 base_url을 인자로 받는다는 점인데요. 이렇게 만들어두면 운영 환경에서는 https://api.github.com을 넘기고, 테스트에서는 mockito 서버의 URL을 넘길 수 있습니다.

#[tokio::test]
async fn test_fetch_user() {
    let mut server = mockito::Server::new_async().await;

    let mock = server
        .mock("GET", "/users/octocat")
        .match_header("user-agent", "my-app")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"login":"octocat","id":1}"#)
        .create_async()
        .await;

    let user = fetch_user(&server.url(), "octocat").await.unwrap();

    assert_eq!(user.login, "octocat");
    assert_eq!(user.id, 1);
    mock.assert_async().await;
}

테스트 코드가 네트워크 없이도 결정적으로 동작하게 되었습니다. 응답 본문은 SerdeUser 구조체로 역직렬화해 주니 따로 신경 쓸 일도 없죠. 이렇게 base_url을 외부에서 주입받는 패턴은 mockito와 궁합이 좋습니다.

마치며

지금까지 mockito 크레이트를 사용해서 Rust에서 HTTP 모킹을 하는 방법을 알아보았습니다. 기본적인 모의 응답 생성부터 다양한 매처, 호출 횟수 검증, 비동기 지원, 픽스처 파일 활용까지 살펴봤는데요. 외부 API에 의존하는 코드도 안정적이고 빠른 테스트로 작성할 수 있게 되었을 거예요.

실제 프로젝트에서는 base_url을 환경 변수나 설정 파일에서 읽어오도록 만들어두면 mockito와 더 자연스럽게 어울립니다. 운영 환경에서는 진짜 API 주소를, 테스트에서는 mockito 서버 주소를 주입하면 되니까요.

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

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord