Rust 기초: assert와 assert_eq 매크로로 검증하기

Rust로 코드를 작성하다 보면 “이 값이 정말 내가 기대한 것과 같을까?”를 확인해야 하는 순간이 많습니다. 단위 테스트를 작성할 때는 물론이고, 개발 중에 특정 조건이 반드시 성립하는지 검증하고 싶을 때도 그렇죠.

이럴 때 Rust가 제공하는 assert!, assert_eq!, assert_ne! 매크로가 딱입니다. 이번 글에서는 이 매크로들을 어떻게 쓰는지, 그리고 테스트에서 어떻게 활용하면 좋은지 알아보겠습니다.

assert! 매크로

assert!는 가장 기본적인 검증 매크로입니다. 주어진 조건이 true인지 확인하고, 만약 false라면 프로그램을 패닉(panic)시킵니다.

fn main() {
    let score = 85;

    assert!(score >= 60);
    println!("합격입니다!");
}
결과
합격입니다!

score가 60 이상이므로 assert!는 아무 일도 없이 지나갑니다. 하지만 만약 score가 50이었다면 어떻게 될까요?

fn main() {
    let score = 50;

    assert!(score >= 60);
    println!("합격입니다!");
}
결과
thread 'main' panicked at 'assertion failed: score >= 60'

조건이 거짓이면 프로그램이 즉시 멈추고 어떤 조건이 실패했는지 알려줍니다.

assert!는 불리언 값을 반환하는 메서드와 함께 쓸 때 특히 자연스럽습니다.

fn main() {
    let text = String::from("Hello, Rust!");

    assert!(text.contains("Rust"));
    assert!(text.starts_with("Hello"));
    assert!(!text.is_empty());
}

Result 타입의 is_ok()이나 is_err() 같은 메서드와도 잘 어울립니다.

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("0으로 나눌 수 없습니다".to_string())
    } else {
        Ok(a / b)
    }
}

fn main() {
    let result = divide(10.0, 2.0);
    assert!(result.is_ok());

    let error = divide(10.0, 0.0);
    assert!(error.is_err());
}

assert_eq!와 assert_ne! 매크로

두 값이 같은지 비교할 때는 assert_eq!를, 다른지 비교할 때는 assert_ne!를 사용합니다.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    assert_eq!(add(2, 3), 5);
    assert_ne!(add(2, 3), 6);
}

“그냥 assert!(a == b)로 하면 되지 않나?” 하고 생각할 수 있는데요. 실패했을 때의 차이가 꽤 큽니다.

assert!를 사용하면 조건이 거짓이라는 것만 알려주지만,

thread 'main' panicked at 'assertion failed: add(2, 3) == 10'

assert_eq!를 사용하면 실제 값과 기대한 값을 둘 다 보여주기 때문에 디버깅이 훨씬 수월합니다.

thread 'main' panicked at 'assertion `left == right` failed
  left: 5
 right: 10'

이 차이는 복잡한 데이터 구조를 비교할 때 더 두드러집니다.

#[derive(Debug, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 1, y: 3 };

    assert_eq!(p1, p2);
}
결과
thread 'main' panicked at 'assertion `left == right` failed
  left: Point { x: 1, y: 2 }
 right: Point { x: 1, y: 3 }'

구조체의 어떤 필드가 다른지 한눈에 파악할 수 있죠. 참고로 assert_eq!assert_ne!를 사용하려면 비교 대상이 PartialEq 트레이트를 구현해야 하고, 실패 메시지 출력을 위해 Debug 트레이트도 필요합니다.

커스텀 실패 메시지

세 매크로 모두 실패 시 출력할 커스텀 메시지를 추가할 수 있습니다. format! 매크로와 동일한 문법을 사용하기 때문에 변수 값을 메시지에 포함할 수 있습니다.

