모듈과 테스트 - 코드 나누고 검증하기
이 장에서 배우는 것
앞 장까지는 코드를 src/main.rs 한 파일에 계속 쌓았다. 예제가 커질수록 이 방식은 불편해진다. 어디에 무엇이 있는지 찾기 어렵고, 한 곳을 고치면 다른 곳이 깨져도 알아채기 어렵다. 이 장에서는 코드를 모듈로 나누어 경계를 정하고, 테스트를 붙여 고친 뒤에도 동작이 그대로인지 기계가 확인하게 만든다. 가계부 도구의 기록 저장 부분과 집계 부분을 서로 다른 파일로 분리하고 각각에 테스트를 붙인다.
- mod, pub, use 로 모듈을 만들고 공개 범위를 정한다.
- 모듈을 src/ledger.rs 같은 별도 파일로 옮기는 규칙을 익힌다.
- #[test] 와 #[cfg(test)] 로 테스트를 작성하고 cargo test 로 실행한다.
- 문서 주석(///, //!)을 쓰고, 문서 예제가 실행되는 조건을 안다.
문제 상황
가계부 도구가 커져서 main.rs 가 200줄을 넘었다고 하자. 기록 구조체, 잔액 계산, 분류별 집계, 출력 코드가 한 파일에 섞여 있다. 어느 날 집계 코드를 고치다가 잔액 계산 함수의 부호를 실수로 바꿨다. 컴파일은 통과하고 프로그램도 돌아간다. 숫자가 틀렸다는 것은 사람이 출력을 눈으로 확인해야만 알 수 있다.
이 상황에는 두 가지 대책이 필요하다. 첫째는 나누기다. 기록을 다루는 코드와 집계 코드를 서로 다른 모듈에 두고, 바깥에서 써도 되는 함수만 공개한다. 둘째는 검증이다. "빈 장부의 잔액은 0", "지출은 잔액을 줄인다" 같은 기대를 코드로 적어 두고, 명령 하나로 전부 돌려 본다.
모듈과 공개 범위
모듈(module)은 이름을 가진 코드 묶음이다. 함수, 구조체, 열거형, 다른 모듈을 담을 수 있다. 가장 단순한 형태는 파일 안에 mod 블록을 쓰는 것이다.
mod greeting {
pub fn hello() -> String {
helper("안녕")
}
fn helper(word: &str) -> String {
format!("{word}, 가계부")
}
}
fn main() {
println!("{}", greeting::hello());
}
모듈 안의 항목은 기본적으로 비공개다. 바깥에서 쓰게 하려면 앞에 pub 을 붙인다. 위 예에서 hello 는 main 에서 greeting::hello() 로 부를 수 있고, helper 는 greeting 안에서만 쓸 수 있다. 모듈 안 함수의 세부 구현을 바꿔도 바깥 코드가 영향받지 않게 하려는 장치다.
공개 범위의 규칙
| 표기 | 볼 수 있는 곳 | 비고 |
|---|---|---|
| (아무것도 없음) | 선언한 모듈과 그 자식 모듈 | 기본값 |
| pub | 이 모듈을 볼 수 있는 모든 곳 | 부모가 모듈을 감추면 소용없다 |
| pub(crate) | 같은 크레이트 안 어디서나 | 크레이트는 cargo 가 만드는 컴파일 단위다 |
| pub(super) | 바로 위 부모 모듈 | 내부 협력용 |
기억할 규칙은 방향이다. 자식 모듈은 조상의 비공개 항목을 볼 수 있지만, 부모는 자식의 비공개 항목을 볼 수 없다. 뒤에서 테스트 모듈이 부모의 비공개 함수를 시험할 수 있는 것도 이 규칙 덕분이다.
구조체에서는 한 가지가 더 있다. struct 앞에 pub 을 붙여도 필드는 각각 따로 공개 여부를 정한다. 필드를 비공개로 두면 바깥에서는 만들어 둔 함수를 통해서만 값을 바꿀 수 있다. 다음 코드는 이 규칙에 걸린다.
// main.rs 에서 Ledger 의 비공개 필드를 직접 만지려는 시도
book.entries.push(Entry::new(Kind::Income, "급여", 100));
컴파일러는 error[E0616]: field `entries` of struct `Ledger` is private 라고 알려 준다. 요지는 필드가 비공개라서 바깥에서 접근할 수 없다는 것이다. add 메서드를 통해 넣으면 해결된다.
use 로 경로 줄이기
모듈 안의 항목은 경로(path)로 가리킨다. 경로는 크레이트 루트에서 시작하는 crate::ledger::Entry 처럼 쓰거나, 현재 위치 기준으로 self::, 부모 기준으로 super:: 를 붙여 쓴다. 매번 길게 쓰기 번거로우면 use 로 이름을 현재 범위에 들여온다.
use crate::ledger::{Kind, Ledger}; // 여러 항목을 묶어서
use std::collections::HashMap as Map; // as 로 다른 이름 붙이기
use 는 이름을 줄여 쓰게 할 뿐, 새로운 코드를 가져오지는 않는다. 이 점은 다음 절에서 다시 나온다.
파일로 모듈 나누기
mod 블록이 길어지면 파일로 옮긴다. 규칙은 단순하다. 크레이트 루트(src/main.rs)에 mod ledger; 처럼 본문 없이 세미콜론으로 끝나는 선언을 쓰면, 컴파일러가 src/ledger.rs 를 찾아 그 내용을 모듈 본문으로 취급한다. 모듈 안에 하위 모듈이 있으면 src/ledger/ 디렉터리 아래에 파일을 둔다. 예를 들어 ledger 의 하위 모듈 parse 는 src/ledger/parse.rs 에 놓고 ledger.rs 안에서 mod parse; 로 선언한다. 디렉터리 안에 mod.rs 를 두는 예전 방식도 남아 있지만, 새 코드는 이름.rs 방식이 파일 목록에서 구분하기 쉽다.
주의할 점은 파일을 만드는 것만으로는 아무 일도 일어나지 않는다는 것이다. 컴파일러는 크레이트 루트에서 출발해 mod 선언을 따라가며 파일을 읽는다. mod 선언이 없는 파일은 컴파일 대상이 아니다. 그래서 use 만 쓰고 mod 를 빼먹으면 이름을 찾을 수 없다는 오류가 난다.
테스트 작성과 실행
Rust 의 테스트는 별도 프레임워크 없이 언어와 cargo 에 들어 있다. 함수 앞에 #[test] 를 붙이면 그 함수는 테스트가 된다. 테스트 함수는 인자와 반환값이 없고, 패닉(panic)이 나지 않고 끝나면 성공이다. 패닉은 프로그램이 더 진행하지 않고 즉시 멈추는 오류 상태다. 조건을 확인하는 매크로가 조건이 틀리면 패닉을 일으키므로 테스트가 실패한다.
| 도구 | 하는 일 | 실패하는 경우 |
|---|---|---|
| assert!(조건) | 조건이 참인지 확인 | 조건이 거짓 |
| assert_eq!(왼쪽, 오른쪽) | 두 값이 같은지 확인 | 값이 다르면 두 값을 함께 출력 |
| assert_ne!(왼쪽, 오른쪽) | 두 값이 다른지 확인 | 값이 같음 |
| #[should_panic] | 패닉이 나야 성공인 테스트 표시 | 패닉이 나지 않음 |
assert_eq! 가 실패하면 기대와 실제 값이 함께 나온다. 예를 들어 잔액이 700 이어야 하는데 600 이 나왔다면 출력에 left: 600 과 right: 700 이 나란히 보인다. 이 두 값이 원인을 좁히는 첫 단서가 된다.
#[cfg(test)] 모듈
테스트는 보통 시험 대상 파일의 맨 아래에 tests 라는 모듈로 모은다. 이 모듈에 #[cfg(test)] 를 붙이면 cargo test 로 빌드할 때만 컴파일된다. cfg 는 조건부 컴파일(configuration)이라는 뜻으로, 조건이 참일 때만 그 항목을 컴파일에 포함한다. 일반 실행에서는 테스트 코드가 결과물에 들어가지 않는다.
tests 모듈은 부모 모듈의 자식이므로 부모의 비공개 함수도 볼 수 있다. 모듈 안에서 use super::*; 로 부모의 이름을 한꺼번에 들여오면 편하다. 이 덕분에 바깥에 공개하지 않는 내부 함수도 시험할 수 있다.
cargo test 와 문서 주석
cargo test 는 테스트 함수를 모아 병렬로 실행하고 결과를 요약한다. 이름 일부를 인자로 주면 그 문자열이 경로에 들어간 테스트만 돌린다. 예를 들어 cargo test balance 는 이름에 balance 가 들어간 테스트만 실행한다.
문서 주석은 코드 설명을 코드 옆에 붙이는 방법이다. /// 는 바로 뒤에 오는 항목을, //! 는 그 주석을 담고 있는 모듈 자체를 설명한다. 내용은 마크다운으로 쓰고, cargo doc 이 이를 웹 문서로 만든다. 일반 주석 // 는 문서에 나타나지 않는다. 문서 주석 안의 코드 블록은 라이브러리 크레이트(src/lib.rs)에서는 cargo test 때 예제로 실행되지만, 이 장의 예제처럼 main.rs 만 있는 바이너리 크레이트에서는 실행되지 않는다. 자세한 규칙은 rustdoc 문서의 문서 테스트 항목에서 확인할 수 있다.
완성 코드
cargo new household 로 만든 프로젝트에 아래 세 파일을 둔다. 외부 크레이트는 쓰지 않는다.
src/main.rs
mod ledger;
mod report;
use ledger::{Entry, Kind, Ledger};
fn main() {
let mut book = Ledger::new();
book.add(Entry::new(Kind::Income, "급여", 2_500_000));
book.add(Entry::new(Kind::Expense, "식비", 12_000));
book.add(Entry::new(Kind::Expense, "교통", 1_350));
book.add(Entry::new(Kind::Expense, "식비", 8_500));
book.add(Entry::new(Kind::Expense, "문화", 30_000));
println!("기록 {}건", book.entries().len());
report::print_summary(&book);
}
src/ledger.rs
//! 수입과 지출 기록을 담는 모듈.
/// 기록의 종류.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Kind {
Income,
Expense,
}
/// 한 건의 기록. 금액은 항상 0 이상으로 적고 부호는 종류로 구분한다.
pub struct Entry {
pub kind: Kind,
pub category: String,
pub amount: i64,
}
impl Entry {
/// 새 기록을 만든다.
pub fn new(kind: Kind, category: &str, amount: i64) -> Entry {
Entry {
kind,
category: category.to_string(),
amount,
}
}
fn signed(&self) -> i64 {
match self.kind {
Kind::Income => self.amount,
Kind::Expense => -self.amount,
}
}
}
/// 기록 모음. 기록은 add 로만 넣을 수 있다.
pub struct Ledger {
entries: Vec<Entry>,
}
impl Ledger {
/// 빈 장부를 만든다.
pub fn new() -> Ledger {
Ledger { entries: Vec::new() }
}
/// 기록 한 건을 추가한다.
pub fn add(&mut self, entry: Entry) {
self.entries.push(entry);
}
/// 수입은 더하고 지출은 빼서 잔액을 돌려준다.
pub fn balance(&self) -> i64 {
self.entries.iter().map(|e| e.signed()).sum()
}
/// 기록을 읽기 전용으로 빌려준다.
pub fn entries(&self) -> &[Entry] {
&self.entries
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_ledger_has_zero_balance() {
let book = Ledger::new();
assert_eq!(book.balance(), 0);
}
#[test]
fn expense_lowers_balance() {
let mut book = Ledger::new();
book.add(Entry::new(Kind::Income, "급여", 1000));
book.add(Entry::new(Kind::Expense, "식비", 300));
assert_eq!(book.balance(), 700);
}
#[test]
fn signed_flips_expense() {
let e = Entry::new(Kind::Expense, "교통", 1350);
assert_eq!(e.signed(), -1350);
}
}
src/report.rs
//! 장부를 집계해서 보여 주는 모듈.
use crate::ledger::{Kind, Ledger};
use std::collections::HashMap;
/// 지출만 분류별로 합산해 분류 이름 순으로 정렬해서 돌려준다.
pub fn totals_by_category(book: &Ledger) -> Vec<(String, i64)> {
let mut map: HashMap<String, i64> = HashMap::new();
for e in book.entries() {
if e.kind == Kind::Expense {
*map.entry(e.category.clone()).or_insert(0) += e.amount;
}
}
let mut rows: Vec<(String, i64)> = map.into_iter().collect();
rows.sort();
rows
}
/// 분류별 지출과 잔액을 출력한다.
pub fn print_summary(book: &Ledger) {
println!("== 분류별 지출 ==");
for (name, total) in totals_by_category(book) {
println!("{name}: {total}");
}
println!("잔액: {}", book.balance());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ledger::Entry;
#[test]
fn groups_expenses_by_category_in_order() {
let mut book = Ledger::new();
book.add(Entry::new(Kind::Expense, "식비", 100));
book.add(Entry::new(Kind::Expense, "교통", 50));
book.add(Entry::new(Kind::Expense, "식비", 20));
let rows = totals_by_category(&book);
assert_eq!(
rows,
vec![("교통".to_string(), 50), ("식비".to_string(), 120)]
);
}
#[test]
fn income_is_not_counted_as_expense() {
let mut book = Ledger::new();
book.add(Entry::new(Kind::Income, "급여", 1000));
assert!(totals_by_category(&book).is_empty());
}
}
줄별 해설
main.rs 의 mod 선언. mod ledger; 와 mod report; 는 두 파일을 크레이트에 포함시킨다. 이 두 줄이 없으면 ledger.rs 와 report.rs 는 컴파일되지 않는다. 그 아래 use ledger::{Entry, Kind, Ledger}; 는 main 에서 이름을 짧게 쓰려는 것이다.
ledger.rs 의 공개 범위. Kind, Entry, Ledger 와 그 메서드는 pub 이라 main 과 report 에서 쓴다. 반면 signed 는 pub 이 없어서 ledger 모듈 안에서만 쓴다. Ledger 의 entries 필드도 비공개다. 그래서 기록 추가는 add 로만 가능하고, 읽기는 entries() 가 돌려주는 슬라이스(&[Entry], 빌린 읽기 전용 조각)로만 가능하다. 반환하는 것이 참조이므로 소유권은 Ledger 에 그대로 남는다.
balance. iter().map(|e| e.signed()).sum() 은 각 기록을 부호 있는 금액으로 바꿔 모두 더한다. 합의 타입은 반환 타입 i64 로 정해진다.
report.rs 의 use. crate::ledger::{Kind, Ledger} 는 크레이트 루트부터 시작하는 경로다. 다른 파일의 모듈을 가리킬 때는 이 절대 경로가 읽기 쉽다. totals_by_category 는 HashMap 에 분류별 합을 모은 뒤 Vec 으로 옮겨 sort() 한다. 튜플은 첫 요소부터 비교되므로 분류 이름 순이 된다. HashMap 은 순회 순서가 정해져 있지 않기 때문에 정렬을 거쳐야 출력이 결정적이 된다.
tests 모듈. 두 파일 모두 #[cfg(test)] 아래에 tests 를 둔다. ledger 의 signed_flips_expense 는 비공개 함수를 직접 부르는데, tests 가 자식 모듈이라서 가능하다. report 쪽 테스트는 Entry 를 쓰므로 use crate::ledger::Entry; 를 따로 적었다. super::* 로는 report.rs 가 들여온 Kind, Ledger 가 함께 들어오지만 Entry 는 report.rs 에서 쓰지 않아 들여오지 않았기 때문이다.
문서 주석. 각 파일 맨 위의 //! 는 모듈 설명이고, 항목 앞의 /// 는 그 항목의 설명이다.
실행 결과
$ cargo run
기록 5건
== 분류별 지출 ==
교통: 1350
문화: 30000
식비: 20500
잔액: 2448150
잔액은 2,500,000 에서 지출 12,000 + 1,350 + 8,500 + 30,000 = 51,850 을 뺀 2,448,150 이다. 분류 이름은 문자열 정렬 순서(교, 문, 식)로 나온다. 이제 테스트를 돌린다.
$ cargo test
running 5 tests
test ledger::tests::empty_ledger_has_zero_balance ... ok
test ledger::tests::expense_lowers_balance ... ok
test ledger::tests::signed_flips_expense ... ok
test report::tests::groups_expenses_by_category_in_order ... ok
test report::tests::income_is_not_counted_as_expense ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
위 출력은 컴파일 진행 줄과 실행 파일 경로 줄을 생략했다. 그 줄들은 환경마다 다르다. 테스트는 병렬로 돌기 때문에 test 줄의 순서도 실행마다 바뀔 수 있다.
실무에서 자주 틀리는 것
1. 파일만 만들고 mod 선언을 빼먹는다
틀린 코드는 src/ledger.rs 를 만들고 main.rs 에서 바로 use 만 쓴 경우다.
// src/main.rs
use ledger::Ledger; // mod ledger; 가 없다
fn main() {
let _book = Ledger::new();
}
컴파일러는 unresolved import `ledger` 계열의 오류를 낸다. 요지는 ledger 라는 이름을 찾을 수 없다는 것이다. 파일은 mod 선언을 통해서만 크레이트에 들어온다. 고친 코드는 맨 위에 mod ledger; 를 추가한다.
// src/main.rs
mod ledger;
use ledger::Ledger;
fn main() {
let _book = Ledger::new();
}
2. 바깥에서 쓸 함수에 pub 을 빼먹는다
// src/report.rs
fn print_summary(book: &Ledger) { /* ... */ }
// src/main.rs
report::print_summary(&book);
오류는 error[E0603]: function `print_summary` is private 이다. 요지는 함수가 비공개라서 다른 모듈에서 부를 수 없다는 것이다. 함수 앞에 pub 을 붙인다. 반대로 pub 을 붙였는데 아무 곳에서도 쓰지 않으면, 바이너리 크레이트에서는 사용되지 않는 코드라는 경고가 나온다. 공개 범위는 쓰이는 만큼만 연다.
// src/report.rs
pub fn print_summary(book: &Ledger) { /* ... */ }
3. 테스트 모듈에 #[cfg(test)] 를 빼먹는다
mod tests {
use super::*;
#[test]
fn works() { assert_eq!(1 + 1, 2); }
}
이 모듈은 일반 빌드에서도 컴파일 대상이다. #[test] 함수는 일반 빌드에서 제외되므로 use super::*; 만 남아서 unused import 경고가 나온다. 테스트 전용 도우미 함수를 두었다면 사용되지 않는 함수 경고도 함께 나온다. 모듈 앞에 속성을 붙인다.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn works() { assert_eq!(1 + 1, 2); }
}
4. 테스트가 HashMap 의 순회 순서에 기대고 있다
let rows: Vec<(String, i64)> = map.into_iter().collect();
assert_eq!(rows[0].0, "교통"); // 순서가 정해져 있지 않다
HashMap 은 순회 순서를 약속하지 않는다. 이런 테스트는 어떤 실행에서는 통과하고 어떤 실행에서는 실패해서 원인을 찾기 어렵다. 결과를 정렬해서 돌려주는 함수를 만들고, 그 함수를 검증한다.
let mut rows: Vec<(String, i64)> = map.into_iter().collect();
rows.sort();
assert_eq!(rows[0].0, "교통");
한눈에 보기
| 주제 | 쓰는 법 | 핵심 규칙 |
|---|---|---|
| 모듈 선언 | mod 이름 { ... } 또는 mod 이름; | 세미콜론 형태는 이름.rs 를 찾아 포함한다 |
| 공개 | pub, pub(crate) | 기본은 비공개, 필드는 따로 공개한다 |
| 경로 | crate::, super::, self:: | use 는 이름만 줄여 준다 |
| 테스트 | #[test], assert_eq! | 패닉 없이 끝나면 성공 |
| 테스트 모듈 | #[cfg(test)] mod tests | cargo test 빌드에만 포함된다 |
| 문서 주석 | /// 항목, //! 모듈 | 마크다운, cargo doc 으로 문서 생성 |
연습 문제
- report.rs 에
pub fn total_expense(book: &Ledger) -> i64를 추가한다. 지출 기록의 금액을 모두 더하는 함수다. main 에서 호출해 결과를 출력하고, tests 모듈에 테스트를 하나 추가한다. - 다음 코드가 컴파일되지 않는 이유를 설명하고 고친다.
mod shop { fn price() -> u32 { 100 } } fn main() { println!("{}", shop::price()); } - 이 장의 완성 코드에서
cargo test expense를 실행하면 몇 개의 테스트가 돌아가는가. 돌아가지 않는 테스트는 무엇인가. - ledger.rs 맨 위의
//!와 항목 앞의///는 무엇이 다른가. 이 두 주석이 붙는 대상을 각각 적는다.
정답과 해설
1번. 예시 답은 다음과 같다.
pub fn total_expense(book: &Ledger) -> i64 {
book.entries()
.iter()
.filter(|e| e.kind == Kind::Expense)
.map(|e| e.amount)
.sum()
}
// tests 모듈 안
#[test]
fn total_expense_ignores_income() {
let mut book = Ledger::new();
book.add(Entry::new(Kind::Income, "급여", 1000));
book.add(Entry::new(Kind::Expense, "식비", 300));
book.add(Entry::new(Kind::Expense, "교통", 200));
assert_eq!(total_expense(&book), 500);
}
main 에서 println!("총 지출: {}", report::total_expense(&book)); 를 호출해야 한다. 호출하지 않으면 cargo run 에서 사용되지 않는 함수 경고가 나온다. 예제 데이터로는 51850 이 출력된다.
2번. price 가 pub 이 아니라서 shop 바깥의 main 이 부를 수 없다. error[E0603]: function `price` is private 가 나온다. pub fn price() 로 고친다.
3번. 이름에 expense 가 들어간 테스트는 4개다. expense_lowers_balance, signed_flips_expense, groups_expenses_by_category_in_order, income_is_not_counted_as_expense 이다. 돌아가지 않는 것은 empty_ledger_has_zero_balance 하나다. 이름 필터는 모듈 경로를 포함한 전체 이름에서 부분 문자열을 찾는다.
4번. //! 는 그 주석이 들어 있는 모듈(또는 크레이트) 자체를 설명한다. ledger.rs 맨 위의 //! 는 ledger 모듈의 설명이 된다. /// 는 바로 뒤에 오는 함수, 구조체, 열거형 같은 항목 하나를 설명한다.