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

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

검색 로직을 src/lib.rsmain 함수와 분리해 두었으니, 이제 코드의 핵심 기능에 대한 테스트를 쓰기가 훨씬 쉬워졌어요. 명령줄에서 바이너리를 호출할 필요 없이, 다양한 인자로 함수를 직접 호출하고 반환값을 확인하면 됩니다.

이 절에서는 테스트 주도 개발(TDD) 과정으로 minigrep 프로그램에 검색 로직을 추가할 거예요. 단계는 다음과 같습니다:

  1. 실패하는 테스트를 쓰고, 예상한 이유로 실패하는지 확인하기 위해 실행한다.
  2. 새 테스트가 통과하게 만드는 데 필요한 만큼만 코드를 작성하거나 수정한다.
  3. 방금 추가하거나 변경한 코드를 리팩터링하고 테스트가 계속 통과하는지 확인한다.
  4. 1단계부터 반복한다!

소프트웨어를 작성하는 여러 방법 중 하나일 뿐이지만, TDD는 코드 설계를 이끄는 데 도움을 줄 수 있어요. 테스트를 통과시키는 코드를 작성하기 전에 테스트를 먼저 쓰면, 과정 전반에 걸쳐 높은 테스트 커버리지를 유지하는 데 도움이 됩니다.

파일 내용에서 쿼리 문자열을 실제로 검색하고, 쿼리와 일치하는 줄 목록을 만드는 기능을 테스트 주도로 구현해 볼 거예요. 이 기능을 search라는 함수에 추가하겠습니다.

출처: The Rust Book

실패하는 테스트 쓰기

11장에서 했던 것처럼 src/lib.rs에 테스트 함수가 있는 tests 모듈을 추가할게요. 테스트 함수는 search 함수가 가져야 할 동작을 명시합니다. 쿼리와 검색할 텍스트를 받아, 텍스트에서 쿼리를 포함한 줄만 반환하는 거죠. Listing 12-15가 이 테스트를 보여줘요.

파일명: 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"를 포함하고 있어요(여는 큰따옴표 뒤의 백슬래시는 Rust에게 이 문자열 리터럴 내용의 시작 부분에 줄바꿈 문자를 넣지 말라고 알려줍니다). search 함수가 반환한 값이 우리가 기대하는 줄만 담고 있다고 단정합니다.

이 테스트를 실행하면, unimplemented! 매크로가 "not implemented"라는 메시지와 함께 패닉을 일으키므로 현재는 실패할 거예요. TDD 원칙에 따라, Listing 12-16처럼 search 함수가 항상 빈 벡터를 반환하도록 정의해서 함수를 호출했을 때 패닉이 나지 않게 하는 정도의 코드만 작은 걸음으로 추가할게요. 그러면 테스트는 컴파일될 것이고, 빈 벡터가 "safe, fast, productive."라는 줄을 담은 벡터와 일치하지 않으므로 실패할 거예요.

파일명: 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장에서 라이프타임 매개변수는 어떤 인자의 라이프타임이 반환값의 라이프타임과 연결되는지를 지정한다는 걸 기억하세요. 이 경우, 반환된 벡터가 (인자 query가 아니라) 인자 contents의 슬라이스를 참조하는 문자열 슬라이스를 담아야 한다고 나타냅니다.

쉽게 말해, search 함수가 반환하는 데이터가 contents 인자로 search 함수에 들어온 데이터만큼 오래 살 것이라고 Rust에 알려줍니다. 이건 중요해요! 슬라이스가 참조하는 데이터는 그 참조가 유효하려면 유효해야 합니다. 컴파일러가 우리가 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

Rust는 출력에 두 매개변수 중 어떤 것이 필요한지 알 수 없으므로 명시적으로 알려줘야 해요. 도움말 텍스트가 모든 매개변수와 출력 타입에 같은 라이프타임 매개변수를 지정하라고 제안하는데, 이건 틀린 조언이에요! contents가 우리 텍스트를 모두 담고 있는 매개변수이고 우리는 그 텍스트 중 일치하는 부분을 반환하려 하므로, 라이프타임 문법으로 반환값에 연결해야 하는 매개변수는 contents뿐임을 우리는 압니다.

다른 프로그래밍 언어는 시그니처에서 인자를 반환값에 연결할 것을 요구하지 않지만, 이 관행은 시간이 지나면서 쉬워질 거예요. 10장의 "라이프타임으로 참조 검증하기" 절의 예시와 이 예시를 비교해 보는 것도 좋겠습니다.

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

현재 테스트는 항상 빈 벡터를 반환하기 때문에 실패해요. 그것을 고치고 search를 구현하려면, 우리 프로그램은 다음 단계를 따라야 합니다:

  1. contents의 각 줄을 반복한다.
  2. 그 줄이 우리 쿼리 문자열을 담고 있는지 확인한다.
  3. 담고 있으면 반환할 값 목록에 추가한다.
  4. 담고 있지 않으면 아무것도 하지 않는다.
  5. 일치하는 결과 목록을 반환한다.

줄 단위 반복으로 시작해서 각 단계를 차근차근 진행해 볼게요.

lines 메서드로 줄 단위 반복하기

Rust에는 문자열을 줄 단위로 반복하는 유용한 메서드가 있는데, 알맞게도 lines라는 이름이에요. Listing 12-17처럼 동작합니다. 아직 컴파일되지는 않는다는 점에 주의하세요.

파일명: 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장에서 깊이 있게 이야기할 거예요. 하지만 Listing 3-5에서 컬렉션의 각 항목에 대해 코드를 실행하도록 반복자와 for 루프를 썼던 걸 기억하시죠? 그 사용 방식을 이렇게 다시 보는 셈이에요.

각 줄에 쿼리 검색하기

다음으로, 현재 줄이 우리 쿼리 문자열을 담고 있는지 확인할게요. 다행히 문자열에는 이걸 해 주는 contains라는 유용한 메서드가 있어요. Listing 12-18처럼 search 함수에 contains 메서드 호출을 추가합니다. 여전히 컴파일되지는 않는다는 점에 주의하세요.

파일명: 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 루프 전에 가변 벡터를 만들고, push 메서드를 호출해 line을 벡터에 저장할 수 있습니다. for 루프가 끝난 뒤 벡터를 반환하면 됩니다. Listing 12-19처럼요.

파일명: 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장에서 반복자를 자세히 살펴보고 이 예시로 돌아와 어떻게 개선할지 알아볼게요.

이제 프로그램 전체가 동작해야 해요! 에밀리 디킨슨 시에서 정확히 한 줄을 반환해야 하는 단어 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`

훌륭해요! 고전 도구의 미니 버전을 직접 만들면서, 애플리케이션을 어떻게 구조화하는지에 대해 많이 배웠어요. 파일 입출력, 라이프타임, 테스트, 명령줄 파싱에 대해서도 조금 배웠고요.

이 프로젝트를 마무리하면서, 환경 변수를 다루는 법과 표준 오류로 출력하는 법을 간단히 시연해 볼게요. 두 가지 모두 명령줄 프로그램을 작성할 때 유용하거든요.

더 알아보기 (Learn more)