JS, TS랑 비교하며 Rust 공부하기 (6)
에러 처리
러스트에는 try...catch문이 없고 Result나 panic!을 사용하여 에러 처리를 한다.
Result<T, E> 열거형과 배리언트들(Ok(T), Err(E))은 프렐루드로부터 가져와진다.
에러 처리를 하지 않고 프로그램을 강제 종료시키고 싶다면 panic!매크로를 사용하면 된다.
기본적으로 실패할지도 모르는 함수를 정의할 때는 Result나 Option 를 반환하고, 프로토타입이나 테스트 같은 상황에서는 패닉을 일으키는게 좋다.
use std::fs::File;
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
unwrap과 expect메서드
unwrap과 expect메서드를 통해 Result<T, E> 타입을 짧게 처리할 수 있다.
unwrap메서드는 Result값이 Ok 배리언트라면 Ok 내의 값을 반환하고, Err배리언트라면 panic!매크로를 호출한다.
expect메서드는 unwrap메서드와 비슷하지만 다른 부분은 매개변수를 통해 에러 메시지를 지정해 줄 수 있다.
Err배리언트가 나올 수 없다고 확신하는 경우에 두 메서드를 쓰는게 좋다.
use std::fs::File;
let greeting_file = File::open("hello.txt").unwrap();
let greeting_file = File::open("hello.txt")
.expect("hello.txt should be included in this project");
? 연산자
?연산자는 함수를 호출하는 코드 쪽으로 Result나 Option 열거형을 반환하는 에러 전파 패턴을 쉽게 처리할 수 있게 해준다.
?연산자는 연산자가 사용된 함수의 반환 타입에 정의된 에러 타입으로 변환되기 때문에 호환 가능한 타입을 가진 함수에서만 사용할 수 있다. 예제 코드에서 보면 File::open과 read_to_string은 Err(io::Error)를 반환하기 때문에 ?연산자를 쓸 수 있다.
// ? 연산자 없이 에러 전파
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");
let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
// ? 연산자 사용한 에러 전파
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
// 더 짧은 버전
// let mut username = String::new();
// File::open("hello.txt")?.read_to_string(&mut username)?;
// Ok(username)
}
테스트
러스트에서 테스트하기 위해선 test 속성이 어노테이션된 함수를 통해 할 수 있다.
함수 이전 줄에 #[test]를 추가하면 테스트 함수로 변경된다.
테스트 성공 여부는 테스트 함수 내에서 패닉이 발생하면 실패이고 아니면 성공이다.
테스트는 cargo test 명령어로 실행되며, 이 명령을 실행하면 러스트는 test 속성이 표시된 함수를 실행하고 결과를 보고하는 테스트 실행 바이너리를 빌드한다.
유닛 테스트는 src 디렉터리 내의 각 파일에 테스트 대상이 될 코드와 함께 작성하며 각 파일에 tests 모듈을 만들고 cfg(test)를 어노테이션하는 게 일반적인 관례이다.
테스트 모듈에 어노테이션하는 #[cfg(test)]은 cargo test 명령어 실행 시에만 컴파일 및 실행되며 라이브러리 빌드 시에는 제외된다.
Result<T, E>를 사용해 테스트를 작성할 수도 있다.
#[cfg(test)]
mod tests {
#[test]
fn it_works1() {
let result = 2 + 2;
assert_eq!(result, 4);
}
#[test]
fn it_works2() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
테스트에 쓰이는 매크로들과 속성들
assert! 매크로는 부울린 값으로 평가되는 인수를 받으며, true면 통과하고 false면 panic! 매크로를 호출하여 테스트가 실패한다.
pub fn add_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
assert!(add_two(2) == 4);
}
}
assert_eq! 매크로는 두 인수를 비교하고 동등한지 아닌지를 판단하며, 실패하면 실패 사유를 알기 쉽게 출력해준다.
인수를 넣는 순서는 상관없고 러스트에서 left, right라고 지칭한다.
// ...
#[test]
fn it_adds_two() {
assert_eq!(4, add_two(2));
}
assert_ne! 매크로는 두 인수를 비교해서 다르면 통과하고 같으면 실패한다.
내부적으로 assert_eq! 매크로와 다른 부분은 != 연산자를 사용한다는 것 뿐이다.
// ...
#[test]
fn it_adds_two() {
assert_eq!(4, add_two(2));
}
위 매크로들의 필수적인 인수들 이후에 인수를 추가하면 실패 메시지에 출력될 내용을 추가할 수 있다.
추가된 인수는 format! 매크로로 전달된다.
// ...
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(
result,
4,
"Value of Add two is not 4, value was {}",
result
);
}
should_panic 속성이 추가된 테스트는 패닉이 발생해야 통과되고 아니면 실패한다.
에러가 잘 처리되는지 검사할 때 사용하면 된다.
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
ignore 속성이 추가된 테스트는 제외된다.
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
#[test]
#[ignore]
fn expensive_test() {
// code that takes an hour to run
}
테스트 실행 방법 제어하기
cargo test 명령어는 기본적으로 모든 테스트를 병렬로 실행하고, 테스트 중 발생하는 출력을 캡처한다. 이로 인해 테스트 결과 외의 출력은 기본적으로 보이지 않지만, 테스트 결과를 더 깔끔하게 확인할 수 있다.
cargo test 명령어에는 두 종류의 인수를 전달할 수 있다. 하나는 cargo test 자체에 전달되는 인수이고, 다른 하나는 테스트 바이너리에 전달되는 인수이다. 이 둘은 -- 구분자를 사용해 나눌 수 있다.
cargo test [cargo 인수] -- [테스트 바이너리 인수]
병렬 실행을 제어하고 싶다면 --test-threads 옵션을 사용할 수 있다. 예를 들어, 테스트를 하나의 스레드로 순차 실행하려면 다음과 같이 입력한다.
cargo test -- --test-threads=1
성공한 테스트에서 출력한 내용도 보고 싶다면 --show-output 옵션을 사용하면 된다.
cargo test -- --show-output
특정 테스트만 실행하고 싶을 때는 테스트 함수 이름을 지정하면 된다. 함수 이름의 일부만 적어도 해당 문자열이 포함된 모든 테스트가 실행된다.
cargo test one_test
cargo test one
무시된 테스트만 실행하고 싶다면 --ignored 옵션을, 무시된 테스트도 포함해서 전부 실행하고 싶다면 --include-ignored 옵션을 사용하면 된다.
cargo test -- --ignored
cargo test -- --include-ignored
통합 테스트
통합 테스트를 작성하려면, 프로젝트 루트(예: src 옆)에 tests 디렉터리를 만들고 이 디렉터리내에 테스트 파일을 추가하면 된다. tests 디렉터리 내의 .rs 파일들은 각각 독립적인 테스트 크레이트로 간주되며, cargo test 시 자동으로 컴파일되고 실행된다.
tests 디렉터리는 프로젝트 루트와 별도의 디렉터리에 위치하기 때문에, 테스트 코드에 #[cfg(test)]는 작성할 필요가 없다.
각 테스트 파일은 별도의 크레이트로 취급되므로, 최상단에 use를 사용해 테스트 대상 라이브러리를 명시적으로 가져와야 한다.
cargo test로 실행하면 유닛 테스트, 통합 테스트, 문서 테스트 순서로 실행되며, 앞 단계에서 실패하면 다음 단계는 생략된다.
참고로 바이너리 크레이트(src/main.rs)만 존재하고 lib.rs가 없는 경우, 통합 테스트에서 내부 함수를 가져와 테스트할 수 없다. 그래서 많은 러스트 프로젝트는 핵심 로직을 lib.rs에 작성하고, main.rs는 실행만 담당하도록 분리한다.
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs
// tests/integration_test.rs
use adder;
#[test]
fn it_adds_two() {
assert_eq!(4, adder::add_two(2));
}
특정 통합 테스트 파일만 실행하고 싶다면 다음과 같이 명령어를 사용한다:
cargo test --test integration_test
여러 통합 테스트 파일에서 공통적으로 사용할 setup 코드가 있다면, tests/common.rs로 만들 경우 테스트로 인식되므로 출력에 나타나게 된다. 이를 피하려면 다음과 같이 서브 디렉터리를 만들면 된다.
tests
├── common
│ └── mod.rs
└── integration_test.rs
// tests/common/mod.rs
pub fn setup() {
// 테스트 공통 설정
}
// tests/integration_test.rs
use adder;
mod common;
#[test]
fn it_adds_two() {
common::setup();
assert_eq!(4, adder::add_two(2));
}