이 글에서는 Rust 단위 테스트에서 사용하는 두 가지 테스트 더블, 스파이와 더미를 소개하고, 슈퍼빌런/사이드킥/부하 콘셉트를 통해 스파이 구현과 더미 적용, 그리고 관련 테스트를 작성·검증하는 과정을 예제로 설명합니다.
지난 글에서 테스트 더블이 무엇이며 왜 필요한지 설명했다. 이번 글에서는 테스트 더블의 두 가지 유형, 즉 스파이와 더미에 초점을 맞춘다.1
스파이는 자신에게 호출이 들어왔는지와 각 호출에 사용된 인자를 기억하는 테스트 더블이다. 호출자 인스턴스의 활동을 ‘엿본다(spy).’ 더미는 아무 일도 하지 않지만, 다른 것을 만들거나 사용하기 위해서 필요하다.
상황을 설정해 보자. 슈퍼빌런이 슈퍼인 이유는 날 수 있어서가 아니다(대부분은 날 수 없다). 악행을 널리 퍼뜨릴 수 있기 때문이다. 정말 효과를 내려면, 사이드킥만으로는 부족하다. 부하도 필요하다. 부하는 쓸모 있고 소모 가능하다. 다만 아주 똑똑하지는 않다. 그래서 슈퍼빌런이 내리는 명령은 명확하고 간결해야 한다.
먼저 부하(henchman)를 정의하자. 이를 트레잇으로 모델링할 것이다. 새 파일(henchman.rs)에 넣고 lib.rs에 추가한다. 이 트레잇에는 본부를 짓도록 지시할 때 사용할 단 하나의 메서드가 있다.
//! Module to define henchmen.
#![allow(dead_code)]
/// Henchman trait.
pub trait Henchman {
fn build_secret_hq(&mut self, location: String);
}
lib.rs 파일에 추가한다.
pub mod henchman;
pub use henchman::Henchman;
슈퍼빌런들이 ‘일’을 시작하면 자리 잡아야 하고, 집값이 천정부지로 치솟았기 때문에 부하에게 본부를 짓게 해야 한다. 슈퍼빌런들이 본부 설계에는 일류 건축가를 쓰고, 토지는 ‘어떻게든’ 헐값에 구한다는 점은 인정하자. 어쨌든 슈퍼빌런은 start_world_domination_stage1()이라는 새 메서드로 세계 정복을 시작할 수 있다. 이 메서드에서 슈퍼빌런은 사이드킥에게 가젯을 건네고, 약한 도시들의 목록을 가져오라고 시킨다. 그런 다음 목록의 첫 번째 도시에 본부를 짓도록 부하에게 지시한다. 아마 가장 약한 도시일 것이다.
먼저 새 Supervillain 메서드를 정의하고 Henchman과 Gadget을 임포트하자. Gadget은 이전 글에서 이미 정의했다.
pub fn start_world_domination_stage1<H: Henchman, G: Gadget>(&self, henchman: &mut H, gadget: &G) {
}
이 메서드 구현에서는, 가젯을 사용해 사이드킥에게서 약한 타깃 목록을 가져오는 사이드킥이 필요하다.
if let Some(ref sidekick) = self.sidekick {
let targets = sidekick.get_weak_targets(gadget);
}
약한 도시 목록이 비어 있지 않을 때만 계속 진행할 수 있다. 그렇지 않으면 세계 정복 1단계 작업을 끝내고 리턴해야 한다.
if !targets.is_empty() {
}
하지만 약한 도시 목록에 항목이 하나라도 있다면, 전달받은 부하에게 선택된 타깃 도시에 build_secret_hq()를 하라고 지시할 수 있다.
henchman.build_secret_hq(targets[0].clone());
아직 한 부분이 빠져 있다. Sidekick에 새 메서드 선언이 필요하지만, 구현은 빈 껍데기면 된다.
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
vec![]
}
이 정도면 충분하겠지만, 정말 문제 없는지 컴파일해 보자. 먼저 라이브러리를 다음으로 컴파일한다
cargo
b --lib
. 지금까지는 좋다. 그리고 cargo t --lib로 테스트도 돌려 보니… 지난 글에서 만든 Sidekick의 테스트 더블에 새 메서드가 없다. tests::doubles에 정의하고 그 모듈에서 Gadget을 임포트해야 한다.
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
vec![]
}
이제 시나리오가 준비되었다.
세계 정복 1단계를 트리거하는 메서드를 테스트하려고 한다. 이 메서드는 부하와 가젯을 요구한다. 부하는 나중에 다루기로 하고, 이 메서드에서 사용하는 가젯은 그대로 사이드킥에게 전달만 된다는 점을 깨닫는다. 우리가 쓸 테스트의 사이드킥은 테스트 더블이므로, 가젯으로 실제로 무언가를 할 필요가 없다.
이것이 전형적인 더미의 사용 사례다. 더미 없이 테스트를 쓸 수는 없지만, 실제로 사용되지 않으므로 빈 껍데기여도 된다.
Supervillain의 tests 모듈에 GadgetDummy를 만든다. tests::doubles는 지난 글에서처럼 컴파일 타임 설정 조건으로 교체해야 하는 더블들을 위해 남겨 두자.
struct GadgetDummy;
impl Gadget for GadgetDummy {
fn do_stuff(&self) {}
}
이전 글에서는 Sidekick의 스텁을 구현하면서, agree() 메서드가 호출될 때 어떤 값을 돌려줄지 제어할 수 있게 했다. 이번에는 다른 메서드 get_weak_targets()에도 같은 방식을 적용하자. 호출에 응답할 값을 담아 둘 필드를 Sidekick 더블에 추가하고, 관련 생성자 연관 함수에서 초기화한다.2
pub struct Sidekick<'a> {
phantom: PhantomData<&'a ()>,
pub agree_answer: bool,
pub targets: Vec<String>,
}
impl<'a> Sidekick<'a> {
pub fn new() -> Sidekick<'a> {
Sidekick {
phantom: PhantomData,
agree_answer: false,
targets: vec![],
}
}
테스트를 더 읽기 쉽게 하고 오탈자로 인한 실수를 줄이기 위해 몇 가지 상수를 정의하자. 이에 대해서는 이 연재의 두 번째 글에서 설명했다. 이 상수에는 첫 번째 타깃 도시와 전체 목록이 들어간다. 이 상수들은 단 하나의 테스트에서만 쓰겠지만, 일관성을 위해 test_common 모듈(test_common.rs 파일)에 두겠다.
pub const FIRST_TARGET: &str = "Tampa";
pub const TARGETS: [&'static str; 3] = [FIRST_TARGET, "Pamplona", "Vilnius"];
그리고 Sidekick 더블의 메서드를 구현해 targets 필드에 저장된 값을 반환하도록 하자.
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
self.targets.clone()
}
이제 준비가 되었으니 조명을 낮추고 좋아하는 음악 스트리밍 앱에서 “The Spy Who Loved Me”를 재생해 보자. Henchman의 테스트 더블을 구현할 건데, 스파이로 만들 것이다. 슈퍼빌런이 비밀 본부를 짓도록 지시했는지, 했다면 그 위치 인자를 무엇으로 넘겼는지 알고 싶기 때문이다.
Supervillain의 tests 모듈에 HenchmanSpy 타입을 만든다. 이 타입은 우리가 테스트하는 메서드에서 사용할 수 있도록 Henchman 트레잇을 구현해야 한다.
struct HenchmanSpy;
impl Henchman for HenchmanSpy {
fn build_secret_hq(&mut self, location: String) {
}
}
(테스트) 스파이의 목적을 달성하려면, build_secret_hq() 호출에 사용된 인자를 기억하기 위한 필드를 HenchmanSpy에 추가해야 한다. 메서드가 호출되지 않았다면 None이 들어가도록 Option<String>으로 만든다.
struct HenchmanSpy {
hq_location: Option<String>,
}
그리고 모니터링하려는 메서드 구현에서 방금 정의한 필드에 인자를 저장한다.
impl Henchman for HenchmanSpy {
fn build_secret_hq(&mut self, location: String) {
self.hq_location = Some(location);
}
}
이제 모든 테스트 더블을 준비했으니, 테스트를 작성할 수 있다. 먼저 골격부터 보자. 세계 정복 1단계를 실행하면 본부가 가장 약한 도시에 지어지는지를 테스트하려 한다.
#[test_context(Context)]
#[test]
fn world_domination_stage1_builds_hq_in_first_weak_target(ctx: &mut Context) {
}
준비 단계에서 GadgetDummy, HenchmanSpy(필드 초기화 포함), 그리고 Sidekick 더블 인스턴스를 만든다. 또한 목록 요청 시 어떤 타깃들을 제공할지 사이드킥 더블에 알려준다. 그리고 그 사이드킥 더블을 우리가 테스트하는 슈퍼빌런 인스턴스(SUT: system under test)에 할당한다.
let gdummy = GadgetDummy{};
let mut hm_spy = HenchmanSpy {
hq_location: None,
};
let mut sk_double = doubles::Sidekick::new();
sk_double.targets = test_common::TARGETS.map(String::from).to_vec();
ctx.sut.sidekick = Some(sk_double);
행동(act) 단계는 가장 간단하다. 우리의 헨치맨 스파이와 가젯 더미를 인자로 넘겨, 세계 정복 1단계를 트리거하는 메서드를 호출한다.
ctx.sut.start_world_domination_stage1(&mut hm_spy, &gdummy);
검증(assert)에서는 헨치맨 스파이에 저장된 본부 위치가 타깃 목록의 첫 번째 도시인지 확인한다. 이는 Henchman의 메서드가 올바른 인자로 호출된 경우에만 성립한다.
assert_eq!(hm_spy.hq_location, Some(test_common::FIRST_TARGET.to_string()));
컴파일하고 간 맞춘 다음(cargo t --lib) 테스트를 돌리자. 모두 통과해야 한다. 할렐루야!
이 글에서 여러 파일을 변경했으니, 이 연재의 모든 코드를 담은 레포의 해당 커밋을 확인해 보길 권한다. 하지만 변화 내용을 더 잘 이해할 수 있도록, 이번 글 시점의 supervillain.rs 최종 버전을 아래에 실어 둔다.
//! Module for supervillains and their related stuff
#![allow(unused)]
use std::time::Duration;
use thiserror::Error;
#[cfg(not(test))]
use crate::sidekick::Sidekick;
use crate::{Gadget, Henchman};
#[cfg(test)]
use tests::doubles::Sidekick;
/// Type that represents supervillains.
#[derive(Default)]
pub struct Supervillain<'a> {
pub first_name: String,
pub last_name: String,
pub sidekick: Option<Sidekick<'a>>,
}
pub trait Megaweapon {
fn shoot(&self);
}
impl Supervillain<'_> {
/// Return the value of the full name as a single string.
///
/// Full name is produced concatenating first name, a single space, and the last name.
///
/// # Examples
/// ```
///# use evil::supervillain::Supervillain;
/// let lex = Supervillain {
/// first_name: "Lex".to_string(),
/// last_name: "Luthor".to_string(),
/// };
/// assert_eq!(lex.full_name(), "Lex Luthor");
/// ```
pub fn full_name(&self) -> String {
format!("{} {}", self.first_name, self.last_name)
}
pub fn set_full_name(&mut self, name: &str) {
let components = name.split(" ").collect::<Vec<_>>();
println!("Received {} components.", components.len());
if components.len() != 2 {
panic!("Name must have first and last name");
}
self.first_name = components[0].to_string();
self.last_name = components[1].to_string();
}
pub fn attack(&self, weapon: &impl Megaweapon) {
weapon.shoot();
}
pub async fn come_up_with_plan(&self) -> String {
tokio::time::sleep(Duration::from_millis(100)).await;
String::from("Take over the world!")
}
pub fn conspire(&mut self) {
if let Some(ref sidekick) = self.sidekick {
if !sidekick.agree() {
self.sidekick = None;
}
}
}
pub fn start_world_domination_stage1<H: Henchman, G: Gadget>(
&self,
henchman: &mut H,
gadget: &G,
) {
if let Some(ref sidekick) = self.sidekick {
let targets = sidekick.get_weak_targets(gadget);
if !targets.is_empty() {
henchman.build_secret_hq(targets[0].clone());
}
}
}
}
impl TryFrom<&str> for Supervillain<'_> {
type Error = EvilError;
fn try_from(name: &str) -> Result<Self, Self::Error> {
let components = name.split(" ").collect::<Vec<_>>();
if components.len() < 2 {
Err(EvilError::ParseError {
purpose: "full_name".to_string(),
reason: "Too few arguments".to_string(),
})
} else {
Ok(Supervillain {
first_name: components[0].to_string(),
last_name: components[1].to_string(),
sidekick: None,
})
}
}
}
#[derive(Error, Debug)]
pub enum EvilError {
#[error("Parse error: purpose='{}', reason='{}'", .purpose, .reason)]
ParseError { purpose: String, reason: String },
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use test_context::{AsyncTestContext, TestContext, test_context};
use crate::test_common;
use super::*;
#[test_context(Context)]
#[test]
fn full_name_is_first_name_space_last_name(ctx: &mut Context) {
let full_name = ctx.sut.full_name();
assert_eq!(
full_name,
test_common::PRIMARY_FULL_NAME,
"Unexpected full name"
);
}
#[test_context(Context)]
#[test]
fn set_full_name_sets_first_and_last_names(ctx: &mut Context) {
ctx.sut.set_full_name(test_common::SECONDARY_FULL_NAME);
assert_eq!(ctx.sut.first_name, test_common::SECONDARY_FIRST_NAME);
assert_eq!(ctx.sut.last_name, test_common::SECONDARY_LAST_NAME);
}
#[test_context(Context)]
#[test]
#[should_panic(expected = "Name must have first and last name")]
fn set_full_name_panics_with_empty_name(ctx: &mut Context) {
ctx.sut.set_full_name("");
}
#[test]
fn try_from_str_slice_produces_supervillain_full_with_first_and_last_name()
-> Result<(), EvilError> {
let sut = Supervillain::try_from(test_common::SECONDARY_FULL_NAME)?;
assert_eq!(sut.first_name, test_common::SECONDARY_FIRST_NAME);
assert_eq!(sut.last_name, test_common::SECONDARY_LAST_NAME);
Ok(())
}
#[test]
fn try_from_str_slice_produces_error_with_less_than_two_substrings() {
let result = Supervillain::try_from("");
let Err(error) = result else {
panic!("Unexpected value returned by try_from");
};
assert!(
matches!(error, EvilError::ParseError { purpose, reason } if purpose =="full_name" && reason == "Too few arguments")
)
}
#[test_context(Context)]
#[test]
fn attack_shoots_weapon(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon);
assert!(*weapon.is_shot.borrow());
}
#[test_context(Context)]
#[tokio::test]
async fn plan_is_sadly_expected(ctx: &mut Context<'_>) {
assert_eq!(ctx.sut.come_up_with_plan().await, "Take over the world!");
}
#[test_context(Context)]
#[test]
fn keep_sidekick_if_agrees_with_conspiracy(ctx: &mut Context) {
let mut sk_double = doubles::Sidekick::new();
sk_double.agree_answer = true;
ctx.sut.sidekick = Some(sk_double);
ctx.sut.conspire();
assert!(ctx.sut.sidekick.is_some(), "Sidekick fired unexpectedly");
}
#[test_context(Context)]
#[test]
fn fire_sidekick_if_doesnt_agree_with_conspiracy(ctx: &mut Context) {
let mut sk_double = doubles::Sidekick::new();
sk_double.agree_answer = false;
ctx.sut.sidekick = Some(sk_double);
ctx.sut.conspire();
assert!(
ctx.sut.sidekick.is_none(),
"Sidekick not fired unexpectedly"
);
}
#[test_context(Context)]
#[test]
fn conspiracy_without_sidekick_doesnt_fail(ctx: &mut Context) {
ctx.sut.conspire();
assert!(ctx.sut.sidekick.is_none(), "Unexpected sidekick");
}
#[test_context(Context)]
#[test]
fn world_domination_stage1_builds_hq_in_first_weak_target(ctx: &mut Context) {
let gdummy = GadgetDummy {};
let mut hm_spy = HenchmanSpy { hq_location: None };
let mut sk_double = doubles::Sidekick::new();
sk_double.targets = test_common::TARGETS.map(String::from).to_vec();
ctx.sut.sidekick = Some(sk_double);
ctx.sut.start_world_domination_stage1(&mut hm_spy, &gdummy);
assert_eq!(
hm_spy.hq_location,
Some(test_common::FIRST_TARGET.to_string())
);
}
pub(crate) mod doubles {
use std::marker::PhantomData;
use crate::Gadget;
pub struct Sidekick<'a> {
phantom: PhantomData<&'a ()>,
pub agree_answer: bool,
pub targets: Vec<String>,
}
impl<'a> Sidekick<'a> {
pub fn new() -> Sidekick<'a> {
Sidekick {
phantom: PhantomData,
agree_answer: false,
targets: vec![],
}
}
pub fn agree(&self) -> bool {
self.agree_answer
}
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
self.targets.clone()
}
}
}
struct GadgetDummy;
impl Gadget for GadgetDummy {
fn do_stuff(&self) {}
}
struct HenchmanSpy {
hq_location: Option<String>,
}
impl Henchman for HenchmanSpy {
fn build_secret_hq(&mut self, location: String) {
self.hq_location = Some(location);
}
}
struct WeaponDouble {
pub is_shot: RefCell<bool>,
}
impl WeaponDouble {
fn new() -> WeaponDouble {
WeaponDouble {
is_shot: RefCell::new(false),
}
}
}
impl Megaweapon for WeaponDouble {
fn shoot(&self) {
,*self.is_shot.borrow_mut() = true;
}
}
struct Context<'a> {
sut: Supervillain<'a>,
}
impl<'a> AsyncTestContext for Context<'a> {
async fn setup() -> Context<'a> {
Context {
sut: Supervillain {
first_name: test_common::PRIMARY_FIRST_NAME.to_string(),
last_name: test_common::PRIMARY_LAST_NAME.to_string(),
..Default::default()
},
}
}
async fn teardown(self) {}
}
}
이번에는 테스트 더블 두 가지, 스파이와 더미를 소개하고 사용해 보았다.
스파이는 SUT가 다른 타입과 기대한 방식으로 상호작용했는지(기대한 메서드, 기대한 인자, 심지어 호출 횟수까지) 확인할 때 매우 유용하다. 사실 이 연재의 첫 번째 글에서도 슈퍼빌런이 메가웨폰을 사용해 공격했는지 검증하는 데 스파이를 사용했다.
더미는 하는 일이 거의 없지만, 어떤 메서드를 호출하거나 인스턴스를 생성하는 것이 그것 없이는 불가능할 때 필요하다.
이번 사례에서는 필요한 트레잇을 구현한 인스턴스로 교체했다. 이는 Sidekick 스텁을 교체할 때보다 확실히 쉽게 느껴지지만, 항상 가능한 것은 아니다. Henchman에 대한 가변 참조를 받는 것이 다소 자기합리화처럼 보일 수 있고(동의한다), 가변 참조가 불가능한 경우에는 메가웨폰 더블에서 설명했듯 내부 가변성(interior mutability)으로 우회할 수 있다.
다음 글에서는 목(mock)을 설명하고 함께 하나를 작성해 보겠다. 테스트 더블 다섯 가지 중 세 가지를 설명했고, 두 가지가 남았다.
호기심을 유지하라. 코드를 해킹하라. 다음에 또 보자!
영화를 빗댄 이 부제목을 그냥 지나칠 수가 없었다. “스파이와 더미의 모험.” 근처 영화관에서 곧 개봉 예정…일지도.
Sidekick 더블에 Default를 derive해서, 새 필드가 추가되더라도 초기화를 더 간단히 할 수 있다.