fn main() {
    let username = "dale";
    let age = 15;

    assert!(age >= 18, "'{username}'은(는) {age}세로 가입 조건(18세 이상)을 충족하지 않습니다");
}
결과
thread 'main' panicked at ''dale'은(는) 15세로 가입 조건(18세 이상)을 충족하지 않습니다'

assert_eq!에서도 마찬가지입니다.

fn main() {
    let expected = 200;
    let actual = 404;

    assert_eq!(actual, expected, "HTTP 상태 코드가 {expected}이어야 하는데 {actual}입니다");
}
결과
thread 'main' panicked at 'assertion `left == right` failed
  left: 404
 right: 200
HTTP 상태 코드가 200이어야 하는데 404입니다'

이렇게 맥락이 담긴 메시지를 작성해두면, 테스트가 실패했을 때 원인을 훨씬 빠르게 파악할 수 있습니다. 실무에서 수십 개의 테스트가 동시에 돌아갈 때 “assertion failed”만 보이면 막막하지만, 구체적인 메시지가 있으면 바로 문제를 짚어낼 수 있거든요.

테스트에서 활용하기

실제로 assert 매크로를 가장 많이 쓰게 되는 곳은 단위 테스트입니다. Rust에서는 #[test] 속성을 붙인 함수 안에서 이 매크로들로 코드의 동작을 검증하는데요.

fn is_even(n: i32) -> bool {
    n % 2 == 0
}

fn factorial(n: u32) -> u32 {
    match n {
        0 | 1 => 1,
        _ => n * factorial(n - 1),
    }
}

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

    #[test]
    fn test_is_even() {
        assert!(is_even(4));
        assert!(!is_even(7));
    }

    #[test]
    fn test_factorial() {
        assert_eq!(factorial(0), 1);
        assert_eq!(factorial(1), 1);
        assert_eq!(factorial(5), 120);
        assert_ne!(factorial(3), 5);
    }
}

cargo test 명령어로 실행하면 각 테스트 함수가 독립적으로 실행됩니다.

결과
running 2 tests
test tests::test_is_even ... ok
test tests::test_factorial ... ok

test result: ok. 2 passed; 0 failed; 0 ignored

matches! 매크로와 함께 사용하기

값의 정확한 내용까지는 상관없고 패턴만 일치하면 되는 경우가 있습니다. 예를 들어 오류가 발생했는데, 그 오류가 특정 종류인지만 확인하고 싶을 때요.

이럴 때 assert!matches! 매크로를 조합하면 깔끔하게 검증할 수 있습니다.

#[derive(Debug)]
enum AppError {
    NotFound(String),
    Unauthorized,
    BadInput(String),
}

fn validate_age(age: i32) -> Result<(), AppError> {
    if age < 0 {
        Err(AppError::BadInput("나이는 음수가 될 수 없습니다".to_string()))
    } else {
        Ok(())
    }
}

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

    #[test]
    fn test_negative_age_returns_bad_input() {
        let result = validate_age(-1);
        let error = result.unwrap_err();
        assert!(
            matches!(error, AppError::BadInput(_)),
            "BadInput을 기대했지만 {error:?}을(를) 받았습니다"
        );
    }

    #[test]
    fn test_valid_age() {
        let result = validate_age(25);
        assert!(result.is_ok());
    }
}

matches!match 표현식의 축약형으로 패턴이 일치하면 true를, 아니면 false를 반환합니다. assert_eq!로는 열거형의 내부 데이터까지 정확히 맞춰야 해서 번거로운 상황에서, matches!를 쓰면 “이 종류의 오류인가?”만 간단히 확인할 수 있어서 편리합니다.

debug_assert! 매크로

assert!에는 debug_assert!라는 사촌이 있습니다. debug_assert!는 디버그 빌드에서만 동작하고, 릴리즈 빌드에서는 완전히 무시됩니다.

fn process_data(data: &[u8]) -> usize {
    debug_assert!(!data.is_empty(), "빈 데이터가 전달되었습니다");
    // 실제 처리 로직
    data.len()
}

