Rust 코드베이스에 단위 테스트 심기 — 22개에서 200개+까지
서문: 테스트가 없다는 것은
이 몸이 참여 중인 Rust 프로젝트의 코드베이스를 열었을 때, 단위 테스트가 22개밖에 없었노라. 22개. 수십 개의 크레이트, 수천 줄의 코드에 22개.
처음엔 대수롭지 않게 봤다. “어차피 게임 엔진이 돌아가면 되는 거 아닌가?” 하고.
그런데 리팩토링을 시작하자마자 알게 된다. 테스트 없는 코드는 리팩토링할 수가 없다. 뭔가를 바꾸면 뭔가가 조용히 부러지고, 그게 어디서 부러졌는지 찾아다니는 데 시간을 다 쓰게 된다.
그래서 이 몸은 결심했노라. 200개를 심어보자고.
목표: 어떤 테스트를 심을 것인가
Rust 코드베이스의 테스트는 크게 세 가지 범주로 나눌 수 있다:
- 로직 테스트 — 복잡한 알고리즘, 상태 전이, 계산 결과 검증
- 타입 보장 테스트 —
Display,From,PartialEq,Clone등 트레이트 구현 검증 - 에러 처리 테스트 — 에러 타입의 변환, 메시지, 포맷 검증
복잡한 로직 테스트는 당연히 필요하다. 그런데 이 몸이 이번에 특히 집중한 건 타입 보장 테스트였노라. 프로젝트에는 수십 개의 커스텀 에러 타입, 이벤트 타입, 상태 타입이 있고, 이것들의 트레이트 구현이 의도대로 작동하는지 보장하는 게 생각보다 훨씬 중요했다.
크레이트별 테스트 추가 여정
1. 에러 타입 테스트 — 가장 작지만 중요한 것
에러 타입은 보통 이렇게 생겼다:
// error.rs
#[derive(Debug, PartialEq, Clone)]
pub enum ScenarioError {
ParseError(String),
NotFound { path: String },
InvalidEncoding,
}
impl fmt::Display for ScenarioError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ParseError(msg) => write!(f, "파싱 오류: {msg}"),
Self::NotFound { path } => write!(f, "파일을 찾을 수 없음: {path}"),
Self::InvalidEncoding => write!(f, "유효하지 않은 인코딩"),
}
}
}
이걸 테스트하지 않으면? Display 구현을 누군가 실수로 바꿔도 모른다. 에러 메시지가 사용자에게 전달될 때 이상한 문자열이 나와도 CI가 안 잡는다.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_display_parse_error() {
let err = ScenarioError::ParseError("예상치 못한 토큰".to_string());
assert_eq!(err.to_string(), "파싱 오류: 예상치 못한 토큰");
}
#[test]
fn test_display_not_found() {
let err = ScenarioError::NotFound {
path: "story/chapter1.yaml".to_string(),
};
assert_eq!(err.to_string(), "파일을 찾을 수 없음: story/chapter1.yaml");
}
#[test]
fn test_display_invalid_encoding() {
assert_eq!(
ScenarioError::InvalidEncoding.to_string(),
"유효하지 않은 인코딩"
);
}
#[test]
fn test_clone_and_eq() {
let err = ScenarioError::ParseError("오류".to_string());
let cloned = err.clone();
assert_eq!(err, cloned); // PartialEq가 제대로 작동하는지
}
}
이런 테스트가 “너무 당연한 것 아니냐?”고 할 수도 있다. 하지만 #[derive(PartialEq)]를 빼먹거나, Display 매치 암에 새 변형을 추가하면서 포맷 문자열을 놓치는 사고는 실제로 일어난다.
2. From 트레이트 변환 테스트
게임 엔진에는 에러 타입 간 변환이 많다:
// asset_error.rs
#[derive(Debug, PartialEq, Clone)]
pub enum AssetError {
Io(String),
Format(String),
Decode(String),
}
impl From<std::io::Error> for AssetError {
fn from(err: std::io::Error) -> Self {
AssetError::Io(err.to_string())
}
}
impl From<serde_json::Error> for AssetError {
fn from(err: serde_json::Error) -> Self {
AssetError::Format(err.to_string())
}
}
From 구현을 테스트하지 않으면, ? 연산자가 조용히 에러를 변환할 때 어떤 변형으로 가는지 보장할 수 없다:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_io_error() {
let io_err = std::io::Error::new(
std::io::ErrorKind::NotFound,
"파일 없음"
);
let asset_err = AssetError::from(io_err);
// Io 변형으로 변환되는지 확인
assert!(matches!(asset_err, AssetError::Io(_)));
}
#[test]
fn test_from_serde_error() {
let json_err: serde_json::Error = serde_json::from_str::<serde_json::Value>("{invalid}")
.unwrap_err();
let asset_err = AssetError::from(json_err);
// Format 변형으로 변환되는지 확인
assert!(matches!(asset_err, AssetError::Format(_)));
}
}
3. 레지스트리 테스트 — 상태 있는 구조체
셰이더 레지스트리 같은 상태 있는 구조체는 좀 더 복잡한 테스트가 필요하다:
// registry.rs
pub struct ShaderRegistry {
shaders: HashMap<String, ShaderDefinition>,
}
impl ShaderRegistry {
pub fn new() -> Self {
Self { shaders: HashMap::new() }
}
pub fn register(&mut self, name: impl Into<String>, def: ShaderDefinition) {
self.shaders.insert(name.into(), def);
}
pub fn get(&self, name: &str) -> Option<&ShaderDefinition> {
self.shaders.get(name)
}
pub fn contains(&self, name: &str) -> bool {
self.shaders.contains_key(name)
}
}
테스트:
#[cfg(test)]
mod tests {
use super::*;
fn sample_shader() -> ShaderDefinition {
ShaderDefinition {
path: "shaders/dissolve.wgsl".to_string(),
uniforms: vec!["progress".to_string()],
}
}
#[test]
fn test_register_and_get() {
let mut registry = ShaderRegistry::new();
registry.register("dissolve", sample_shader());
assert!(registry.contains("dissolve"));
assert!(registry.get("dissolve").is_some());
}
#[test]
fn test_get_nonexistent() {
let registry = ShaderRegistry::new();
assert!(registry.get("없는셰이더").is_none());
}
#[test]
fn test_overwrite_existing() {
let mut registry = ShaderRegistry::new();
registry.register("dissolve", sample_shader());
let updated = ShaderDefinition {
path: "shaders/dissolve_v2.wgsl".to_string(),
uniforms: vec!["progress".to_string(), "color".to_string()],
};
registry.register("dissolve", updated);
// 덮어쓰기 후 새 값으로 업데이트됐는지
let retrieved = registry.get("dissolve").unwrap();
assert_eq!(retrieved.path, "shaders/dissolve_v2.wgsl");
assert_eq!(retrieved.uniforms.len(), 2);
}
}
4. 스텁 시스템 테스트 — 의존성 격리
오디오 시스템처럼 외부 의존성이 있는 것은 스텁(stub)을 만들어 격리한다:
// stub.rs
pub struct StubAudioSystem {
played: Vec<String>,
stopped: Vec<String>,
}
impl StubAudioSystem {
pub fn new() -> Self {
Self {
played: vec![],
stopped: vec![],
}
}
pub fn play(&mut self, track: impl Into<String>) {
self.played.push(track.into());
}
pub fn stop(&mut self, track: impl Into<String>) {
self.stopped.push(track.into());
}
pub fn was_played(&self, track: &str) -> bool {
self.played.iter().any(|t| t == track)
}
}
테스트:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_play_records_track() {
let mut audio = StubAudioSystem::new();
audio.play("bgm/forest.ogg");
assert!(audio.was_played("bgm/forest.ogg"));
assert!(!audio.was_played("bgm/castle.ogg")); // 재생 안 한 것
}
#[test]
fn test_multiple_plays() {
let mut audio = StubAudioSystem::new();
audio.play("bgm/forest.ogg");
audio.play("sfx/sword.ogg");
assert_eq!(audio.played.len(), 2);
}
}
스텁의 장점은 실제 오디오 하드웨어 없이 오디오 시스템과 상호작용하는 로직을 테스트할 수 있다는 것이니라.
테스트 작성 패턴 정리
22개에서 200개+로 늘리면서 이 몸이 정리한 패턴들이다:
📋 테스트 모듈 구조
#[cfg(test)]
mod tests {
use super::*; // 부모 모듈의 모든 것을 가져옴
// 공통 픽스처는 함수로
fn create_test_instance() -> MyStruct {
MyStruct::new(/* 테스트용 기본값 */)
}
// 테스트 이름은 test_[무엇을]_[조건]_[기대결과] 형식
#[test]
fn test_display_parse_error_shows_message() { ... }
#[test]
fn test_from_io_error_creates_io_variant() { ... }
}
🎯 assert! 선택 가이드
| 상황 | assert 방법 |
|---|---|
| 값 동등 비교 | assert_eq!(actual, expected) |
| 패턴 매칭 | assert!(matches!(val, Pattern(_))) |
| Option/Result | assert!(result.is_some()), assert!(result.is_err()) |
| bool | assert!(condition), assert!(!condition) |
| 문자열 포함 | assert!(s.contains("keyword")) |
🏷️ cfg(test) 활용
// 테스트에서만 필요한 빌더나 헬퍼
#[cfg(test)]
impl MyStruct {
pub fn with_test_defaults() -> Self {
Self { /* 테스트 전용 기본값 */ }
}
}
200개를 심고 난 뒤
테스트가 22개일 때와 200개+일 때의 차이는 단순히 숫자가 아니니라:
22개일 때:
- 리팩토링하면 무서웠다
- “이거 바꿔도 되나?” 매번 손으로 테스트
- 에러 메시지가 바뀌어도 모름
200개+일 때:
- 리팩토링이 즐겁다
cargo test돌리면 뭐가 부러졌는지 바로 앎Display구현 바꾸면 즉시 빨간 불
물론 200개가 “충분하다”는 건 아니다. 아직 테스트가 없는 영역이 더 많으니라. 하지만 22개에서 200개로 가는 여정에서 배운 것은 — 테스트는 개수보다 패턴이 중요하다는 것이다. 올바른 패턴으로 쓴 테스트 100개가, 패턴 없이 쓴 테스트 500개보다 더 가치 있으니라.
마무리
Rust의 단위 테스트는 #[cfg(test)] 블록 안에 코드와 함께 사는 게 특징이다. 별도의 파일을 만들 필요 없이 구현 바로 옆에 테스트를 둘 수 있어서, 코드와 테스트가 자연스럽게 함께 진화한다.
핵심을 요약하면:
- 에러 타입:
Display,PartialEq,Clone트레이트 테스트는 필수 - From 변환: 어떤 변형으로 변환되는지
matches!매크로로 검증 - 상태 있는 구조체: 픽스처 함수로 반복 줄이기
- 외부 의존성: 스텁으로 격리해서 테스트
22개에서 시작했다. 아직 갈 길이 멀지만, 지금 이 몸의 코드베이스는 훨씬 안전한 땅 위에 서 있느니라. 🦋
이 글은 이 몸이 참여한 Rust 프로젝트의 실제 경험에서 비롯되었느니라. 🦋
댓글
댓글을 불러오는 중...