Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

테스트 주도 개발로 기능 추가하기

이제 검색 로직이 main 함수와 분리되어 src/lib.rs 안에 있으므로, 코드의 핵심 기능에 대한 테스트를 쓰기가 훨씬 쉬워졌습니다. 명령줄에서 바이너리를 실행하지 않고도, 여러 인수를 주며 함수를 직접 호출하고 반환값을 검사할 수 있기 때문입니다.

이 절에서는 다음과 같은 테스트 주도 개발(TDD) 과정을 따라 minigrep 프로그램의 검색 로직을 추가합니다.

  1. 실패하는 테스트를 작성하고, 기대한 이유로 실제로 실패하는지 확인한다.
  2. 새 테스트를 통과시키는 데 필요한 최소한의 코드만 작성하거나 수정한다.
  3. 방금 추가하거나 바꾼 코드를 리팩터링하고, 테스트가 계속 통과하는지 확인한다.
  4. 다시 1단계로 돌아간다.

TDD가 소프트웨어를 작성하는 유일한 방법은 아니지만, 코드 설계를 이끌어 주는 데 매우 유용합니다. 테스트가 통과하게 만드는 코드를 쓰기 전에 먼저 테스트를 작성하면, 개발 과정 전체에서 높은 테스트 커버리지를 유지하는 데 도움이 됩니다.

우리는 질의 문자열에 맞는 줄들을 실제로 검색하고, 그 줄들의 목록을 반환하는 기능을 테스트 주도로 구현할 것입니다. 이 기능은 search 라는 함수 안에 추가합니다.

실패하는 테스트 작성하기

src/lib.rs11장에서 했던 것처럼 tests 모듈과 테스트 함수를 추가합니다. 이 테스트 함수는 우리가 search 함수에 기대하는 동작을 명시합니다. 즉, 질의 문자열과 검색 대상 텍스트를 받아, 질의를 포함하는 줄만 반환해야 합니다. 목록 12-15가 그 테스트입니다.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --snip--

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-15: 우리가 원했던 기능을 위해 search 함수의 실패하는 테스트 만들기

이 테스트는 "duct" 라는 문자열을 검색합니다. 검색 대상 텍스트는 세 줄인데, 그중 "duct" 를 포함한 줄은 하나뿐입니다(열린 큰따옴표 뒤의 역슬래시는, 문자열 리터럴 내용 앞에 줄바꿈 문자가 들어가지 않게 하기 위한 것입니다). 우리는 search 함수가 반환한 값이 오직 "safe, fast, productive." 줄 하나만 담고 있는지 단언합니다.

지금 이 테스트를 실행하면 실패합니다. unimplemented! 매크로가 “not implemented” 메시지와 함께 패닉하기 때문입니다. TDD 원칙에 따라, 함수 호출 시 패닉만은 하지 않도록 딱 필요한 만큼의 코드만 먼저 추가하겠습니다. 이를 위해 search 함수가 항상 빈 벡터를 반환하도록 정의해 둡니다. 목록 12-16을 보세요. 그러면 테스트는 일단 컴파일되고, 이제는 “빈 벡터” 가 우리가 기대한 줄 하나와 다르기 때문에 실패하게 됩니다.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-16: search 를 호출했을 때 최소한 패닉하지 않게만 정의하기

이제 왜 search 시그니처에 명시적인 라이프타임 'a 를 넣고, contents 인수와 반환값 모두에 그 라이프타임을 붙여야 하는지 설명해 보겠습니다. [10장] ch10-lifetimes에서 보았듯이, 라이프타임 매개변수는 반환값 라이프타임이 어느 인수의 라이프타임과 연결되는지를 지정합니다. 이 경우 우리는 반환되는 벡터가 query 가 아니라 contents 의 일부를 가리키는 문자열 슬라이스들을 담아야 한다고 표현하고 있습니다.

즉, 우리는 search 함수가 반환하는 데이터가, contents 인수로 전달된 데이터만큼은 살아 있어야 한다고 러스트에게 알려 주는 것입니다. 이것은 중요합니다! 슬라이스가 가리키는 대상 데이터가 살아 있어야만 그 참조도 유효하기 때문입니다. 만약 컴파일러가 우리가 contents 가 아니라 query 를 기준으로 슬라이스를 만든다고 잘못 추측한다면, 안전성 검사를 엉뚱한 방식으로 하게 될 것입니다.

이 라이프타임 주석을 빼고 이 함수를 컴파일하려 하면, 다음과 같은 오류를 얻게 됩니다.

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
  |                      ----            ----         ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
  |
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
  |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

러스트는 두 매개변수 중 어느 쪽과 출력이 연결되는지 알 수 없기 때문에, 우리가 직접 명시해 주어야 합니다. 도움말은 모든 매개변수와 출력 타입에 같은 라이프타임 매개변수를 붙이라고 제안하지만, 그건 이 경우 틀립니다. 우리가 반환하고 싶은 것은 query 가 아니라 contents 일부이기 때문에, 반환값과 연결되어야 하는 매개변수는 contents 뿐입니다.