성능에 민감한 코드에서 개발 중에는 검증하고 싶지만, 프로덕션에서는 검증 비용을 없애고 싶을 때 유용합니다. debug_assert_eq!debug_assert_ne!도 동일한 방식으로 사용 가능합니다.

#[should_panic]은 신중하게

테스트에서 패닉이 발생하는지 확인하고 싶을 때 #[should_panic] 속성을 사용할 수 있습니다.

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("0으로 나눌 수 없습니다!");
    }
    a / b
}

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

    #[test]
    #[should_panic(expected = "0으로 나눌 수 없습니다")]
    fn test_divide_by_zero() {
        divide(10, 0);
    }
}

하지만 이 속성은 주의해서 사용해야 합니다. 패닉이 발생하기만 하면 테스트가 통과하기 때문에, 의도하지 않은 곳에서 패닉이 나도 잡아내지 못할 수 있거든요.

가능하면 Result 타입을 반환하도록 함수를 설계하고, assert!(result.is_err())matches!를 사용하여 오류 종류까지 검증하는 것이 더 안전합니다. #[should_panic]은 패닉이 의도된 동작인 경우(예: 잘못된 인덱스 접근 같은 프로그래밍 오류)에만 사용하는 것을 권장합니다.

pretty_assertions 크레이트

복잡한 구조체나 긴 문자열을 비교할 때 표준 assert_eq!의 출력만으로는 어떤 부분이 다른지 파악하기 어려울 수 있습니다. pretty_assertions 크레이트를 사용하면 차이점을 색상으로 구분하여 보여줍니다.

먼저 Cargo.toml에 개발 의존성으로 추가합니다.

Cargo.toml
[dev-dependencies]
pretty_assertions = "1"

그런 다음 테스트 코드에서 가져오기만 하면 됩니다.

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;

    #[test]
    fn test_config() {
        let expected = "host: localhost\nport: 8080\nmode: production";
        let actual = "host: localhost\nport: 3000\nmode: development";

        assert_eq!(expected, actual);
    }
}

기존의 assert_eq!를 덮어쓰는 방식이라 코드를 크게 변경할 필요가 없습니다. 실패 시 달라진 부분이 빨간색과 초록색으로 강조되어 표시되므로, 대규모 데이터 비교에서 특히 시간을 아낄 수 있습니다.

assert 매크로 선택 가이드

어떤 상황에서 어떤 매크로를 쓰면 좋을지 정리해보겠습니다.

상황추천 매크로예시
조건이 참인지 확인assert!assert!(value.is_ok())
두 값이 같은지 비교assert_eq!assert_eq!(result, 42)
두 값이 다른지 비교assert_ne!assert_ne!(status, 0)
패턴만 일치하면 됨assert! + matches!assert!(matches!(err, MyError::NotFound(_)))
릴리즈에서 비활성화debug_assert!debug_assert!(index < len)

핵심은 간단합니다. 두 값을 비교한다면 assert_eq!를, 불리언 조건이라면 assert!를, 패턴 매칭이 필요하면 assert!matches!를 조합하면 됩니다. 그리고 실패 메시지에는 실제 상태와 기대한 상태를 모두 담아서, 미래의 나(혹은 동료)가 실패 로그만 보고도 원인을 파악할 수 있도록 해주세요.

마치며

Rust의 assert 매크로들은 단순하지만 테스트 코드에서 빠지지 않는 필수 도구입니다. 상황에 맞는 매크로를 골라 쓰고, 실패 메시지를 꼼꼼히 적어두는 습관을 들이면 나중에 테스트가 깨졌을 때 한결 수월해집니다.

Rust의 출력 매크로DisplayDebug 트레이트와 함께 동작하듯이, assert 매크로도 PartialEqDebug 트레이트를 기반으로 동작합니다. 이런 트레이트 시스템에 대해 더 알고 싶다면 From과 Into 트레이트Copy와 Clone 트레이트에 대한 글도 참고해 보세요.

This work is licensed under CC BY 4.0 CC BY

Discord