다른 프로그래밍 언어들은 보통 함수 시그니처에서 인수와 반환값을 이렇게 연결하라고 요구하지 않지만, 시간이 지나면 익숙해질 것입니다. 10장의 “라이프타임으로 참조의 유효성 검증하기” 절의 예들과 이 예를 비교해 보는 것도 도움이 됩니다.

테스트를 통과시키는 코드 작성하기

현재 테스트는 늘 빈 벡터를 반환하므로 실패합니다. 이를 고쳐 search 를 구현하려면, 프로그램은 다음 단계를 수행해야 합니다.

  1. 텍스트의 각 줄을 순회한다.
  2. 현재 줄이 질의 문자열을 포함하는지 검사한다.
  3. 포함한다면, 반환할 값 목록에 그 줄을 추가한다.
  4. 포함하지 않으면 아무 것도 하지 않는다.
  5. 매칭된 결과 목록을 반환한다.

이제 각 단계를 차례대로 구현해 봅시다. 먼저 줄 단위 반복부터 시작합니다.

lines 메서드로 줄 순회하기

러스트는 문자열을 줄 단위로 순회하는 데 아주 유용한 lines 메서드를 제공합니다. 목록 12-17이 이를 보여 줍니다. 다만 아직은 컴파일되지 않습니다.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-17: contents 의 각 줄을 순회하기

lines 메서드는 반복자를 반환합니다. 반복자는 13장 에서 자세히 다루겠지만, 목록 3-5에서 이미 비슷한 사용법을 본 적이 있습니다. 컬렉션 각 항목에 대해 코드를 실행하기 위해 for 루프와 반복자를 함께 썼었죠.

각 줄에서 질의 문자열 찾기

다음으로 현재 줄이 질의 문자열을 포함하는지 확인해야 합니다. 다행히 문자열에는 이를 위한 contains 라는 편리한 메서드가 이미 있습니다! 목록 12-18처럼 search 함수 안에 contains 호출을 추가해 보세요. 물론 이 상태에서는 아직도 컴파일되지 않습니다.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-18: 현재 줄이 query 안 문자열을 포함하는지 확인하는 기능 추가하기

지금은 기능을 차근차근 쌓아 올리는 중입니다. 이제 이 코드가 컴파일되려면, 함수 시그니처에서 약속한 대로 본문에서 값을 반환해야 합니다.

매칭된 줄 저장하기

이 함수를 완성하려면, 우리가 반환할 매칭 줄들을 저장할 방법이 필요합니다. 이를 위해 for 루프 전에 가변 벡터를 하나 만들고, 벡터에 line 을 넣기 위해 push 메서드를 호출하면 됩니다. for 루프가 끝나면 그 벡터를 반환합니다. 목록 12-19를 보세요.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-19: 반환할 수 있도록 매칭 줄들을 저장하기

이제 search 함수는 query 를 포함하는 줄만 반환해야 하므로, 우리의 테스트도 통과해야 합니다. 테스트를 실행해 봅시다.

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

테스트가 통과했습니다. 잘 동작한다는 뜻입니다!

이 시점에서, 테스트를 계속 통과시키면서 search 함수 구현을 리팩터링할 기회가 없는지 생각해 볼 수도 있습니다. 현재 search 안의 코드는 그렇게 나쁘지 않지만, 반복자의 몇 가지 유용한 기능을 활용하고 있지는 않습니다. 이 예제는 [13장] ch13-iterators에서 반복자를 자세히 다룰 때 다시 돌아와, 어떻게 개선할 수 있는지 살펴보겠습니다.

이제 프로그램 전체가 동작해야 합니다! Emily Dickinson 시에서 정확히 한 줄만 반환할 것으로 기대되는 단어 frog 를 넣어 실행해 봅시다.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

좋습니다! 이번에는 여러 줄과 매칭될 단어인 body 로 시도해 봅시다.

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

마지막으로, 시 안에 전혀 없는 단어인 monomorphization 을 검색했을 때는 아무 줄도 나오지 않는지 확인해 봅시다.

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

훌륭합니다. 우리는 고전적인 도구의 작은 버전을 직접 만들어 냈고, 애플리케이션을 어떻게 구조화하는지에 대해 많은 것을 배웠습니다. 파일 입출력, 라이프타임, 테스트, 명령줄 파싱에 대해서도 조금 더 실전적인 감각을 얻었습니다.

이 프로젝트를 마무리하기 위해, 환경 변수 다루기와 표준 에러에 출력하기를 짧게 보여 주겠습니다. 둘 다 커맨드라인 프로그램을 작성할 때 유용합니다.